From 38073714ca838342638263652359158589d7db02 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 23 Jun 2025 22:53:39 +0000 Subject: [PATCH 001/286] chore: tests --- README.md | 20 ++++- justfile | 21 +++--- localpost/consumers/kafka.py | 45 ++--------- localpost/consumers/sqs.py | 61 ++++----------- localpost/consumers/stream.py | 26 +++++-- localpost/flow/__init__.py | 3 +- localpost/flow/_batching.py | 138 ---------------------------------- localpost/flow/_flow.py | 45 +---------- localpost/flow/_ops.py | 69 ++++++++++++++++- localpost/flow/_stream.py | 105 ++++++++++++++++++++++++++ tests/consumers/stream.py | 58 ++++++++++++++ tests/flow/__init__.py | 0 tests/flow/combine.py | 15 ++++ tests/flow/flow.py | 47 ++++++++++++ tests/flow/middlewares.py | 29 +++++++ tests/flow/ops.py | 28 +++++++ 16 files changed, 419 insertions(+), 291 deletions(-) delete mode 100644 localpost/flow/_batching.py create mode 100644 localpost/flow/_stream.py create mode 100644 tests/consumers/stream.py create mode 100644 tests/flow/__init__.py create mode 100644 tests/flow/combine.py create mode 100644 tests/flow/flow.py create mode 100644 tests/flow/middlewares.py create mode 100644 tests/flow/ops.py diff --git a/README.md b/README.md index 373580e..0a850f1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,20 @@ TBD TBD -### Decorators (middlewares & wrappers) +### Building pipelines + +TBD, including: +- decorator style +- `<<` operator + +### Built-in ops + +TBD, including: +- map, filter, flatmap +- buffer +- batch + +### Custom middlewares TBD @@ -58,7 +71,10 @@ TBD ### Hosted services -TBD +TBD, including: +- combining multiple service together + - run services in parallel + - wrap service(s) with another one ### Running multiple services diff --git a/justfile b/justfile index 2dd124b..d9f1b48 100755 --- a/justfile +++ b/justfile @@ -9,8 +9,6 @@ deps: deps-upgrade: uv sync --all-groups --all-extras --upgrade -check: check-style types - [doc("Check types (using both PyRight and MyPy)")] types: -pyright --pythonpath $(which python) \ @@ -38,10 +36,6 @@ types-all: types type-coverage: pyright --pythonpath $(which python) --verifytypes localpost localpost/* -check-style: - ruff check localpost - ruff check examples tests - format: ruff check --fix localpost ruff format localpost @@ -50,15 +44,20 @@ format-all: format ruff check --fix examples tests ruff format examples tests -[doc("Inverse dependency tree for a package, to understand why it is installed")] -why package: - uv tree --invert --package {{ package }} +check file: + -ruff check --fix {{ file }} + -pyright --pythonpath $(which python) {{ file }} + -mypy --pretty --strict-bytes --python-executable $(which python) {{ file }} -test: +tests: pytest --cov-report=term --cov-report=xml --cov-branch --cov -v unit-tests: - pytest -m "not integration" --cov-report=term --cov-report=xml --cov-branch --cov -v + pytest -m "not integration" --cov-report=term --cov-branch --cov -v integration-tests: pytest -m "integration" -n auto -v + +[doc("Inverse dependency tree for a package, to understand why it is installed")] +why package: + uv tree --invert --package {{ package }} diff --git a/localpost/consumers/kafka.py b/localpost/consumers/kafka.py index cac3531..7809474 100644 --- a/localpost/consumers/kafka.py +++ b/localpost/consumers/kafka.py @@ -216,45 +216,14 @@ def kafka_config(**overrides) -> dict[str, Any]: return conf_from_env | conf_from_args -# @final -# class KafkaBroker: -# """ -# Convenient way to create and register Kafka consumers. -# """ -# -# def __init__(self, **config): -# conf_from_args = {k.replace("_", "."): v for k, v in config.items()} -# conf_from_env = kafka_conf_from_env() -# self.client_config = conf_from_env | conf_from_args -# -# def topic_consumer( -# self, topics: str | Iterable[str], /, *, consumers: int = 1 -# # ) -> Callable[[HandlerManager[KafkaMessage] | SyncHandlerManager[KafkaMessage]], HostedService]: -# ) -> Callable[[T], T]: -# def _decorator(handler): -# consumer = KafkaTopicConsumer( -# ensure_sync_handler(handler), -# [topics] if isinstance(topics, str) else topics, -# client_config=self.client_config, -# consumers=consumers, -# ) -# # return HostedService(consumer, name=f"KafkaTopicConsumer({topics!r})") -# return handler -# -# return _decorator - - # PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function def kafka_consumer( topics: str | Iterable[str], client_config: Mapping[str, Any] | None = None, /, *, consumers: int = 1 ) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumer]: - def decorator(handler: AnyHandlerManager[KafkaMessage]): - consumer = KafkaConsumer( - ensure_sync_handler_manager(handler), - [topics] if isinstance(topics, str) else topics, - client_config=client_config, - consumers=consumers, - ) - return consumer - - return decorator + """ Decorator to create a Kafka consumer hosted service. """ + return lambda handler: KafkaConsumer( + ensure_sync_handler_manager(handler), + [topics] if isinstance(topics, str) else topics, + client_config=client_config, + consumers=consumers, + ) diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py index 7e7299c..78c340b 100644 --- a/localpost/consumers/sqs.py +++ b/localpost/consumers/sqs.py @@ -2,7 +2,7 @@ import dataclasses as dc import logging -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict, cast, final @@ -11,16 +11,13 @@ from localpost import flow from localpost._utils import EventView, ensure_async_callable -from localpost.flow import AsyncHandler, AsyncHandlerManager, FlowHandlerManager +from localpost.flow import AnyHandlerManager, AsyncHandlerManager, FlowHandlerManager, ensure_async_handler_manager from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager if TYPE_CHECKING: from types_aiobotocore_sqs import SQSClient from types_aiobotocore_sqs.literals import MessageSystemAttributeNameType - from types_aiobotocore_sqs.type_defs import ( - MessageTypeDef, - ReceiveMessageRequestTypeDef, - ) + from types_aiobotocore_sqs.type_defs import MessageTypeDef, ReceiveMessageRequestTypeDef __all__ = [ "delete_messages", @@ -186,6 +183,7 @@ class SqsQueueConsumer(ExposedServiceBase): def __init__( self, handler: AsyncHandlerManager[SqsMessage], + /, queue_name: str, *, queue_url: str | None = None, @@ -212,7 +210,7 @@ async def _run_consumer( self, queue_url: str, client: "SQSClient", - message_handler: AsyncHandler[SqsMessage], + message_handler: Callable[[SqsMessage], Awaitable[None]], shutdown_scope: CancelScope, app_started: EventView, ): @@ -259,38 +257,11 @@ async def _resolve_url_from_name(): scope.cancel() -# @final -# class SqsBroker: -# def __init__(self, *, client_factory: ClientFactory | None = None): -# self.client_factory = client_factory or create_client -# -# def queue_consumer( -# self, queue_name_or_url: str, /, *, consumers: int = 1 -# ) -> Callable[[HandlerManager[SqsMessage]], HostedService]: -# if "/" in queue_name_or_url: -# queue_url = queue_name_or_url -# queue_name = _queue_name_from_url(queue_url) -# else: -# queue_url = None -# queue_name = queue_name_or_url -# -# def _decorator(handler) -> HostedService: -# consumer = SqsQueueConsumer( -# handler, -# queue_name=queue_name, -# queue_url=queue_url, -# client_factory=self.client_factory, -# consumers=consumers, -# ) -# return HostedService(consumer, name=f"SqsQueueConsumer({queue_name!r})") -# -# return _decorator - - # PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function def sqs_queue_consumer( queue_name_or_url: str, client_factory: ClientFactory | None = None, /, *, consumers: int = 1 -) -> Callable[[AsyncHandlerManager[SqsMessage]], SqsQueueConsumer]: +) -> Callable[[AnyHandlerManager[SqsMessage]], SqsQueueConsumer]: + """ Decorator to create an SQS queue consumer hosted service. """ if "/" in queue_name_or_url: queue_url = queue_name_or_url queue_name = _queue_name_from_url(queue_url) @@ -298,17 +269,13 @@ def sqs_queue_consumer( queue_url = None queue_name = queue_name_or_url - def decorator(handler): - consumer = SqsQueueConsumer( - handler, - queue_name=queue_name, - queue_url=queue_url, - client_factory=client_factory, - consumers=consumers, - ) - return consumer - - return decorator + return lambda handler: SqsQueueConsumer( + ensure_async_handler_manager(handler), + queue_name=queue_name, + queue_url=queue_url, + client_factory=client_factory, + consumers=consumers, + ) class LambdaEventRecordMessageAttributeValue(TypedDict): diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py index 432c5c7..5dc43c6 100644 --- a/localpost/consumers/stream.py +++ b/localpost/consumers/stream.py @@ -1,24 +1,26 @@ from __future__ import annotations -from typing import TypeVar, final +from collections.abc import Callable +from typing import TypeVar, final, Generic from anyio.streams.memory import MemoryObjectReceiveStream -from localpost.flow import AsyncHandlerManager -from localpost.flow._flow import stream_consumer +from localpost.flow import AnyHandlerManager, ensure_async_handler_manager, AsyncHandlerManager +from localpost.flow._stream import stream_consumer as _stream_consumer from localpost.hosting import ServiceLifetimeManager T = TypeVar("T") -__all__ = ["StreamConsumer"] +__all__ = ["StreamConsumer", "stream_consumer"] @final -class StreamConsumer: +class StreamConsumer(Generic[T]): def __init__( self, handler: AsyncHandlerManager[T], reader: MemoryObjectReceiveStream[T], + /, *, concurrency: int = 1, process_leftovers: bool = True, @@ -34,7 +36,7 @@ def __init__( async def __call__(self, service_lifetime: ServiceLifetimeManager): async with ( self.handler as message_handler, - stream_consumer( + _stream_consumer( self.reader, message_handler, self.concurrency, @@ -43,3 +45,15 @@ async def __call__(self, service_lifetime: ServiceLifetimeManager): ): service_lifetime.set_started() # Wait until the source channel is closed + + +def stream_consumer( + reader: MemoryObjectReceiveStream[T], /, *, concurrency: int = 1, process_leftovers: bool = True, +) -> Callable[[AnyHandlerManager[T]], StreamConsumer[T]]: + """ Decorator to create a stream consumer hosted service. """ + return lambda handler: StreamConsumer( + ensure_async_handler_manager(handler), + reader, + concurrency=concurrency, + process_leftovers=process_leftovers + ) diff --git a/localpost/flow/__init__.py b/localpost/flow/__init__.py index a662e35..d06411a 100644 --- a/localpost/flow/__init__.py +++ b/localpost/flow/__init__.py @@ -1,4 +1,3 @@ -from ._batching import batch from ._flow import ( AnyHandlerManager, AsyncHandler, @@ -15,7 +14,7 @@ handler_manager, handler_middleware, ) -from ._ops import buffer, delay, log_errors, skip_first +from ._ops import batch, buffer, delay, log_errors, skip_first __all__ = [ "handler", diff --git a/localpost/flow/_batching.py b/localpost/flow/_batching.py deleted file mode 100644 index ce64849..0000000 --- a/localpost/flow/_batching.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import math -from collections.abc import AsyncGenerator, Callable, Sequence -from contextlib import asynccontextmanager -from typing import Any, Literal, overload - -from anyio import ( - ClosedResourceError, - EndOfStream, - create_task_group, - move_on_after, -) -from anyio.abc import ObjectReceiveStream -from typing_extensions import TypeVar - -from localpost._utils import MemoryStream, start_task_soon - -from ._flow import ( - AsyncHandler, - FlowHandler, - HandlerDecorator, - ensure_async_handler, - handler_middleware, - logger, -) - -T = TypeVar("T", default=Any) -TC = TypeVar("TC", bound=Sequence[object], default=Sequence[object]) # A collection of T objects (for batching) - - -@overload -def batch( - batch_size: int, - batch_window: int | float, # Seconds - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Sequence[Any], Any]: ... - - -@overload -def batch( - batch_size: int, - batch_window: int | float, # Seconds - items_f: Callable[[Sequence[T]], TC], - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[TC, T]: ... - - -def batch( - batch_size: int, - batch_window: int | float, # Seconds - items_f: Callable[[Sequence[T]], TC] | None = None, - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Any, T]: - """ - Collect items into batches. - - A new batch is produced when `batch_size` is reached or `batch_window` expires. - """ - if batch_size < 1: - raise ValueError("Batch size must be greater than or equal to 1") - if batch_window < 0: - raise ValueError("Batch window must be greater than 0") - if capacity < 0: - raise ValueError("Buffer capacity must be greater than or equal to 0") - - @handler_middleware - async def _middleware(next_h: FlowHandler[Sequence[object]]) -> AsyncGenerator[FlowHandler[T]]: - buffer_writer, buffer_reader = MemoryStream.create(capacity) - stream_h = ensure_async_handler(next_h) - consumer = stream_batch_consumer(buffer_reader, stream_h, items_f, batch_size, batch_window, process_leftovers) - async with consumer, buffer_writer: # As usual, order matters - if math.isinf(capacity) or full_mode == "drop": - yield next_h.create(async_h=buffer_writer.send_or_drop_async, sync_h=buffer_writer.send_or_drop) - else: - yield FlowHandler.create_async(async_h=buffer_writer.send) - - return _middleware - - -@asynccontextmanager -async def stream_batch_consumer( # noqa: C901 (ignore complexity) - source: ObjectReceiveStream[T], - h: AsyncHandler[Sequence[object]], - items_f: Callable[[Sequence[T]], TC] | None, - batch_size: int, - batch_window: int | float, # Seconds - process_leftovers: bool = True, -): - async def read_batch() -> Sequence[T]: - items: list[T] = [] - try: - with move_on_after(batch_window): - while len(items) < batch_size: - message = await source.receive() - items.append(message) - return items - except EndOfStream: - if items: - return items # Return the last batch first - raise - - async def consume(): - while True: - try: - items = await read_batch() - if items: - await h(items_f(items) if items_f is not None else items) - except EndOfStream: - logger.debug("Source stream has been completed, no more items to consume") - break - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - break - - async with source, create_task_group() as tg: - start_task_soon(tg, consume) - - yield - - if process_leftovers: - # Process all the remaining items (until the source stream is completed) - pass - else: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await source.aclose() diff --git a/localpost/flow/_flow.py b/localpost/flow/_flow.py index b8b198c..6628cbc 100644 --- a/localpost/flow/_flow.py +++ b/localpost/flow/_flow.py @@ -3,7 +3,6 @@ import dataclasses as dc import inspect import logging -import math from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import ( AbstractAsyncContextManager, @@ -14,8 +13,7 @@ ) from typing import Any, Generic, ParamSpec, TypeAlias, cast, final -from anyio import ClosedResourceError, EndOfStream, create_task_group, to_thread -from anyio.abc import ObjectReceiveStream +from anyio import to_thread from typing_extensions import TypeVar from localpost._utils import ensure_async_callable, ensure_sync_callable, is_async_callable @@ -90,6 +88,7 @@ def handler(func: AnyHandler[T]) -> FlowHandlerManager[T]: Wrap a function, so it can be used as a flow handler. """ assert callable(func) + assert not inspect.isgeneratorfunction(func) and not inspect.isasyncgenfunction(func) return FlowHandlerManager(lambda: nullcontext(FlowHandler.ensure(func))) @@ -283,43 +282,3 @@ async def __aexit__(self, exc_type, exc_value, traceback): return await self._resolved_hm.__aexit__(exc_type, exc_value, traceback) finally: self._resolved_hm = None - - -@asynccontextmanager -async def stream_consumer( - source: ObjectReceiveStream[T], - h: AsyncHandler[T], - concurrency: int = 1, - process_leftovers: bool = True, -): - async def consume(handle_soon: bool): - while True: - try: - item = await source.receive() - - if handle_soon: # Infinite concurrency, just spawn a new task for each item - tg.start_soon(h, item) - else: - await h(item) - except EndOfStream: - logger.debug("Source stream has been completed, no more items to consume") - break - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - break - - async with source, create_task_group() as tg: - if math.isinf(concurrency): - tg.start_soon(consume, True) - else: - for _ in range(concurrency): - tg.start_soon(consume, False) - - yield - - if process_leftovers: - # Process all the remaining items (until the source stream is completed) - pass - else: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await source.aclose() diff --git a/localpost/flow/_ops.py b/localpost/flow/_ops.py index 6a1b5f2..ec17f44 100644 --- a/localpost/flow/_ops.py +++ b/localpost/flow/_ops.py @@ -2,8 +2,8 @@ import math import time -from collections.abc import Awaitable, Callable, Iterable -from typing import Any, Literal +from collections.abc import Awaitable, Callable, Iterable, Sequence, AsyncGenerator +from typing import Any, Literal, overload from typing_extensions import TypeVar @@ -15,18 +15,18 @@ ensure_sync_callable, sleep, ) - from ._flow import ( FlowHandler, HandlerDecorator, ensure_async_handler, handler_middleware, logger, - stream_consumer, ) +from ._stream import stream_batch_consumer, stream_consumer T = TypeVar("T", default=Any) T2 = TypeVar("T2", default=Any) +TC = TypeVar("TC", bound=Sequence[object], default=Sequence[object]) # A collection of T objects (for batching) R = TypeVar("R", Awaitable[None], None) @@ -133,6 +133,67 @@ async def middleware(next_h: FlowHandler): return middleware +@overload +def batch( + batch_size: int, + batch_window: int | float, # Seconds + /, + *, + capacity: int | float = 0, + process_leftovers: bool = True, + full_mode: Literal["wait", "drop"] = "wait", +) -> HandlerDecorator[Sequence[Any], Any]: ... + + +@overload +def batch( + batch_size: int, + batch_window: int | float, # Seconds + items_f: Callable[[Sequence[T]], TC], + /, + *, + capacity: int | float = 0, + process_leftovers: bool = True, + full_mode: Literal["wait", "drop"] = "wait", +) -> HandlerDecorator[TC, T]: ... + + +def batch( + batch_size: int, + batch_window: int | float, # Seconds + items_f: Callable[[Sequence[T]], TC] | None = None, + /, + *, + capacity: int | float = 0, + process_leftovers: bool = True, + full_mode: Literal["wait", "drop"] = "wait", +) -> HandlerDecorator[Any, T]: + """ + Collect items into batches. + + A new batch is produced when `batch_size` is reached or `batch_window` expires. + """ + if batch_size < 1: + raise ValueError("Batch size must be greater than or equal to 1") + if batch_window < 0: + raise ValueError("Batch window must be greater than 0") + if capacity < 0: + raise ValueError("Buffer capacity must be greater than or equal to 0") + + @handler_middleware + async def _middleware(next_h: FlowHandler[Sequence[object]]) -> AsyncGenerator[FlowHandler[T]]: + buffer_writer, buffer_reader = MemoryStream.create(capacity) + stream_h = ensure_async_handler(next_h) + consumer = stream_batch_consumer(buffer_reader, stream_h, items_f, batch_size, batch_window, process_leftovers) + async with consumer, buffer_writer: # As usual, order matters + if math.isinf(capacity) or full_mode == "drop": + yield next_h.create(async_h=buffer_writer.send_or_drop_async, sync_h=buffer_writer.send_or_drop) + else: + yield FlowHandler.create_async(async_h=buffer_writer.send) + + return _middleware + + def filter( # noqa func: Callable[[T], Awaitable[bool]] | Callable[[T], bool], ) -> HandlerDecorator[T, T]: diff --git a/localpost/flow/_stream.py b/localpost/flow/_stream.py new file mode 100644 index 0000000..f2d64af --- /dev/null +++ b/localpost/flow/_stream.py @@ -0,0 +1,105 @@ +import math +from collections.abc import Sequence, Callable +from contextlib import asynccontextmanager +from typing import Any + +from anyio import EndOfStream, ClosedResourceError, create_task_group, move_on_after +from anyio.abc import ObjectReceiveStream +from typing_extensions import TypeVar + +from ._flow import ( + AsyncHandler, + logger, +) +from .._utils import start_task_soon + +T = TypeVar("T", default=Any) +TC = TypeVar("TC", bound=Sequence[object], default=Sequence[object]) # A collection of T objects (for batching) + + +@asynccontextmanager +async def stream_consumer( + source: ObjectReceiveStream[T], + h: AsyncHandler[T], + concurrency: int = 1, + process_leftovers: bool = True, +): + async def consume(handle_soon: bool): + while True: + try: + item = await source.receive() + + if handle_soon: # Infinite concurrency, just spawn a new task for each item + tg.start_soon(h, item) + else: + await h(item) + except EndOfStream: + logger.debug("Source stream has been completed, no more items to consume") + break + except ClosedResourceError: + logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") + break + + async with source, create_task_group() as tg: + if math.isinf(concurrency): + tg.start_soon(consume, True) + else: + for _ in range(concurrency): + tg.start_soon(consume, False) + + yield + + if process_leftovers: + # Process all the remaining items (until the source stream is completed) + pass + else: + # Immediately stop consuming (close the receiver) and ignore the remaining items + await source.aclose() + + +@asynccontextmanager +async def stream_batch_consumer( # noqa: C901 (ignore complexity) + source: ObjectReceiveStream[T], + h: AsyncHandler[Sequence[object]], + items_f: Callable[[Sequence[T]], TC] | None, + batch_size: int, + batch_window: int | float, # Seconds + process_leftovers: bool = True, +): + async def read_batch() -> Sequence[T]: + items: list[T] = [] + try: + with move_on_after(batch_window): + while len(items) < batch_size: + message = await source.receive() + items.append(message) + return items + except EndOfStream: + if items: + return items # Return the last batch first + raise + + async def consume(): + while True: + try: + items = await read_batch() + if items: + await h(items_f(items) if items_f is not None else items) + except EndOfStream: + logger.debug("Source stream has been completed, no more items to consume") + break + except ClosedResourceError: + logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") + break + + async with source, create_task_group() as tg: + start_task_soon(tg, consume) + + yield + + if process_leftovers: + # Process all the remaining items (until the source stream is completed) + pass + else: + # Immediately stop consuming (close the receiver) and ignore the remaining items + await source.aclose() diff --git a/tests/consumers/stream.py b/tests/consumers/stream.py new file mode 100644 index 0000000..c0e25a8 --- /dev/null +++ b/tests/consumers/stream.py @@ -0,0 +1,58 @@ +import anyio +import pytest +from anyio import WouldBlock, create_memory_object_stream, create_task_group, sleep + +from localpost import flow +from localpost.consumers.stream import stream_consumer +from localpost.flow import batch +from localpost.hosting import Host + +pytestmark = [pytest.mark.anyio, pytest.mark.integration] + + +@pytest.fixture() +async def local_queue(): + writer, reader = create_memory_object_stream(10) + yield writer, reader + + +async def test_normal_case(local_queue): + writer, reader = local_queue + + # Arrange + + sent = ["hello", "world", "!"] + for message in sent: + writer.send_nowait(message) + + # Act + + received = [] + + @stream_consumer(reader) + @flow.handler + async def handle(m: str): + nonlocal received + received += [m] + + host = Host(handle) + async with host.aserve(): + await anyio.sleep(0.5) # "App is working" + writer.close() + + # Assert + + assert host.status["exception"] is None + assert received == sent + + +async def test_batching(local_queue): + writer, reader = local_queue + + # TODO Implement + + +async def test_buffering(local_queue): + writer, reader = local_queue + + # TODO Implement diff --git a/tests/flow/__init__.py b/tests/flow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flow/combine.py b/tests/flow/combine.py new file mode 100644 index 0000000..5f174d3 --- /dev/null +++ b/tests/flow/combine.py @@ -0,0 +1,15 @@ +import pytest + +pytestmark = pytest.mark.anyio + + +# resolved_service = flow_handler_manager | service_factory +def test_resolve_operator(): + # TODO Implement + pass + + +# flow_handler_manager2 = flow_handler_manager1 << skip_first(1) +def test_decorate_operator(): + # TODO Implement + pass diff --git a/tests/flow/flow.py b/tests/flow/flow.py new file mode 100644 index 0000000..8f74d15 --- /dev/null +++ b/tests/flow/flow.py @@ -0,0 +1,47 @@ +import pytest + +from localpost import flow +from localpost.flow import FlowHandlerManager + +pytestmark = pytest.mark.anyio + + +async def test_handler_decorator(): + handled_items = [] + + @flow.handler + def sample_handler(item): + handled_items.append(item) + + assert isinstance(sample_handler, FlowHandlerManager) + + async with sample_handler as handler: + handler("sample item") + + assert handled_items == ["sample item"] + + +async def test_handler_manager_decorator(): + handled_items = [] + + @flow.handler_manager + async def sample_handler_manager(): + def sample_handler(item): + handled_items.append(item) + + yield sample_handler + + assert isinstance(sample_handler_manager, FlowHandlerManager) + + async with sample_handler_manager as handler: + handler("sample item") + + assert handled_items == ["sample item"] + + + +# TODO Test ensure_async_handler +# TODO Test ensure_async_handler_manager + +# TODO Test ensure_sync_handler +# TODO Test ensure_sync_handler_manager diff --git a/tests/flow/middlewares.py b/tests/flow/middlewares.py new file mode 100644 index 0000000..6138d19 --- /dev/null +++ b/tests/flow/middlewares.py @@ -0,0 +1,29 @@ +import pytest + +from localpost import flow +from localpost.flow import FlowHandler, FlowHandlerManager + +pytestmark = pytest.mark.anyio + + +async def test_custom_middleware(): + handled_items = [] + + @flow.handler_middleware + async def custom_middleware(next_handler: FlowHandler): + def modified_handler(item): + handled_items.append("modified " + item) + + yield next_handler.create(sync_h=modified_handler) + + @custom_middleware + @flow.handler + def sample_handler(item): + handled_items.append(item) + + assert isinstance(sample_handler, FlowHandlerManager) + + async with sample_handler as handler: + handler("sample item") + + assert handled_items == ["modified sample item"] diff --git a/tests/flow/ops.py b/tests/flow/ops.py new file mode 100644 index 0000000..b8bbeeb --- /dev/null +++ b/tests/flow/ops.py @@ -0,0 +1,28 @@ +import pytest + +pytestmark = pytest.mark.anyio + + +async def test_skip_first(): + # Create a dummy flow and apply skip_first to it + pass # TODO Implement + + +async def test_buffer(): + # Create a dummy flow and apply buffer to it + pass # TODO Implement + + +async def test_filter(): + # Create a dummy flow and apply filter to it, with a condition + pass # TODO Implement + + +async def test_map(): + # Create a dummy flow and apply map to it, with a transformation function + pass # TODO Implement + + +async def test_flatmap(): + # Create a dummy flow and apply flatmap to it, with a transformation function + pass # TODO Implement From 9415691ce507bc7f5ddfd5407b00284f7eb3ad8e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 3 Jul 2025 20:52:52 +0000 Subject: [PATCH 002/286] WIP --- CHANGELOG.md | 31 +- localpost/__init__.py | 1 + localpost/_debug.py | 11 +- localpost/_utils.py | 29 +- localpost/consumers/kafka.py | 224 +++++++------ localpost/consumers/kafka_otel.py | 2 +- localpost/consumers/sqs.py | 312 +++++++++--------- localpost/consumers/sqs_otel.py | 2 +- localpost/consumers/stream.py | 60 ++-- localpost/consumers/stream_otel.py | 4 +- localpost/flow/__init__.py | 8 +- localpost/flow/_flow.py | 45 +-- localpost/flow/_ops.py | 70 ++-- localpost/flow/_stream.py | 147 ++++----- localpost/hosting/_host.py | 100 +++--- localpost/scheduler/__init__.py | 3 +- localpost/scheduler/_cond.py | 31 ++ pyproject.toml | 22 +- tests/consumers/sqs.py | 11 +- tests/flow/combine.py | 15 - tests/flow/custom_middlewares.py | 36 ++ tests/flow/middlewares.py | 35 +- tests/flow/ops.py | 29 +- .../{hosts_combine.py => service_ops.py} | 15 + tests/hosting/service_wrap.py | 18 - 25 files changed, 704 insertions(+), 557 deletions(-) delete mode 100644 tests/flow/combine.py create mode 100644 tests/flow/custom_middlewares.py rename tests/hosting/{hosts_combine.py => service_ops.py} (68%) delete mode 100644 tests/hosting/service_wrap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 672a247..72b9451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -## [0.4.0] - 2025-04-03 +## [0.5.0] - 2025-06-25 + +### Added + +- `localpost.consumers.stream` for in-memory queues +- `localpost.debug` context manager, to simplify debugging +- More tests + +### Changed + +- `localpost.consumers.kafka` reworked + +## [0.4.0] - 2025-06-23 ### Fixed @@ -24,28 +36,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `HostedService` class, to represent a named hosted service -- Hosted service middlewares: start_timeout, stop_timeout, and lifespan +- Hosted service middlewares: start_timeout, shutdown_timeout, and lifespan - Ability to combine multiple hosted services (`+` operator) - Ability to wrap a hosted service (or a set of services) by another one (`>>` operator) - `Host.state` (similar to `ServiceLifetime.state`) +- More tests ### Changed -- `EventView.__bool__()` instead of `EventView.is_set()` -- `app_host.AppService` instead of `app_host.HostedService` -- `localpost.flow_ops` has been merged into `localpost.flow` +- `EventView.__bool__()` in addition to `EventView.is_set()` +- `AppHost` reworked (simplified) +- `localpost.flow_ops` merged into `localpost.flow` ### Removed - `localpost.scheduler.serve()` & `localpost.scheduler.aserve()` (just use `Host` instead) -## [0.3.1] - 2025-03-13 - -### Added - -- More tests - -## [0.3.0] - 2025-03-11 +## [0.3.0] - 2025-03-12 ### Changed diff --git a/localpost/__init__.py b/localpost/__init__.py index 7152ac8..a7353bd 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -1,5 +1,6 @@ from ._debug import debug from ._run import arun, run +from ._utils import Result try: from .__meta__ import version as __version__ # noqa diff --git a/localpost/_debug.py b/localpost/_debug.py index f2a9a6f..fbb1088 100644 --- a/localpost/_debug.py +++ b/localpost/_debug.py @@ -1,7 +1,9 @@ from contextlib import AbstractContextManager +from localpost._utils import unwrap_exc -class DebugState(AbstractContextManager[None]): + +class DebugState(AbstractContextManager[None, None]): def __init__(self): self._entered = 0 @@ -11,8 +13,13 @@ def __bool__(self): def __enter__(self) -> None: self._entered += 1 - def __exit__(self, _, __, ___) -> None: + def __exit__(self, exc_type, exc_value: BaseException | None, traceback) -> None: self._entered -= 1 + if exc_value and self._entered == 0: + source_exc = unwrap_exc(exc_value) + if source_exc != exc_value: + # Re-raise the original exception for better debugging + raise source_exc from source_exc.__cause__ debug = DebugState() diff --git a/localpost/_utils.py b/localpost/_utils.py index 45315e6..f15ad1c 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -27,7 +27,7 @@ ) import anyio -from anyio import CancelScope, WouldBlock, create_task_group, from_thread, to_thread +from anyio import CancelScope, WouldBlock, create_task_group, from_thread, to_thread, CapacityLimiter from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, MemoryObjectStreamState @@ -69,12 +69,24 @@ def start_task_soon(tg: TaskGroup, func: Callable[[], Awaitable[Any]], name: obj tg.start_soon(func, name=name) # type: ignore -def unwrap_exc(exc: Exception) -> Exception: +def unwrap_exc(exc: BaseException) -> BaseException: if isinstance(exc, ExceptionGroup) and len(exc.exceptions) == 1: return unwrap_exc(exc.exceptions[0]) return exc +@dc.dataclass(frozen=True, slots=True) +class AsyncContextManagerAdapter(Generic[T]): + source: AbstractContextManager[T] + limiter: CapacityLimiter = dc.field(default_factory=lambda: CapacityLimiter(1)) + + def __aenter__(self) -> T: + return to_thread.run_sync(self.source.__enter__, limiter=self.limiter) + + def __aexit__(self, exc_type, exc_value, traceback): + return to_thread.run_sync(self.source.__exit__, exc_type, exc_value, traceback, limiter=self.limiter) + + class _SupportsClose(Protocol): def close(self) -> object: ... @@ -161,11 +173,15 @@ def is_async_callable(obj: Callable[..., Any] | object, _=None, /) -> TypeGuard[ def ensure_async_callable( - func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], / + func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], + /, + *, + max_threads: int | float | CapacityLimiter | None = None, ) -> Callable[P, Awaitable[T]]: if is_async_callable(func): return func - return functools.partial(to_thread.run_sync, func) # type: ignore[return-value] + limiter = CapacityLimiter(max_threads) if isinstance(max_threads, int | float) else max_threads + return functools.partial(to_thread.run_sync, func, limiter=limiter) # type: ignore[return-value] def ensure_sync_callable( @@ -344,10 +360,7 @@ def __await__(self) -> Generator[Any, Any, None]: @dc.dataclass(frozen=True, slots=True) class Event(EventView): - _source: anyio.Event - - def __init__(self) -> None: - object.__setattr__(self, "_source", anyio.Event()) + _source: anyio.Event = dc.field(default_factory=anyio.Event) def __repr__(self) -> str: return f"{self.__class__.__name__}(is_set={self._source.is_set()})" diff --git a/localpost/consumers/kafka.py b/localpost/consumers/kafka.py index 7809474..b18982c 100644 --- a/localpost/consumers/kafka.py +++ b/localpost/consumers/kafka.py @@ -1,38 +1,58 @@ -from __future__ import annotations - import dataclasses as dc import logging import os -from collections.abc import Callable, Iterable, Mapping, Sequence -from contextlib import AbstractContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, final +from collections.abc import Callable, Iterable, Mapping, Sequence, Collection +from contextlib import AbstractContextManager, AbstractAsyncContextManager, asynccontextmanager, AsyncExitStack +from functools import partial +from typing import Any, final, Final, TypeAlias import confluent_kafka -from anyio import CapacityLimiter, create_task_group, from_thread, to_thread -from confluent_kafka import TIMESTAMP_NOT_AVAILABLE, Consumer +from anyio import from_thread, to_thread, create_task_group, CapacityLimiter +from confluent_kafka import TIMESTAMP_NOT_AVAILABLE from localpost._utils import EventView -from localpost.flow import AnyHandlerManager, SyncHandlerManager, ensure_sync_handler_manager +from localpost.flow import AnyHandlerManager, ensure_sync_handler, SyncHandler from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager __all__ = [ "KafkaMessage", "KafkaMessages", - "KafkaConsumer", - # "KafkaBroker", - "kafka_config", + "kafka_config_from_env", "kafka_consumer", ] logger = logging.getLogger(__name__) +CHECK_INTERVAL: Final = 0.5 # seconds + + +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class ConsumerClient: + config: Mapping[str, Any] + topics: Sequence[str] + _client: confluent_kafka.Consumer + + def subscribe(self) -> None: + self._client.subscribe(self.topics) + + def poll(self) -> confluent_kafka.Message | None: + return self._client.poll(CHECK_INTERVAL) # Interrupt periodically, so we can respect the cancellation + + def store_offset(self, message: confluent_kafka.Message) -> None: + """ + Store the offset of the message, so it won't be redelivered (but only when `enable.auto.offset.store` is + actually disabled). + """ + if not self.config.get("enable.auto.offset.store", True): + self._client.store_offsets(message) + @final -@dc.dataclass(frozen=True, slots=True, eq=False) +@dc.dataclass(frozen=True, eq=False, slots=True) class KafkaMessage(AbstractContextManager[bytes, None]): payload: confluent_kafka.Message - _client: Consumer - _client_config: Mapping[str, Any] + _client: ConsumerClient def __repr__(self): return f"{self.__class__.__name__}(topic={self.payload.topic()!r}" @@ -60,9 +80,9 @@ def value(self) -> bytes: def try_ack(self) -> None: """ Store the offset of the message, so it won't be redelivered (but only when `enable.auto.offset.store` is - actually disabled. + actually disabled). """ - if not self._client_config.get("enable.auto.offset.store", True): + if not self._client.config.get("enable.auto.offset.store", True): self.ack() def ack(self) -> None: @@ -71,7 +91,7 @@ def ack(self) -> None: Works only if 'enable.auto.offset.store' is set to False! """ - self._client.store_offsets(self.payload) # Actual commit is done in the background + self._client.store_offset(self.payload) # Actual commit is done in the background @final @@ -81,16 +101,18 @@ class KafkaMessages(Sequence[KafkaMessage], AbstractContextManager[Sequence[byte Non-empty batch of Kafka messages. """ - payload: Sequence[KafkaMessage] + source: Sequence[KafkaMessage] - def __init__(self, payload: Sequence[KafkaMessage]): - if isinstance(payload, KafkaMessages): - payload = payload.payload - assert payload - object.__setattr__(self, "payload", payload) + def __post_init__(self): + if len(self.source) == 0: + raise ValueError(f"{self.__class__.__name__} must not be empty") + + @property + def payload(self) -> Sequence[confluent_kafka.Message]: + return [msg.payload for msg in self.source] def __enter__(self) -> Sequence[bytes]: - return [msg.value for msg in self.payload] + return [msg.value for msg in self.source] def __exit__(self, exc_type, exc_value, traceback) -> None: if exc_type is None: @@ -111,88 +133,96 @@ def ack(self) -> None: message.ack() +@dc.dataclass(frozen=True, eq=False, slots=True) +class KafkaConsumerThread: + client: ConsumerClient + handler: SyncHandler[KafkaMessage] + + def __call__(self, shutting_down: EventView) -> None: + while not shutting_down: + from_thread.check_cancelled() + poll_res = self.client.poll() + if poll_res is None: + continue + if error := poll_res.error(): + if error.retriable(): + logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) + continue + if error.fatal(): + raise RuntimeError(error.str()) + message = KafkaMessage(poll_res, self.client) + self.handler(message) + + @final -class KafkaConsumer(ExposedServiceBase): +class KafkaConsumerService(ExposedServiceBase): def __init__( self, - handler: SyncHandlerManager[KafkaMessage], - topics: Iterable[str], + cf: Callable[[], AbstractAsyncContextManager[ConsumerClient]], + handler_m: AnyHandlerManager[KafkaMessage], /, *, - client_config: Mapping[str, Any] | None = None, - consumers: int = 1, + consumers: int, ): super().__init__() + if consumers < 1: - raise ValueError("At least one consumer is required") + raise ValueError("Number of consumers must be at least 1") + + self.handler_m = handler_m + self.client_factory = cf + self.num_consumers = consumers + + async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: + assert self.num_consumers > 0 + self._lifetime = service_lifetime # Expose service lifetime events + threads_limiter = CapacityLimiter(self.num_consumers) + run_thread = partial(to_thread.run_sync, limiter=threads_limiter) + clients = [] + # Although Confluent Kafka's Consumer is thread-safe, it's not intended to be used concurrently: + # - by default, a message received from poll() is automatically acknowledged + # - OTEL instrumentation stores the current span in an object field, so concurrent calls to poll() will mess + # up the traces + async with AsyncExitStack() as client_stack, self.handler_m as handler: + logger.debug("Creating Kafka clients (1 per consumer)...") + for _ in range(self.num_consumers): + clients.append(await client_stack.enter_async_context(self.client_factory())) + logger.debug("Subscribing Kafka clients to topics...") + async with create_task_group() as tg: + for c in clients: + tg.start_soon(run_thread, c.subscribe) + message_handler = ensure_sync_handler(handler) + service_lifetime.set_started() + # Start pulling messages only after the whole app is started + await service_lifetime.host.started + async with create_task_group() as tg: + for c in clients: + tg.start_soon( + run_thread, + KafkaConsumerThread(c, message_handler), service_lifetime.shutting_down + ) + # Consumer threads will complete on shutdown - self.client_config: dict[str, Any] = dict(client_config or {}) - self.topics = list(topics) - self.handler = handler - self.consumers = consumers - self.poll_timeout = 0.5 + +@dc.dataclass(frozen=True, slots=True) +class ClientFactory: + config: Mapping[str, Any] + topics: Collection[str] @asynccontextmanager - async def _create_client(self): - # TODO stats_cb, to provide detailed debug information - # https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#kafka-client-configuration - # https://github.com/confluentinc/librdkafka/blob/master/STATISTICS.md - client = Consumer( - self.client_config, + async def __call__(self): + transport = confluent_kafka.Consumer( + self.config, logger=logger, # noqa ) - await to_thread.run_sync(client.subscribe, self.topics) # type: ignore + client = ConsumerClient(dict(self.config), list(self.topics), transport) try: yield client finally: - await to_thread.run_sync(client.close) # type: ignore - - def _run_consumer( - self, - client: Consumer, - message_handler: Callable[[KafkaMessage], None], - shutting_down: EventView, - ) -> None: - while not shutting_down: - from_thread.check_cancelled() - poll_res = client.poll(self.poll_timeout) # Poll with a short timeout, so we can respect the cancellation - if poll_res is None: - continue # Empty poll, check for cancellation and continue - if error := poll_res.error(): - if error.retriable(): - logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) - continue - if error.fatal(): - raise RuntimeError(error.str()) - message = KafkaMessage(poll_res, client, self.client_config) - message_handler(message) - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - assert self.consumers > 0 - threads_limiter = CapacityLimiter(self.consumers) - self._lifetime = service_lifetime - - def _consumer_thread(c): - return to_thread.run_sync( - self._run_consumer, - c, - message_handler, - service_lifetime.shutting_down, - # A custom limiter, to not reduce the global capacity permanently - limiter=threads_limiter, - ) - - # Make sure to create a task group _after_ resolving the handler, so we exit it only after all the consumer - # tasks are done - async with AsyncExitStack() as clients, self.handler as message_handler, create_task_group() as tg: - service_lifetime.set_started() - await service_lifetime.host.started # Start pulling messages only after the whole app is started - for _ in range(self.consumers): - client = await clients.enter_async_context(self._create_client()) - tg.start_soon(_consumer_thread, client) + await to_thread.run_sync(transport.close) -def kafka_config_from_env() -> dict[str, Any]: +def kafka_config_from_env(**overrides) -> dict[str, Any]: """ Construct a configuration dictionary for KAFKA_* environment variables. @@ -207,23 +237,21 @@ def _read_env_vars(): if var_name.startswith("KAFKA_"): yield var_name[6:].lower().replace("_", "."), var_val - return dict(_read_env_vars()) - - -def kafka_config(**overrides) -> dict[str, Any]: + conf_from_env = dict(_read_env_vars()) conf_from_args = {k.replace("_", "."): v for k, v in overrides.items()} - conf_from_env = kafka_config_from_env() return conf_from_env | conf_from_args # PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function def kafka_consumer( topics: str | Iterable[str], client_config: Mapping[str, Any] | None = None, /, *, consumers: int = 1 -) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumer]: +) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: """ Decorator to create a Kafka consumer hosted service. """ - return lambda handler: KafkaConsumer( - ensure_sync_handler_manager(handler), - [topics] if isinstance(topics, str) else topics, - client_config=client_config, + return lambda handler_m: KafkaConsumerService( + ClientFactory( + kafka_config_from_env(**(client_config or {})), + [topics] if isinstance(topics, str) else topics + ), + handler_m, consumers=consumers, ) diff --git a/localpost/consumers/kafka_otel.py b/localpost/consumers/kafka_otel.py index c2b4b14..7c25adf 100644 --- a/localpost/consumers/kafka_otel.py +++ b/localpost/consumers/kafka_otel.py @@ -71,6 +71,6 @@ def _handle_sync(item): with call_tracer(item): next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py index 78c340b..af4e75d 100644 --- a/localpost/consumers/sqs.py +++ b/localpost/consumers/sqs.py @@ -2,17 +2,21 @@ import dataclasses as dc import logging -from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence -from contextlib import AbstractAsyncContextManager, AsyncExitStack +import math +from collections.abc import Callable, Iterable, Mapping, Sequence +from contextlib import AbstractAsyncContextManager, asynccontextmanager, AbstractContextManager from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict, cast, final from aiobotocore.session import get_session from anyio import CancelScope, create_task_group from localpost import flow -from localpost._utils import EventView, ensure_async_callable -from localpost.flow import AnyHandlerManager, AsyncHandlerManager, FlowHandlerManager, ensure_async_handler_manager +from localpost._utils import ensure_async_callable, MemoryStream +from localpost.flow import AnyHandlerManager, AsyncHandlerManager, FlowHandlerManager, ensure_async_handler_manager, \ + AsyncHandler, ensure_async_handler +from localpost.flow._stream import create_stream_consumer, BatchReceiver from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager +from localpost.hosting.utils import ThreadSafeMemorySendStream if TYPE_CHECKING: from types_aiobotocore_sqs import SQSClient @@ -20,81 +24,104 @@ from types_aiobotocore_sqs.type_defs import MessageTypeDef, ReceiveMessageRequestTypeDef __all__ = [ - "delete_messages", "SqsMessage", "SqsMessages", - "SqsQueueConsumer", - # "SqsBroker", - "lambda_handler", "sqs_queue_consumer", + "lambda_handler", ] -ClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager["SQSClient"]] - logger = logging.getLogger(__name__) - -async def _delete_message_chunk(messages: Sequence[SqsMessage]): - client = messages[0]._client # noqa - queue_url = messages[0].queue_url - await client.delete_message_batch( - QueueUrl=queue_url, - Entries=[ - { - "Id": str(i), - "ReceiptHandle": message.receipt_handle, - } - for i, message in enumerate(messages) - ], - ) +_EMPTY_RECEIVE: Final[Sequence["MessageTypeDef"]] = () -def _split_messages(messages: Sequence[SqsMessage]) -> Iterable[Sequence[SqsMessage]]: - partitions: dict[str, list[SqsMessage]] = {} - for message in messages: - partitions.setdefault(message.queue_url, []).append(message) - for queue_messages in partitions.values(): - for i in range(0, len(queue_messages), 10): - yield queue_messages[i : i + 10] +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class ConsumerClient: + queue_name: str + queue_url: str + _client: object + + @classmethod + def create(cls, queue_name: str, queue_url: str) -> Self: + import boto3 + client = boto3.client("sqs") + + def receive(self, receive_req: "ReceiveMessageRequestTypeDef") -> Sequence["MessageTypeDef"]: + receive_req = receive_req | {"QueueUrl": self.queue_url} + # TODO Check HTTP status and retry on errors (exponential backoff) + pull_resp = await self._client.receive_message(**receive_req) + return pull_resp.get("Messages", _EMPTY_RECEIVE) + + def delete(self, messages: SqsMessage | Iterable[SqsMessage]) -> None: + if isinstance(message := messages, SqsMessage): + await self._client.delete_message( + QueueUrl=self.queue_url, + ReceiptHandle=message.receipt_handle, + ) + else: + await self._client.delete_message_batch( + QueueUrl=self.queue_url, + Entries=[ # type: ignore + {"Id": str(i), "ReceiptHandle": message.receipt_handle} + for i, message in enumerate(messages) + ], + ) -async def delete_messages(messages: Sequence[SqsMessage]): - """ - Delete multiple messages from the queue. +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class AsyncConsumerClient: + queue_name: str + queue_url: str + _client: "SQSClient" - AWS supports batches up to 10 messages. If the input list is longer, it will be split into chunks, and all the - chunks will be processed simultaneously. - """ - async with create_task_group() as tg: - for chunk in _split_messages(messages): - tg.start_soon(_delete_message_chunk, chunk) + async def receive(self, receive_req: "ReceiveMessageRequestTypeDef") -> Sequence["MessageTypeDef"]: + receive_req = receive_req | {"QueueUrl": self.queue_url} + # TODO Check HTTP status and retry on errors (exponential backoff) + pull_resp = await self._client.receive_message(**receive_req) + return pull_resp.get("Messages", _EMPTY_RECEIVE) + + async def delete(self, messages: SqsMessage | Iterable[SqsMessage]) -> None: + if isinstance(message := messages, SqsMessage): + await self._client.delete_message( + QueueUrl=self.queue_url, + ReceiptHandle=message.receipt_handle, + ) + else: + await self._client.delete_message_batch( + QueueUrl=self.queue_url, + Entries=[ # type: ignore + {"Id": str(i), "ReceiptHandle": message.receipt_handle} + for i, message in enumerate(messages) + ], + ) -# See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py +AsyncClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]] @final -@dc.dataclass(frozen=True, slots=True, eq=False) -class SqsMessage(AbstractAsyncContextManager[str, None]): +@dc.dataclass(frozen=True, slots=True) +class SqsMessage(AbstractContextManager["SqsMessage", None]): payload: "MessageTypeDef" """ Raw message data from the SQS queue. See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_Message.html. """ - queue_name: str - queue_url: str - _client: "SQSClient" + client: AsyncConsumerClient + ack_queue: ThreadSafeMemorySendStream[SqsMessage] def __repr__(self): - return f"{self.__class__.__name__}(queue_name={self.queue_name!r})" - - async def __aenter__(self) -> str: - return self.body + return f"{self.__class__.__name__}(queue_name={self.client.queue_name!r})" - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__(self, exc_type, exc_value, traceback) -> None: if exc_type is None: - await self.ack() + self.ack() + + def ack(self): + self.ack_queue.send_nowait(self) @property def receipt_handle(self): @@ -110,56 +137,38 @@ def body(self) -> str: def attributes(self): return self.payload.get("MessageAttributes", {}) - async def ack(self) -> None: - """ - Delete the message from the queue (acknowledge), otherwise it will reappear after the visibility timeout. - """ - await self._client.delete_message( - QueueUrl=self.queue_url, - ReceiptHandle=self.receipt_handle, - ) - @final @dc.dataclass(frozen=True, slots=True) -class SqsMessages(Sequence[SqsMessage], AbstractAsyncContextManager[Sequence[str], None]): - """ - Non-empty batch of SQS messages. - """ +class SqsMessages(Sequence[SqsMessage], AbstractContextManager[Sequence[SqsMessage], None]): + """ Non-empty batch of SQS messages. """ + + source: Sequence[SqsMessage] - payload: Sequence[SqsMessage] + def __init__(self, source: Sequence[SqsMessage]): + if isinstance(source, SqsMessages): + source = source.source + if len(source) == 0: + raise ValueError(f"{self.__class__.__name__} must not be empty") + object.__setattr__(self, "source", source) def __repr__(self): return f"{self.__class__.__name__}(len={len(self)})" - def __init__(self, payload: Sequence[SqsMessage]): - if isinstance(payload, SqsMessages): - payload = payload.payload - assert payload - object.__setattr__(self, "payload", payload) - - async def __aenter__(self) -> Sequence[str]: - return [m.body for m in self.payload] - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__(self, exc_type, exc_value, traceback) -> None: if exc_type is None: - await self.ack() + for message in self.source: + message.ack() + + @property + def payload(self) -> Sequence["MessageTypeDef"]: + return [msg.payload for msg in self.source] def __getitem__(self, item): - return self.payload[item] + return self.source[item] def __len__(self): - return len(self.payload) - - @property - def queue_name(self) -> str: - return self.payload[0].queue_name - - async def ack(self) -> None: - """ - Delete the messages from the queue (acknowledge), otherwise they will reappear after the visibility timeout. - """ - await delete_messages(self.payload) + return len(self.source) def _queue_name_from_url(url: str) -> str: @@ -169,35 +178,46 @@ def _queue_name_from_url(url: str) -> str: return parse_result.path.split("/")[-1] -def create_client() -> AbstractAsyncContextManager["SQSClient"]: - """ - Default SQS client factory. - """ - return get_session().create_client("sqs") +async def _queue_url_from_name(name: str, c: "SQSClient") -> str: + resolve_resp = await c.get_queue_url(QueueName=name) + return resolve_resp["QueueUrl"] -@final -class SqsQueueConsumer(ExposedServiceBase): - _EMPTY_RECEIVE: Final[Sequence["MessageTypeDef"]] = () +def client_factory(queue_name_or_url: str, /) -> AsyncClientFactory: + """ Default SQS client factory. """ + @asynccontextmanager + async def with_client(): + if "/" in queue_name_or_url: + queue_url = queue_name_or_url + queue_name = _queue_name_from_url(queue_url) + else: + queue_url = None + queue_name = queue_name_or_url + async with get_session().create_client("sqs") as transport: + if queue_url is None: + queue_url = await _queue_url_from_name(queue_name, transport) + client = AsyncConsumerClient(queue_name, queue_url, transport) + yield client + + return with_client # type: ignore[return-value] + +@final +class SqsConsumerService(ExposedServiceBase): def __init__( self, - handler: AsyncHandlerManager[SqsMessage], + cf: AsyncClientFactory, + handler_m: AsyncHandlerManager[SqsMessage], /, - queue_name: str, *, - queue_url: str | None = None, - client_factory: ClientFactory | None = None, - consumers: int = 1, + consumers: int, ): super().__init__() if consumers < 1: raise ValueError("Number of consumers must be at least 1") - self.queue_name = queue_name - self.queue_url = queue_url - self.handler = handler - self.client_factory: ClientFactory = client_factory or create_client + self.handler_m = handler_m + self.client_factory = cf self.consumers = consumers self.receive_req_template: "ReceiveMessageRequestTypeDef" = { "QueueUrl": "", # Will be filled in later @@ -206,74 +226,56 @@ def __init__( "WaitTimeSeconds": 20, } - async def _run_consumer( + # See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py + async def _consume( self, - queue_url: str, - client: "SQSClient", - message_handler: Callable[[SqsMessage], Awaitable[None]], + client: AsyncConsumerClient, + message_handler: AsyncHandler[SqsMessage], + ack_queue: ThreadSafeMemorySendStream[SqsMessage], shutdown_scope: CancelScope, - app_started: EventView, ): - async def pull_messages() -> Sequence["MessageTypeDef"]: - # TODO Check HTTP status and retry on errors (exponential backoff) - pull_resp = await client.receive_message(**receive_req) - return pull_resp.get("Messages", self._EMPTY_RECEIVE) - - receive_req: "ReceiveMessageRequestTypeDef" = self.receive_req_template | {"QueueUrl": queue_url} - messages = self._EMPTY_RECEIVE - with shutdown_scope: - await app_started # Start pulling messages only after the whole app is started while not shutdown_scope.cancel_called: + messages = _EMPTY_RECEIVE with shutdown_scope: - messages = await pull_messages() - if shutdown_scope.cancel_called: - break + messages = await client.receive(self.receive_req_template) if not messages: - logger.debug("No messages in the queue (empty receive)") - continue + continue # No messages received (empty queue or shutdown) for m in messages: - await message_handler(SqsMessage(m, self.queue_name, queue_url, client)) + await message_handler(SqsMessage(m, client, ack_queue)) async def __call__(self, service_lifetime: ServiceLifetimeManager): - self._lifetime = service_lifetime + self._lifetime = service_lifetime # Expose service lifetime events - async def _resolve_url_from_name(): - async with self.client_factory() as c: - resolve_resp = await c.get_queue_url(QueueName=self.queue_name) - return resolve_resp["QueueUrl"] + ack_queue_writer, ack_queue_reader = MemoryStream.create(math.inf) + batched_ack_queue_reader = BatchReceiver(ack_queue_reader, batch_size=10, batch_window=1.0) - queue_url = self.queue_url or await _resolve_url_from_name() + # Graceful shutdown scopes, one per consumer consumer_scopes = [CancelScope() for _ in range(self.consumers)] - app_started = service_lifetime.host.started - # Make sure to create a task group _after_ resolving the handler, so we exit it only after all the consumer - # tasks are done - async with AsyncExitStack() as clients, self.handler as mh, create_task_group() as tg: - for shutdown_scope in consumer_scopes: - client = await clients.enter_async_context(self.client_factory()) - tg.start_soon(self._run_consumer, queue_url, client, mh, shutdown_scope, app_started) + async with ( + self.client_factory() as client, + create_stream_consumer(batched_ack_queue_reader, client.delete, concurrency=math.inf), + ack_queue_writer, + self.handler_m as handler, + create_task_group() as tg): + message_handler = ensure_async_handler(handler) + robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) service_lifetime.set_started() + # Start pulling messages only after the whole app is started + await service_lifetime.host.started + for consumer_scope in consumer_scopes: + tg.start_soon(self._consume, client, message_handler, robust_ack_queue, consumer_scope) await service_lifetime.shutting_down - for scope in consumer_scopes: - scope.cancel() + for consumer_scope in consumer_scopes: + consumer_scope.cancel() # PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function def sqs_queue_consumer( - queue_name_or_url: str, client_factory: ClientFactory | None = None, /, *, consumers: int = 1 -) -> Callable[[AnyHandlerManager[SqsMessage]], SqsQueueConsumer]: + cf: AsyncClientFactory, /, *, consumers: int = 1 +) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: """ Decorator to create an SQS queue consumer hosted service. """ - if "/" in queue_name_or_url: - queue_url = queue_name_or_url - queue_name = _queue_name_from_url(queue_url) - else: - queue_url = None - queue_name = queue_name_or_url - - return lambda handler: SqsQueueConsumer( - ensure_async_handler_manager(handler), - queue_name=queue_name, - queue_url=queue_url, - client_factory=client_factory, + return lambda handler: SqsConsumerService( + cf, ensure_async_handler_manager(handler), consumers=consumers, ) diff --git a/localpost/consumers/sqs_otel.py b/localpost/consumers/sqs_otel.py index 586b4f7..6917f45 100644 --- a/localpost/consumers/sqs_otel.py +++ b/localpost/consumers/sqs_otel.py @@ -69,6 +69,6 @@ def _handle_sync(item): with call_tracer(item): next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py index 5dc43c6..975e19c 100644 --- a/localpost/consumers/stream.py +++ b/localpost/consumers/stream.py @@ -1,59 +1,53 @@ -from __future__ import annotations - +import math from collections.abc import Callable -from typing import TypeVar, final, Generic +from typing import TypeVar, Generic -from anyio.streams.memory import MemoryObjectReceiveStream +from anyio.abc import ObjectReceiveStream -from localpost.flow import AnyHandlerManager, ensure_async_handler_manager, AsyncHandlerManager -from localpost.flow._stream import stream_consumer as _stream_consumer +from localpost._utils import wait_any +from localpost.flow import AnyHandlerManager, ensure_async_handler +from localpost.flow._stream import create_stream_consumer from localpost.hosting import ServiceLifetimeManager T = TypeVar("T") -__all__ = ["StreamConsumer", "stream_consumer"] +__all__ = ["stream_consumer"] -@final -class StreamConsumer(Generic[T]): +class StreamConsumerService(Generic[T]): def __init__( self, - handler: AsyncHandlerManager[T], - reader: MemoryObjectReceiveStream[T], + target: ObjectReceiveStream[T], + handler: AnyHandlerManager[T], /, *, - concurrency: int = 1, - process_leftovers: bool = True, + concurrency: int, + process_leftovers: bool, ): - if concurrency < 1: + if not (math.isinf(concurrency) or (isinstance(concurrency, int) and concurrency >= 1)): raise ValueError("Number of consumers must be at least 1") - self.handler = handler - self.reader = reader + self.target = target + self.handler_m = handler self.concurrency = concurrency self.process_leftovers = process_leftovers async def __call__(self, service_lifetime: ServiceLifetimeManager): - async with ( - self.handler as message_handler, - _stream_consumer( - self.reader, - message_handler, - self.concurrency, - self.process_leftovers, - ), - ): + async with self.handler_m as handler, create_stream_consumer( + self.target, ensure_async_handler(handler, max_threads=self.concurrency), concurrency=self.concurrency, + ) as consumer_state: service_lifetime.set_started() - # Wait until the source channel is closed + if not self.process_leftovers: + await wait_any(service_lifetime.shutting_down, consumer_state.closed) + await self.target.aclose() + # Otherwise just wait for the stream to be exhausted and closed def stream_consumer( - reader: MemoryObjectReceiveStream[T], /, *, concurrency: int = 1, process_leftovers: bool = True, -) -> Callable[[AnyHandlerManager[T]], StreamConsumer[T]]: + target: ObjectReceiveStream[T], /, *, concurrency: int = 1, process_leftovers: bool = True, +) -> Callable[[AnyHandlerManager[T]], StreamConsumerService[T]]: """ Decorator to create a stream consumer hosted service. """ - return lambda handler: StreamConsumer( - ensure_async_handler_manager(handler), - reader, - concurrency=concurrency, - process_leftovers=process_leftovers + return lambda handler_m: StreamConsumerService( + target, handler_m, + concurrency=concurrency, process_leftovers=process_leftovers ) diff --git a/localpost/consumers/stream_otel.py b/localpost/consumers/stream_otel.py index 3016e4d..79ccf31 100644 --- a/localpost/consumers/stream_otel.py +++ b/localpost/consumers/stream_otel.py @@ -72,7 +72,7 @@ def _handle_sync(item): with call_tracer(item): next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware @@ -92,6 +92,6 @@ def _handle_sync(item): with call_tracer(item): next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware diff --git a/localpost/flow/__init__.py b/localpost/flow/__init__.py index d06411a..dc1d573 100644 --- a/localpost/flow/__init__.py +++ b/localpost/flow/__init__.py @@ -1,4 +1,5 @@ from ._flow import ( + AnyHandler, AnyHandlerManager, AsyncHandler, AsyncHandlerManager, @@ -9,7 +10,9 @@ SyncHandler, SyncHandlerManager, ensure_async_handler_manager, + ensure_async_handler, ensure_sync_handler_manager, + ensure_sync_handler, handler, handler_manager, handler_middleware, @@ -17,16 +20,19 @@ from ._ops import batch, buffer, delay, log_errors, skip_first __all__ = [ + "AnyHandler", + "AnyHandlerManager", "handler", "handler_manager", - "AnyHandlerManager", # async "AsyncHandler", "AsyncHandlerManager", + "ensure_async_handler", "ensure_async_handler_manager", # sync "SyncHandler", "SyncHandlerManager", + "ensure_sync_handler", "ensure_sync_handler_manager", # combinators "HandlerMiddleware", diff --git a/localpost/flow/_flow.py b/localpost/flow/_flow.py index 6628cbc..6327636 100644 --- a/localpost/flow/_flow.py +++ b/localpost/flow/_flow.py @@ -6,14 +6,13 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import ( AbstractAsyncContextManager, - AbstractContextManager, AsyncExitStack, asynccontextmanager, nullcontext, ) from typing import Any, Generic, ParamSpec, TypeAlias, cast, final -from anyio import to_thread +from anyio import CapacityLimiter from typing_extensions import TypeVar from localpost._utils import ensure_async_callable, ensure_sync_callable, is_async_callable @@ -60,17 +59,6 @@ logger = logging.getLogger("localpost.flow") -class _ThreadContext(AbstractAsyncContextManager): - def __init__(self, cm: AbstractContextManager): - self._source = cm - - async def __aenter__(self): - return await to_thread.run_sync(self._source.__enter__) # type: ignore - - async def __aexit__(self, exc_type, exc_value, traceback): - return await to_thread.run_sync(self._source.__exit__, exc_type, exc_value, traceback) - - # Too complex case, maybe for the future # def handler_manager_factory( # func: Union[ @@ -80,7 +68,7 @@ async def __aexit__(self, exc_type, exc_value, traceback): # Callable[P, Generator[Handler[T]]], # ], # ) -> Callable[P, HandlerManager[T]]: -# return cast(Callable[P, HandlerManager[T]], _HandlerManagerFactory.ensure(func)) +# ... def handler(func: AnyHandler[T]) -> FlowHandlerManager[T]: @@ -113,10 +101,14 @@ async def _handler_manager(*args, **kwargs): return _handler_manager -def ensure_async_handler(source: AnyHandler[T]) -> AsyncHandler[T]: - if isinstance(source, FlowHandler): - return source.async_h - return ensure_async_callable(source) +def ensure_async_handler( + h: AnyHandler[T], /, *, max_threads: int | float | CapacityLimiter | None = None, +) -> AsyncHandler[T]: + if isinstance(h, FlowHandler): + handle_async = h.async_h if h.is_async else ensure_async_callable(h.sync_h, max_threads=max_threads) + else: + handle_async = ensure_async_callable(h, max_threads=max_threads) + return handle_async def ensure_async_handler_manager(source: AnyHandlerManager[T]) -> AsyncHandlerManager[T]: @@ -152,12 +144,23 @@ def __call__(self, next_hm: FlowHandlerManager[T]) -> FlowHandlerManager[T2]: def handler_middleware(m: HandlerMiddleware[T, T2], /) -> HandlerDecorator[T, T2]: - assert inspect.isasyncgenfunction(m) return _HandlerMiddlewareDecorator(_handler_middleware(m)) def _handler_middleware(m: HandlerMiddleware[T, T2]) -> _HandlerMiddleware[T, T2]: - return _ensure_handler_manager_factory(asynccontextmanager(m)) + assert inspect.isasyncgenfunction(m) + source = asynccontextmanager(m) + + @asynccontextmanager + async def _handler_manager(next_h: FlowHandler[T]): + async with source(next_h) as h: + if isinstance(h, tuple) and len(h) == 2: + async_h, sync_h = h # If the handler is a tuple, it should be (async_h, sync_h) + yield next_h.create(async_h=async_h, sync_h=sync_h) + else: + yield FlowHandler.ensure(h) + + return _handler_manager @final @@ -175,6 +178,7 @@ def __post_init__(self): @classmethod def ensure(cls, h: AnyHandler[T]) -> FlowHandler[T]: + assert callable(h) if isinstance(h, FlowHandler): return h if is_async_callable(h): @@ -258,7 +262,6 @@ def _use(self, m: _HandlerMiddleware[T, T2]) -> FlowHandlerManager[T2]: return cast(FlowHandlerManager[T2], f) def use(self, m: HandlerMiddleware[T, T2], /) -> FlowHandlerManager[T2]: - assert inspect.isasyncgenfunction(m) return self._use(_handler_middleware(m)) # handler << handler_decorator diff --git a/localpost/flow/_ops.py b/localpost/flow/_ops.py index ec17f44..953f8c3 100644 --- a/localpost/flow/_ops.py +++ b/localpost/flow/_ops.py @@ -2,9 +2,10 @@ import math import time -from collections.abc import Awaitable, Callable, Iterable, Sequence, AsyncGenerator +from collections.abc import Awaitable, Callable, Iterable, Sequence, Collection from typing import Any, Literal, overload +from anyio import fail_after from typing_extensions import TypeVar from localpost._utils import ( @@ -22,11 +23,11 @@ handler_middleware, logger, ) -from ._stream import stream_batch_consumer, stream_consumer +from ._stream import create_stream_consumer, BatchReceiver T = TypeVar("T", default=Any) T2 = TypeVar("T2", default=Any) -TC = TypeVar("TC", bound=Sequence[object], default=Sequence[object]) # A collection of T objects (for batching) +TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) R = TypeVar("R", Awaitable[None], None) @@ -45,7 +46,7 @@ def _handle_sync(item): time.sleep(item_jitter.total_seconds()) next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware @@ -67,7 +68,7 @@ def _handle_sync(item): except Exception: # noqa h_logger.exception("Error while processing a message") - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware @@ -98,7 +99,7 @@ def _handle_sync(item): else: next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware @@ -111,9 +112,7 @@ def buffer( process_leftovers: bool = True, full_mode: Literal["wait", "drop"] = "wait", ) -> HandlerDecorator[Any, Any]: - """ - Buffer items in an in-memory stream. - """ + """ Buffer items in an async in-memory stream. """ if capacity < 0: raise ValueError("Buffer capacity must be greater than or equal to 0") if concurrency < 1: @@ -122,17 +121,29 @@ def buffer( @handler_middleware async def middleware(next_h: FlowHandler): buffer_writer, buffer_reader = MemoryStream.create(capacity) - stream_h = ensure_async_handler(next_h) - consumer = stream_consumer(buffer_reader, stream_h, concurrency, process_leftovers) + stream_h = ensure_async_handler(next_h, max_threads=concurrency) + consumer = create_stream_consumer(buffer_reader, stream_h, concurrency, process_leftovers) async with consumer, buffer_writer: # As usual, order matters if math.isinf(capacity) or full_mode == "drop": - yield next_h.create(async_h=buffer_writer.send_or_drop_async, sync_h=buffer_writer.send_or_drop) + yield buffer_writer.send_or_drop_async, buffer_writer.send_or_drop else: - yield FlowHandler.create_async(async_h=buffer_writer.send) + yield buffer_writer.send return middleware +# Implement later +# def sync_buffer( +# capacity: float, +# /, +# *, +# consumers: int = 1, +# process_leftovers: bool = True, +# full_mode: Literal["wait", "drop"] = "wait", +# ) -> HandlerDecorator[Any, Any]: +# pass + + @overload def batch( batch_size: int, @@ -181,19 +192,36 @@ def batch( raise ValueError("Buffer capacity must be greater than or equal to 0") @handler_middleware - async def _middleware(next_h: FlowHandler[Sequence[object]]) -> AsyncGenerator[FlowHandler[T]]: - buffer_writer, buffer_reader = MemoryStream.create(capacity) + async def _middleware(next_h: FlowHandler[Sequence[object]]): + buffer_writer, buffer_reader = MemoryStream[T].create(capacity) + buffer_batch_reader = BatchReceiver(buffer_reader, + batch_size, batch_window, items_f=items_f or (lambda x: x)) stream_h = ensure_async_handler(next_h) - consumer = stream_batch_consumer(buffer_reader, stream_h, items_f, batch_size, batch_window, process_leftovers) + consumer = create_stream_consumer(buffer_batch_reader, stream_h, + concurrency=1, + process_leftovers=process_leftovers) async with consumer, buffer_writer: # As usual, order matters if math.isinf(capacity) or full_mode == "drop": - yield next_h.create(async_h=buffer_writer.send_or_drop_async, sync_h=buffer_writer.send_or_drop) + yield buffer_writer.send_or_drop_async, buffer_writer.send_or_drop else: - yield FlowHandler.create_async(async_h=buffer_writer.send) + yield buffer_writer.send return _middleware +def timeout(duration: float, /) -> HandlerDecorator[T, T]: + """ Async timeout middleware. """ + @handler_middleware + async def middleware(next_h: FlowHandler[T]): + async def _handle_async(item: T): + with fail_after(duration): + await next_h.async_h(item) + + yield _handle_async + + return middleware + + def filter( # noqa func: Callable[[T], Awaitable[bool]] | Callable[[T], bool], ) -> HandlerDecorator[T, T]: @@ -210,7 +238,7 @@ def _handle_sync(item: T): if sync_filter(item): next_h.sync_h(item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware @@ -231,7 +259,7 @@ def _handle_sync(item: T2): mapped = sync_mapper(item) next_h.sync_h(mapped) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware @@ -254,6 +282,6 @@ def _handle_sync(item: T2) -> None: for mapped_item in mapped: next_h.sync_h(mapped_item) - yield next_h.create(async_h=_handle_async, sync_h=_handle_sync) + yield _handle_async, _handle_sync return middleware diff --git a/localpost/flow/_stream.py b/localpost/flow/_stream.py index f2d64af..b69568a 100644 --- a/localpost/flow/_stream.py +++ b/localpost/flow/_stream.py @@ -1,105 +1,92 @@ +import dataclasses as dc import math -from collections.abc import Sequence, Callable +from collections.abc import Callable, Collection from contextlib import asynccontextmanager -from typing import Any +from typing import Any, Generic from anyio import EndOfStream, ClosedResourceError, create_task_group, move_on_after from anyio.abc import ObjectReceiveStream from typing_extensions import TypeVar -from ._flow import ( - AsyncHandler, - logger, -) -from .._utils import start_task_soon +from localpost._utils import start_task_soon, EventView, Event +from ._flow import AsyncHandler, logger T = TypeVar("T", default=Any) -TC = TypeVar("TC", bound=Sequence[object], default=Sequence[object]) # A collection of T objects (for batching) +TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) + + +@dc.dataclass(frozen=True, eq=False, slots=True) +class StreamConsumerState: + closed: EventView @asynccontextmanager -async def stream_consumer( - source: ObjectReceiveStream[T], +async def create_stream_consumer( + stream: ObjectReceiveStream[T], h: AsyncHandler[T], + /, + *, concurrency: int = 1, - process_leftovers: bool = True, + process_leftovers: bool = True, # FIXME Remove ): - async def consume(handle_soon: bool): - while True: - try: - item = await source.receive() + if math.isinf(concurrency): + async def handle(item: T) -> None: + # Infinite concurrency, just spawn a new task for each item + tg.start_soon(h, item) + else: + handle = h - if handle_soon: # Infinite concurrency, just spawn a new task for each item - tg.start_soon(h, item) - else: - await h(item) - except EndOfStream: - logger.debug("Source stream has been completed, no more items to consume") - break - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - break + async def source_items(): + try: + while True: + yield await stream.receive() + except EndOfStream: + logger.debug("Source stream has been completed, no more items to process") + except ClosedResourceError: + logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") + finally: + closed.set() - async with source, create_task_group() as tg: - if math.isinf(concurrency): - tg.start_soon(consume, True) - else: - for _ in range(concurrency): - tg.start_soon(consume, False) + async def consume(): + async for item in source_items(): + await handle(item) - yield + closed = Event() + async with stream, create_task_group() as tg: + for _ in range(1 if math.isinf(concurrency) else concurrency): + start_task_soon(tg, consume) + yield StreamConsumerState(closed) - if process_leftovers: - # Process all the remaining items (until the source stream is completed) - pass - else: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await source.aclose() -@asynccontextmanager -async def stream_batch_consumer( # noqa: C901 (ignore complexity) - source: ObjectReceiveStream[T], - h: AsyncHandler[Sequence[object]], - items_f: Callable[[Sequence[T]], TC] | None, - batch_size: int, - batch_window: int | float, # Seconds - process_leftovers: bool = True, -): - async def read_batch() -> Sequence[T]: - items: list[T] = [] - try: - with move_on_after(batch_window): - while len(items) < batch_size: - message = await source.receive() - items.append(message) - return items - except EndOfStream: - if items: - return items # Return the last batch first - raise +@dc.dataclass(frozen=True, eq=False, slots=True) +class BatchReceiver(Generic[T, TC], ObjectReceiveStream[TC]): + source: ObjectReceiveStream[T] + batch_size: int + batch_window: float + items_f: Callable[[list[T]], TC] = lambda x: x - async def consume(): + def __post_init__(self): + if self.batch_size < 1: + raise ValueError("Batch size must be at least 1") + if self.batch_window <= 0: + raise ValueError("Batch window must be greater than 0 seconds") + + async def receive(self) -> TC: while True: + items = [] try: - items = await read_batch() - if items: - await h(items_f(items) if items_f is not None else items) + with move_on_after(self.batch_window): + while len(items) < self.batch_size: + message = await self.source.receive() + items.append(message) + if not items: + continue + return self.items_f(items) except EndOfStream: - logger.debug("Source stream has been completed, no more items to consume") - break - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - break - - async with source, create_task_group() as tg: - start_task_soon(tg, consume) - - yield - - if process_leftovers: - # Process all the remaining items (until the source stream is completed) - pass - else: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await source.aclose() + if items: + return self.items_f(items) # Return the last batch first + raise + + async def aclose(self) -> None: + await self.source.aclose() diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 99a3e7c..f73c3e7 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -5,6 +5,7 @@ import inspect import itertools import logging +import math import threading from abc import abstractmethod from collections.abc import AsyncGenerator, Awaitable, Callable, Collection, Generator, Iterable, Iterator, Mapping @@ -57,9 +58,7 @@ logger = logging.getLogger("localpost.hosting") -# A custom limiter for anyio.to_thread.run_sync (to avoid using the default limiter capacity for long-running tasks -# (hosted services)). Basically a custom thread pool. -sync_services_limiter = CapacityLimiter(1) +sync_services_limiter = CapacityLimiter(math.inf) # Separate thread pool for sync services (long-running tasks) @final @@ -153,7 +152,7 @@ def state(self) -> ServiceState: ... def status(self) -> ServiceStatus: ... # @property - # def child_services(self) -> Collection[ServiceLifetimeView]: ... + # def child_services(self) -> Sequence[ServiceLifetimeView]: ... @property def started(self) -> EventView: ... @@ -164,8 +163,6 @@ def shutting_down(self) -> EventView: ... @property def stopped(self) -> EventView: ... - # --- Common --- - @property def value(self) -> Any: ... @@ -191,7 +188,7 @@ def state(self) -> ServiceState: ... def status(self) -> ServiceStatus: ... # @property - # def child_services(self) -> Collection[ServiceLifetimeView]: ... + # def child_services(self) -> Sequence[ServiceLifetimeView]: ... @property def started(self) -> EventView: ... @@ -202,11 +199,14 @@ def shutting_down(self) -> EventView: ... @property def stopped(self) -> EventView: ... - # --- Common --- - @property def host(self) -> AbstractHost: ... + # @property + # def tg(self) -> TaskGroup: + # """ Task group for the service itself and its child services. """ + # ... + def set_started(self, value=None, /, *, graceful_shutdown_scope: CancelScope | None = None) -> None: ... def set_shutting_down(self, *, reason: BaseException | str | None = None) -> None: ... @@ -217,14 +217,29 @@ def set_shutting_down(self, *, reason: BaseException | str | None = None) -> Non # So just skip complex typing here, for now def start_child_service( # type: ignore[misc] self, - # func: Callable[[ServiceLifetimeManager, Unpack[PosArgsT]], Awaitable[None]], - func: Callable[..., Awaitable[None]], + func: ServiceFunc, + /, + *func_args, + name: str | None = None, + ) -> ServiceLifetime: ... + + # 1. PyCharm (at least 2024.03) has a bug when calling a TypeVarTuple-parameterized function with 0 arguments (see + # https://youtrack.jetbrains.com/issue/PY-63820), + # 2. mypy (at least 1.15.0) does not like overloads ("error: Incompatible return value type ... [return-value]"), + # So just skip complex typing here, for now + def start_child_service( # type: ignore[misc] + self, + func: ServiceFunc, /, - # *func_args: Unpack[PosArgsT], *func_args, name: str | None = None, ) -> ServiceLifetime: ... + @asynccontextmanager + async def run_services(self, targets: Iterable[ServiceFunc], /) -> AsyncGenerator[ServiceLifetime]: ... + + @asynccontextmanager + async def run_service(self, target: ServiceFunc, /) -> AsyncGenerator[ServiceLifetime]: ... # Everything that can be used as a hosted service, see HostedService.create() ServiceFunc: TypeAlias = Union[ @@ -274,12 +289,10 @@ def sync_service(func: Callable[..., Any]) -> HostedServiceFunc: @wraps(func) async def _service(lifetime: ServiceLifetimeManager): - sync_services_limiter.total_tokens += 1 await to_thread.run_sync(func, lifetime, limiter=sync_services_limiter) @wraps(func) async def _simple_service(lifetime: ServiceLifetimeManager): - sync_services_limiter.total_tokens += 1 with CancelScope() as run_scope: lifetime.set_started(graceful_shutdown_scope=run_scope) await to_thread.run_sync(func, limiter=sync_services_limiter) @@ -323,16 +336,12 @@ def __init__(self, name: str, host: AbstractHost, parent_tg: TaskGroup): self.shutdown_reason: BaseException | str | None = None self.stopped = Event() - self.exception: BaseException | None = None + self.exception: BaseException | None = None # If crashed or cancelled @property def as_view(self) -> ServiceLifetime: return self - @property - def as_manager(self) -> ServiceLifetimeManager: - return self - @property def state(self) -> ServiceState: if self.stopped: @@ -392,45 +401,56 @@ def start_child_service( /, *target_args, name: str | None = None, + raise_on_error: bool = True, ) -> _ServiceLifetime: if self.stopped: raise RuntimeError("Cannot start a child service for a stopped service") def start_service(): - svc = HostedService.ensure(func).named(name) + svc = HostedService.create(func).named(name) svc_lifetime = _ServiceLifetime(svc.name, self.host, self.tg) - self.child_services.append(svc_lifetime.as_view) - self.tg.start_soon(_run_service, svc, target_args, svc_lifetime, name=svc.name) + self.child_services.append(svc_lifetime) + self.tg.start_soon(_run_service, svc_lifetime, svc, target_args, False, name=svc.name) return svc_lifetime return in_host_thread(self.host, start_service) - -async def _run_service(func, func_args: Iterable[Any], svc_lifetime: _ServiceLifetime): - async def _supervise_service(): - await func(svc_lifetime, *func_args) - if child_services := svc_lifetime.child_services: - await svc_lifetime.shutting_down - for child in child_services: - child.shutdown() - + @asynccontextmanager + async def run_services(self, targets: Iterable[ServiceFunc]): + services = [HostedService.create(s) for s in targets] + service_lifetimes = [_ServiceLifetime(s.name, self.host, self.tg) for s in services] + async with create_task_group() as tg: + for s, sl in zip(services, service_lifetimes): + tg.start_soon(_run_service, sl, s, (), True, name=s.name) + await wait_all(sl.started for sl in service_lifetimes) + yield service_lifetimes + for sl in service_lifetimes: + sl.shutdown() + + + +async def _run_service( + svc_lifetime: _ServiceLifetime, svc_func, func_args: Iterable[Any], raise_on_error: bool +) -> None: svc_name = svc_lifetime.name logger.debug(f"Starting {svc_name}...") try: - # service_tg will be used for the service itself and its child services - async with svc_lifetime.tg as service_tg: - start_task_soon(service_tg, _supervise_service) + async with svc_lifetime.tg: # Used for the service itself and its child services + await svc_func(svc_lifetime, *func_args) + svc_lifetime.set_shutting_down() + for child in svc_lifetime.child_services: + child.shutdown() logger.debug(f"{svc_name} stopped") except get_cancelled_exc_class() as c_exc: # Cancellation exception inherits directly from BaseException svc_lifetime.exception = c_exc logger.error(f"{svc_name} got cancelled") raise # Always propagate the cancellation except Exception as exc: - exc = unwrap_exc(exc) - svc_lifetime.exception = exc - logger.exception(f"{svc_name} crashed", exc_info=exc) - if debug: - raise exc from exc.__cause__ # Re-raise the original exception for debugging + source_exc = unwrap_exc(exc) + svc_lifetime.exception = source_exc + logger.exception(f"{svc_name} crashed", exc_info=source_exc) + if raise_on_error: + raise # Re-raise the original exception for debugging finally: svc_lifetime.stopped.set() @@ -861,7 +881,7 @@ async def _aserve_in(self, portal: BlockingPortal, exec_tg: TaskGroup | None = N tg: TaskGroup = exec_tg if exec_tg else portal._task_group # noqa self._lifetime = sl = _ServiceLifetime(self.name, self, tg) self._exec_context = _HostExecContext(sl, portal, threading.get_ident(), sl.tg.cancel_scope) - tg.start_soon(_run_service, self._prepare_for_run(), (), sl) + tg.start_soon(_run_service, sl, self._prepare_for_run(), (), debug) try: yield cast(HostLifetime, self) finally: diff --git a/localpost/scheduler/__init__.py b/localpost/scheduler/__init__.py index a36e44e..258c0bb 100644 --- a/localpost/scheduler/__init__.py +++ b/localpost/scheduler/__init__.py @@ -1,4 +1,4 @@ -from ._cond import after, every +from ._cond import after, after_all, every from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Scheduler, Task, scheduled_task from ._trigger import delay, take_first, trigger_factory_middleware @@ -18,4 +18,5 @@ "take_first", "every", "after", + "after_all", ] diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index 0a0d601..6c579d4 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -26,6 +26,7 @@ sleep, start_task_soon, td_str, + Result ) from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Task, Trigger @@ -125,3 +126,33 @@ def after(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTempl Trigger an event every time the target task completes successfully. """ return ScheduledTaskTemplate(After(target if isinstance(target, Task) else target.task)) + + +@final +@dc.dataclass(frozen=True, slots=True) +class AfterAll(Generic[ResT]): + target: Task[Any, ResT] + + def __repr__(self): + return f"after_all({self.target!r})" + + def __call__(self, this_task: ScheduledTask) -> Trigger[Result[ResT]]: + task_exec_results = self.target.subscribe() + + async def run(): + try: + while True: + yield await task_exec_results.receive() + except EndOfStream: + logger.info("Target task completed, stopping") + finally: + task_exec_results.close() + + return ClosingContext(run()) + + +def after_all(target: ScheduledTask[Any, Result[T]] | Task[Any, Result[T]], /) -> ScheduledTaskTemplate[Result[T]]: + """ + Trigger an event every time the target task completes (successfully or not). + """ + return ScheduledTaskTemplate(AfterAll(target if isinstance(target, Task) else target.task)) diff --git a/pyproject.toml b/pyproject.toml index 2fa596f..6f8f427 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ scheduler = [ # TODO Better name ] sqs = [ "aiobotocore ~=2.15", + "botocore ~=1.37", # "aws-lambda-powertools", # To migrate from Lambda ] kafka = [ @@ -73,7 +74,16 @@ nats = [ "nats-py ~=2.8", ] rabbitmq = [ - "aio-pika >=9.1,<10.0", + "aio-pika ~=9.1", +# "pika ~=1.3", +] +pubsub = [ + "google-cloud-pubsub ~=2.28", +] +azure = [ + "azure-identity", + "azure-storage-queue ~=12.8", + "azure-servicebus ~=7.10", ] # Yay, https://peps.python.org/pep-0735/ is finally here! @@ -87,18 +97,20 @@ dev-consumers = [ "aws-lambda-powertools", "confluent-kafka[schemaregistry,protobuf]", "grpcio-tools ~=1.68", - "protobuf ~=5.0", ] dev-otel = [ "opentelemetry-exporter-otlp", # Both gRPC and HTTP "opentelemetry-instrumentation-confluent-kafka >=0.50b0", + "opentelemetry-instrumentation-aiokafka >=0.50b0", + "opentelemetry-instrumentation-botocore >=0.50b0", + "protobuf ~=5.0", # See https://github.com/open-telemetry/opentelemetry-python/issues/4639#issuecomment-2997003823 ] dev-types = [ "types-croniter", # "types-confluent-kafka", # Many signatures do not match to the actual implementation - "types-protobuf", +# "types-protobuf", # "grpc-stubs", # Hasn't been updated for 2+ years... - "types-boto3[sqs]", + "types-boto3-lite[sqs] ~=1.37", "types-aiobotocore[sqs] ~=2.15", ] examples = [ @@ -118,7 +130,7 @@ unit-tests = [ ] integration-tests = [ "boto3 ~=1.38", - "testcontainers[kafka,localstack,nats] ~=4.8", + "testcontainers ~=4.10", ] [tool.coverage.run] diff --git a/tests/consumers/sqs.py b/tests/consumers/sqs.py index 9d8df04..23f1182 100644 --- a/tests/consumers/sqs.py +++ b/tests/consumers/sqs.py @@ -6,6 +6,7 @@ import pytest from aiobotocore.session import get_session from testcontainers.localstack import LocalStackContainer +from types_boto3_sqs.client import SQSClient from localpost import flow from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer @@ -44,9 +45,10 @@ async def test_normal_case(local_sqs): # Arrange sent = ["hello", "world", "!"] - sqs_queue = boto3.resource("sqs", **local_sqs).create_queue(QueueName=queue_name) + sqs_client: SQSClient = boto3.client("sqs", **local_sqs) + sqs_queue = sqs_client.create_queue(QueueName=queue_name) for message in sent: - sqs_queue.send_message(MessageBody=message) + sqs_client.send_message(QueueUrl=sqs_queue["QueueUrl"], MessageBody=message) # Act @@ -78,9 +80,10 @@ async def test_batching(local_sqs): # Arrange sent = ["hello", "world", "!"] - sqs_queue = boto3.resource("sqs", **local_sqs).create_queue(QueueName=queue_name) + sqs_client: SQSClient = boto3.client("sqs", **local_sqs) + sqs_queue = sqs_client.create_queue(QueueName=queue_name) for message in sent: - sqs_queue.send_message(MessageBody=message) + sqs_client.send_message(QueueUrl=sqs_queue["QueueUrl"], MessageBody=message) # Act diff --git a/tests/flow/combine.py b/tests/flow/combine.py deleted file mode 100644 index 5f174d3..0000000 --- a/tests/flow/combine.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -pytestmark = pytest.mark.anyio - - -# resolved_service = flow_handler_manager | service_factory -def test_resolve_operator(): - # TODO Implement - pass - - -# flow_handler_manager2 = flow_handler_manager1 << skip_first(1) -def test_decorate_operator(): - # TODO Implement - pass diff --git a/tests/flow/custom_middlewares.py b/tests/flow/custom_middlewares.py new file mode 100644 index 0000000..327b812 --- /dev/null +++ b/tests/flow/custom_middlewares.py @@ -0,0 +1,36 @@ +import pytest + +from localpost import flow +from localpost.flow import FlowHandler, FlowHandlerManager + +pytestmark = pytest.mark.anyio + + +async def test_custom_middleware(): + handled_items = [] + + @flow.handler_middleware + async def custom_middleware(next_handler: FlowHandler): + async def modified_async_handler(item): + handled_items.append("modified async " + item) + await next_handler.async_h(item) + + def modified_sync_handler(item): + handled_items.append("modified sync " + item) + next_handler.sync_h(item) + + yield modified_async_handler, modified_sync_handler + + @custom_middleware + @flow.handler + def sample_handler(item): + handled_items.append(item) + + assert isinstance(sample_handler, FlowHandlerManager) + + async with sample_handler as handler: + handler("sample item") + + assert len(handled_items) == 2 + assert "modified sync sample item" in handled_items + assert "sample item" in handled_items diff --git a/tests/flow/middlewares.py b/tests/flow/middlewares.py index 6138d19..b8bbeeb 100644 --- a/tests/flow/middlewares.py +++ b/tests/flow/middlewares.py @@ -1,29 +1,28 @@ import pytest -from localpost import flow -from localpost.flow import FlowHandler, FlowHandlerManager - pytestmark = pytest.mark.anyio -async def test_custom_middleware(): - handled_items = [] +async def test_skip_first(): + # Create a dummy flow and apply skip_first to it + pass # TODO Implement + + +async def test_buffer(): + # Create a dummy flow and apply buffer to it + pass # TODO Implement - @flow.handler_middleware - async def custom_middleware(next_handler: FlowHandler): - def modified_handler(item): - handled_items.append("modified " + item) - yield next_handler.create(sync_h=modified_handler) +async def test_filter(): + # Create a dummy flow and apply filter to it, with a condition + pass # TODO Implement - @custom_middleware - @flow.handler - def sample_handler(item): - handled_items.append(item) - assert isinstance(sample_handler, FlowHandlerManager) +async def test_map(): + # Create a dummy flow and apply map to it, with a transformation function + pass # TODO Implement - async with sample_handler as handler: - handler("sample item") - assert handled_items == ["modified sample item"] +async def test_flatmap(): + # Create a dummy flow and apply flatmap to it, with a transformation function + pass # TODO Implement diff --git a/tests/flow/ops.py b/tests/flow/ops.py index b8bbeeb..5f174d3 100644 --- a/tests/flow/ops.py +++ b/tests/flow/ops.py @@ -3,26 +3,13 @@ pytestmark = pytest.mark.anyio -async def test_skip_first(): - # Create a dummy flow and apply skip_first to it - pass # TODO Implement +# resolved_service = flow_handler_manager | service_factory +def test_resolve_operator(): + # TODO Implement + pass -async def test_buffer(): - # Create a dummy flow and apply buffer to it - pass # TODO Implement - - -async def test_filter(): - # Create a dummy flow and apply filter to it, with a condition - pass # TODO Implement - - -async def test_map(): - # Create a dummy flow and apply map to it, with a transformation function - pass # TODO Implement - - -async def test_flatmap(): - # Create a dummy flow and apply flatmap to it, with a transformation function - pass # TODO Implement +# flow_handler_manager2 = flow_handler_manager1 << skip_first(1) +def test_decorate_operator(): + # TODO Implement + pass diff --git a/tests/hosting/hosts_combine.py b/tests/hosting/service_ops.py similarity index 68% rename from tests/hosting/hosts_combine.py rename to tests/hosting/service_ops.py index 45e2234..ea115e0 100644 --- a/tests/hosting/hosts_combine.py +++ b/tests/hosting/service_ops.py @@ -29,3 +29,18 @@ async def service3(lifetime): await sleep(0.1) assert host.status # TODO Check host.shutdown() + + +async def test_wrap_service(): + # Like HostedService(service_func1) >> service_func2 + pass # TODO Implement + + +async def test_wrap_multiple_services(): + # Like HostedService(service_func1) >> [service_func2, service_func3] + pass # TODO Implement + + +async def test_wrap_empty_set(): + # Like HostedService(service_func1) >> [] + pass # TODO Implement diff --git a/tests/hosting/service_wrap.py b/tests/hosting/service_wrap.py deleted file mode 100644 index b225534..0000000 --- a/tests/hosting/service_wrap.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -pytestmark = pytest.mark.anyio - - -async def test_wrap_service(): - # Like HostedService(service_func1) >> service_func2 - pass # TODO Implement - - -async def test_wrap_multiple_services(): - # Like HostedService(service_func1) >> [service_func2, service_func3] - pass # TODO Implement - - -async def test_wrap_empty_set(): - # Like HostedService(service_func1) >> [] - pass # TODO Implement From ee88f2b8fe3a9ed5f9cb5395d34ecc4952a8bbdd Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 6 Jul 2025 18:22:47 +0000 Subject: [PATCH 003/286] WIP --- README.md | 13 ++- localpost/__init__.py | 3 +- localpost/_utils.py | 2 +- localpost/consumers/kafka.py | 69 ++++++----- localpost/consumers/sqs.py | 217 +++++++++++++++++++---------------- localpost/flow/_stream.py | 2 +- localpost/hosting/_host.py | 19 --- localpost/hosting/utils.py | 27 +++++ pyproject.toml | 66 ++++++++++- tests/consumers/sqs.py | 7 +- 10 files changed, 263 insertions(+), 162 deletions(-) create mode 100644 localpost/hosting/utils.py diff --git a/README.md b/README.md index 0a850f1..d18415e 100644 --- a/README.md +++ b/README.md @@ -93,5 +93,14 @@ TBD, including: - FastAPI-like - decorators to create scheduled tasks & hosted services - middlewares -- Async first - - AnyIO backed (mainly for structured concurrency, compatibility with Trio as a bonus) +- async first + - AnyIO backed + - structured concurrency + - compatibility with Trio as a bonus + - easy threading (to support both sync and async consumers) + + +## Git Branch Policy + +The **only** stable branch is `master`. There will *never* be a `git push -f` on master. On the other hand, all other +branches are not considered stable; they may be deleted, rebased, force-pushed, and any other manner of funky business. diff --git a/localpost/__init__.py b/localpost/__init__.py index a7353bd..fac4533 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -2,10 +2,11 @@ from ._run import arun, run from ._utils import Result + try: from .__meta__ import version as __version__ # noqa except ImportError: __version__ = "dev" -__all__ = ["__version__", "arun", "run", "debug"] +__all__ = ["__version__", "arun", "run", "debug", "Result"] diff --git a/localpost/_utils.py b/localpost/_utils.py index f15ad1c..12a10b4 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -80,7 +80,7 @@ class AsyncContextManagerAdapter(Generic[T]): source: AbstractContextManager[T] limiter: CapacityLimiter = dc.field(default_factory=lambda: CapacityLimiter(1)) - def __aenter__(self) -> T: + def __aenter__(self) -> Awaitable[T]: return to_thread.run_sync(self.source.__enter__, limiter=self.limiter) def __aexit__(self, exc_type, exc_value, traceback): diff --git a/localpost/consumers/kafka.py b/localpost/consumers/kafka.py index b18982c..f04de54 100644 --- a/localpost/consumers/kafka.py +++ b/localpost/consumers/kafka.py @@ -4,10 +4,10 @@ from collections.abc import Callable, Iterable, Mapping, Sequence, Collection from contextlib import AbstractContextManager, AbstractAsyncContextManager, asynccontextmanager, AsyncExitStack from functools import partial -from typing import Any, final, Final, TypeAlias +from typing import Any, final, Final import confluent_kafka -from anyio import from_thread, to_thread, create_task_group, CapacityLimiter +from anyio import from_thread, to_thread, create_task_group, CapacityLimiter, CancelScope from confluent_kafka import TIMESTAMP_NOT_AVAILABLE from localpost._utils import EventView @@ -50,13 +50,19 @@ def store_offset(self, message: confluent_kafka.Message) -> None: @final @dc.dataclass(frozen=True, eq=False, slots=True) -class KafkaMessage(AbstractContextManager[bytes, None]): +class KafkaMessage(Sequence["KafkaMessage"], AbstractContextManager[bytes, None]): payload: confluent_kafka.Message _client: ConsumerClient def __repr__(self): return f"{self.__class__.__name__}(topic={self.payload.topic()!r}" + def __len__(self): + return 1 + + def __getitem__(self, _): + return self + def __enter__(self) -> bytes: return self.value @@ -133,25 +139,24 @@ def ack(self) -> None: message.ack() -@dc.dataclass(frozen=True, eq=False, slots=True) -class KafkaConsumerThread: - client: ConsumerClient - handler: SyncHandler[KafkaMessage] - - def __call__(self, shutting_down: EventView) -> None: - while not shutting_down: - from_thread.check_cancelled() - poll_res = self.client.poll() - if poll_res is None: +def _consume_sync( + client: ConsumerClient, + message_handler: SyncHandler[KafkaMessage], + shutdown_scope: CancelScope +) -> None: + while not shutdown_scope.cancel_called: + from_thread.check_cancelled() + poll_res = client.poll() + if poll_res is None: + continue + if error := poll_res.error(): + if error.retriable(): + logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) continue - if error := poll_res.error(): - if error.retriable(): - logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) - continue - if error.fatal(): - raise RuntimeError(error.str()) - message = KafkaMessage(poll_res, self.client) - self.handler(message) + if error.fatal(): + raise RuntimeError(error.str()) + message = KafkaMessage(poll_res, client) + message_handler(message) @final @@ -176,16 +181,21 @@ def __init__( async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: assert self.num_consumers > 0 self._lifetime = service_lifetime # Expose service lifetime events + threads_limiter = CapacityLimiter(self.num_consumers) run_thread = partial(to_thread.run_sync, limiter=threads_limiter) - clients = [] + + # Graceful shutdown scopes, one per consumer task (thread) + consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] # Although Confluent Kafka's Consumer is thread-safe, it's not intended to be used concurrently: # - by default, a message received from poll() is automatically acknowledged # - OTEL instrumentation stores the current span in an object field, so concurrent calls to poll() will mess # up the traces + clients = [] + async with AsyncExitStack() as client_stack, self.handler_m as handler: logger.debug("Creating Kafka clients (1 per consumer)...") - for _ in range(self.num_consumers): + for _ in consumer_scopes: clients.append(await client_stack.enter_async_context(self.client_factory())) logger.debug("Subscribing Kafka clients to topics...") async with create_task_group() as tg: @@ -196,12 +206,11 @@ async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: # Start pulling messages only after the whole app is started await service_lifetime.host.started async with create_task_group() as tg: - for c in clients: - tg.start_soon( - run_thread, - KafkaConsumerThread(c, message_handler), service_lifetime.shutting_down - ) - # Consumer threads will complete on shutdown + for c, cs in zip(clients, consumer_scopes): + tg.start_soon(run_thread,_consume_sync, c, message_handler, cs) + await service_lifetime.shutting_down + for cs in consumer_scopes: + cs.cancel() @dc.dataclass(frozen=True, slots=True) @@ -250,7 +259,7 @@ def kafka_consumer( return lambda handler_m: KafkaConsumerService( ClientFactory( kafka_config_from_env(**(client_config or {})), - [topics] if isinstance(topics, str) else topics + [topics] if isinstance(topics, str) else list(topics) ), handler_m, consumers=consumers, diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py index af4e75d..d3e6c6a 100644 --- a/localpost/consumers/sqs.py +++ b/localpost/consumers/sqs.py @@ -7,25 +7,26 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager, AbstractContextManager from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict, cast, final -from aiobotocore.session import get_session -from anyio import CancelScope, create_task_group +from anyio import CancelScope, create_task_group, to_thread from localpost import flow -from localpost._utils import ensure_async_callable, MemoryStream +from localpost._utils import MemoryStream, is_async_callable from localpost.flow import AnyHandlerManager, AsyncHandlerManager, FlowHandlerManager, ensure_async_handler_manager, \ AsyncHandler, ensure_async_handler from localpost.flow._stream import create_stream_consumer, BatchReceiver from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager -from localpost.hosting.utils import ThreadSafeMemorySendStream +from localpost.hosting.utils import ThreadSafeMemorySendStream, ThreadSafeSendStream if TYPE_CHECKING: - from types_aiobotocore_sqs import SQSClient + from types_boto3_sqs import SQSClient as SyncTransport + from types_aiobotocore_sqs import SQSClient as AsyncTransport from types_aiobotocore_sqs.literals import MessageSystemAttributeNameType from types_aiobotocore_sqs.type_defs import MessageTypeDef, ReceiveMessageRequestTypeDef __all__ = [ "SqsMessage", "SqsMessages", + "async_client_factory", "sqs_queue_consumer", "lambda_handler", ] @@ -40,33 +41,19 @@ class ConsumerClient: queue_name: str queue_url: str - _client: object - - @classmethod - def create(cls, queue_name: str, queue_url: str) -> Self: - import boto3 - client = boto3.client("sqs") + _client: "SyncTransport" + receive_req_template: "ReceiveMessageRequestTypeDef" = dc.field(default_factory=lambda: { + "QueueUrl": "", # Will be filled in later + "MessageAttributeNames": ["All"], + "MaxNumberOfMessages": 10, + "WaitTimeSeconds": 20, + }) def receive(self, receive_req: "ReceiveMessageRequestTypeDef") -> Sequence["MessageTypeDef"]: - receive_req = receive_req | {"QueueUrl": self.queue_url} - # TODO Check HTTP status and retry on errors (exponential backoff) - pull_resp = await self._client.receive_message(**receive_req) - return pull_resp.get("Messages", _EMPTY_RECEIVE) + pass # TODO Implement def delete(self, messages: SqsMessage | Iterable[SqsMessage]) -> None: - if isinstance(message := messages, SqsMessage): - await self._client.delete_message( - QueueUrl=self.queue_url, - ReceiptHandle=message.receipt_handle, - ) - else: - await self._client.delete_message_batch( - QueueUrl=self.queue_url, - Entries=[ # type: ignore - {"Id": str(i), "ReceiptHandle": message.receipt_handle} - for i, message in enumerate(messages) - ], - ) + pass # TODO Implement @final @@ -74,49 +61,67 @@ def delete(self, messages: SqsMessage | Iterable[SqsMessage]) -> None: class AsyncConsumerClient: queue_name: str queue_url: str - _client: "SQSClient" - - async def receive(self, receive_req: "ReceiveMessageRequestTypeDef") -> Sequence["MessageTypeDef"]: - receive_req = receive_req | {"QueueUrl": self.queue_url} + _client: "AsyncTransport" + receive_req: "ReceiveMessageRequestTypeDef" = dc.field(default_factory=lambda: { + "QueueUrl": "", # Will be filled in later + "MessageAttributeNames": ["All"], + "MaxNumberOfMessages": 10, + "WaitTimeSeconds": 20, + }) + + def __post_init__(self): + object.__setattr__(self, "receive_req", self.receive_req | {"QueueUrl": self.queue_url}) + + async def receive(self) -> Sequence["MessageTypeDef"]: # TODO Check HTTP status and retry on errors (exponential backoff) - pull_resp = await self._client.receive_message(**receive_req) + pull_resp = await self._client.receive_message(**self.receive_req) return pull_resp.get("Messages", _EMPTY_RECEIVE) - async def delete(self, messages: SqsMessage | Iterable[SqsMessage]) -> None: - if isinstance(message := messages, SqsMessage): + async def delete(self, workload: Iterable[SqsMessage], /) -> None: + if isinstance(workload, SqsMessage): await self._client.delete_message( QueueUrl=self.queue_url, - ReceiptHandle=message.receipt_handle, + ReceiptHandle=workload.receipt_handle, ) else: await self._client.delete_message_batch( QueueUrl=self.queue_url, Entries=[ # type: ignore {"Id": str(i), "ReceiptHandle": message.receipt_handle} - for i, message in enumerate(messages) + for i, message in enumerate(workload) ], ) - +ClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient]] AsyncClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]] @final @dc.dataclass(frozen=True, slots=True) -class SqsMessage(AbstractContextManager["SqsMessage", None]): +class SqsMessage(Sequence["SqsMessage"], AbstractContextManager[str, None]): payload: "MessageTypeDef" """ Raw message data from the SQS queue. See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_Message.html. """ + client: AsyncConsumerClient - ack_queue: ThreadSafeMemorySendStream[SqsMessage] + ack_queue: ThreadSafeSendStream[SqsMessage] def __repr__(self): return f"{self.__class__.__name__}(queue_name={self.client.queue_name!r})" - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __len__(self): + return 1 + + def __getitem__(self, _): + return self + + def __enter__(self) -> str: + return self.body + + def __exit__(self, exc_type, _, __) -> None: if exc_type is None: self.ack() @@ -140,7 +145,7 @@ def attributes(self): @final @dc.dataclass(frozen=True, slots=True) -class SqsMessages(Sequence[SqsMessage], AbstractContextManager[Sequence[SqsMessage], None]): +class SqsMessages(Sequence[SqsMessage], AbstractContextManager[Sequence[str], None]): """ Non-empty batch of SQS messages. """ source: Sequence[SqsMessage] @@ -155,7 +160,16 @@ def __init__(self, source: Sequence[SqsMessage]): def __repr__(self): return f"{self.__class__.__name__}(len={len(self)})" - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __len__(self): + return len(self.source) + + def __getitem__(self, item) -> SqsMessage: + return self.source[item] # type: ignore[return-value] + + def __enter__(self) -> Sequence[str]: + return [msg.body for msg in self.source] + + def __exit__(self, exc_type, _, __) -> None: if exc_type is None: for message in self.source: message.ack() @@ -164,12 +178,6 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: def payload(self) -> Sequence["MessageTypeDef"]: return [msg.payload for msg in self.source] - def __getitem__(self, item): - return self.source[item] - - def __len__(self): - return len(self.source) - def _queue_name_from_url(url: str) -> str: from urllib.parse import urlparse @@ -178,30 +186,63 @@ def _queue_name_from_url(url: str) -> str: return parse_result.path.split("/")[-1] -async def _queue_url_from_name(name: str, c: "SQSClient") -> str: - resolve_resp = await c.get_queue_url(QueueName=name) - return resolve_resp["QueueUrl"] - - -def client_factory(queue_name_or_url: str, /) -> AsyncClientFactory: +def client_factory(queue_name_or_url: str, /) -> ClientFactory: """ Default SQS client factory. """ + import boto3 + @asynccontextmanager async def with_client(): + transport: "SyncTransport" = boto3.client("sqs") + + def _queue_url_from_name(n): + return transport.get_queue_url(QueueName=n)["QueueUrl"] + if "/" in queue_name_or_url: queue_url = queue_name_or_url queue_name = _queue_name_from_url(queue_url) else: - queue_url = None queue_name = queue_name_or_url + queue_url = await to_thread.run_sync(_queue_url_from_name, queue_name) + yield ConsumerClient(queue_name, queue_url, transport) + + return with_client # type: ignore[return-value] + + +def async_client_factory(queue_name_or_url: str, /) -> AsyncClientFactory: + """ Default SQS async client factory. """ + from aiobotocore.session import get_session + + @asynccontextmanager + async def with_client(): async with get_session().create_client("sqs") as transport: - if queue_url is None: - queue_url = await _queue_url_from_name(queue_name, transport) - client = AsyncConsumerClient(queue_name, queue_url, transport) - yield client + if "/" in queue_name_or_url: + queue_url = queue_name_or_url + queue_name = _queue_name_from_url(queue_url) + else: + queue_name = queue_name_or_url + queue_url = (await transport.get_queue_url(QueueName=queue_name))["QueueUrl"] + yield AsyncConsumerClient(queue_name, queue_url, transport) return with_client # type: ignore[return-value] +# See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py +async def _consume_async( + client: AsyncConsumerClient, + message_handler: AsyncHandler[SqsMessage], + ack_queue: ThreadSafeSendStream[SqsMessage], + shutdown_scope: CancelScope, +): + while not shutdown_scope.cancel_called: + messages = _EMPTY_RECEIVE + with shutdown_scope: + messages = await client.receive() + if not messages: + continue # No messages received (empty queue or shutdown) + for m in messages: + await message_handler(SqsMessage(m, client, ack_queue)) + + @final class SqsConsumerService(ExposedServiceBase): def __init__( @@ -216,41 +257,20 @@ def __init__( if consumers < 1: raise ValueError("Number of consumers must be at least 1") - self.handler_m = handler_m self.client_factory = cf - self.consumers = consumers - self.receive_req_template: "ReceiveMessageRequestTypeDef" = { - "QueueUrl": "", # Will be filled in later - "MessageAttributeNames": ["All"], - "MaxNumberOfMessages": 10, - "WaitTimeSeconds": 20, - } - - # See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py - async def _consume( - self, - client: AsyncConsumerClient, - message_handler: AsyncHandler[SqsMessage], - ack_queue: ThreadSafeMemorySendStream[SqsMessage], - shutdown_scope: CancelScope, - ): - while not shutdown_scope.cancel_called: - messages = _EMPTY_RECEIVE - with shutdown_scope: - messages = await client.receive(self.receive_req_template) - if not messages: - continue # No messages received (empty queue or shutdown) - for m in messages: - await message_handler(SqsMessage(m, client, ack_queue)) + self.handler_m = handler_m + self.num_consumers = consumers async def __call__(self, service_lifetime: ServiceLifetimeManager): self._lifetime = service_lifetime # Expose service lifetime events ack_queue_writer, ack_queue_reader = MemoryStream.create(math.inf) batched_ack_queue_reader = BatchReceiver(ack_queue_reader, batch_size=10, batch_window=1.0) + robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) + + # Graceful shutdown scopes, one per consumer task (thread) + consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] - # Graceful shutdown scopes, one per consumer - consumer_scopes = [CancelScope() for _ in range(self.consumers)] async with ( self.client_factory() as client, create_stream_consumer(batched_ack_queue_reader, client.delete, concurrency=math.inf), @@ -258,12 +278,11 @@ async def __call__(self, service_lifetime: ServiceLifetimeManager): self.handler_m as handler, create_task_group() as tg): message_handler = ensure_async_handler(handler) - robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) service_lifetime.set_started() # Start pulling messages only after the whole app is started await service_lifetime.host.started for consumer_scope in consumer_scopes: - tg.start_soon(self._consume, client, message_handler, robust_ack_queue, consumer_scope) + tg.start_soon(_consume_async, client, message_handler, robust_ack_queue, consumer_scope) await service_lifetime.shutting_down for consumer_scope in consumer_scopes: consumer_scope.cancel() @@ -369,22 +388,16 @@ def __init__(self) -> None: def lambda_handler( lambda_h: Callable[[LambdaEvent, LambdaInvocationContext], object], / -) -> FlowHandlerManager[SqsMessage | Sequence[SqsMessage]]: +) -> FlowHandlerManager[Sequence[SqsMessage]]: + assert not is_async_callable(lambda_h) lambda_inv_context = LambdaInvocationContext() - async_lambda_h = ensure_async_callable(lambda_h) @flow.handler - async def _handler(workload: SqsMessage | Sequence[SqsMessage]) -> None: + def _handler(workload: Sequence[SqsMessage]) -> None: lambda_event: LambdaEvent = {"Records": []} - if isinstance(workload, SqsMessage): - message = workload - lambda_event["Records"] = [_message2lambda(message)] - async with message: - await async_lambda_h(lambda_event, lambda_inv_context) - else: - messages = SqsMessages(cast(Sequence[SqsMessage], workload)) - lambda_event["Records"] = [_message2lambda(m) for m in messages] - async with messages: - await async_lambda_h(lambda_event, lambda_inv_context) + messages = SqsMessages(workload) + lambda_event["Records"] = [_message2lambda(m) for m in messages] + with messages: + lambda_h(lambda_event, lambda_inv_context) return _handler diff --git a/localpost/flow/_stream.py b/localpost/flow/_stream.py index b69568a..08f925e 100644 --- a/localpost/flow/_stream.py +++ b/localpost/flow/_stream.py @@ -26,7 +26,7 @@ async def create_stream_consumer( h: AsyncHandler[T], /, *, - concurrency: int = 1, + concurrency: int | float = 1, process_leftovers: bool = True, # FIXME Remove ): if math.isinf(concurrency): diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index f73c3e7..cda2fb2 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -235,12 +235,6 @@ def start_child_service( # type: ignore[misc] name: str | None = None, ) -> ServiceLifetime: ... - @asynccontextmanager - async def run_services(self, targets: Iterable[ServiceFunc], /) -> AsyncGenerator[ServiceLifetime]: ... - - @asynccontextmanager - async def run_service(self, target: ServiceFunc, /) -> AsyncGenerator[ServiceLifetime]: ... - # Everything that can be used as a hosted service, see HostedService.create() ServiceFunc: TypeAlias = Union[ Callable[..., Awaitable[None]], @@ -415,19 +409,6 @@ def start_service(): return in_host_thread(self.host, start_service) - @asynccontextmanager - async def run_services(self, targets: Iterable[ServiceFunc]): - services = [HostedService.create(s) for s in targets] - service_lifetimes = [_ServiceLifetime(s.name, self.host, self.tg) for s in services] - async with create_task_group() as tg: - for s, sl in zip(services, service_lifetimes): - tg.start_soon(_run_service, sl, s, (), True, name=s.name) - await wait_all(sl.started for sl in service_lifetimes) - yield service_lifetimes - for sl in service_lifetimes: - sl.shutdown() - - async def _run_service( svc_lifetime: _ServiceLifetime, svc_func, func_args: Iterable[Any], raise_on_error: bool diff --git a/localpost/hosting/utils.py b/localpost/hosting/utils.py new file mode 100644 index 0000000..c259c9f --- /dev/null +++ b/localpost/hosting/utils.py @@ -0,0 +1,27 @@ +import dataclasses as dc +from typing import TypeVar, final, Protocol + +from anyio.streams.memory import MemoryObjectSendStream + +from localpost.hosting import AbstractHost + +T = TypeVar("T", contravariant=True) + +__all__ = ["ThreadSafeSendStream", "ThreadSafeMemorySendStream"] + + +class ThreadSafeSendStream(Protocol[T]): + def send_nowait(self, item: T) -> None: ... + + +@final +@dc.dataclass(frozen=True, slots=True) +class ThreadSafeMemorySendStream(ThreadSafeSendStream[T]): + source: MemoryObjectSendStream[T] + _host: AbstractHost + + def send_nowait(self, item: T) -> None: + if self._host.same_thread: + self.source.send_nowait(item) + else: + self._host.portal.start_task_soon(self.source.send_nowait, item).result() diff --git a/pyproject.toml b/pyproject.toml index 6f8f427..3da6c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,6 @@ integration-tests = [ [tool.coverage.run] omit = ["tests/*"] - [tool.pyright] include = ["localpost"] @@ -149,18 +148,75 @@ line-length = 120 format.docstring-code-format = true [tool.ruff.lint] +# For the reference, check https://docs.astral.sh/ruff/rules/ select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings "F", # pyflakes + "E", "W", # pycodestyle "I", # isort - "C", # flake8-comprehensions - "B", # flake8-bugbear "Q", # flake8-quotes + "UP", # pyupgrade + "YTT", # flake8-2020 + # TODO Uncomment after adding annotations to all modules + # All "ANN" rules are covered by mypy, except "ANN204" +# "ANN204", # Missing return type annotation for special method ... + "ASYNC", # flake8-async + "S", # flake8-bandit + "BLE", # flake8-blind-except + "B", # flake8-bugbear + "A", # flake8-builtins + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "EXE", # flake8-executable + "FA", # flake8-future-annotations + # XXX Causes warning https://github.com/astral-sh/ruff/issues/8272 + "ISC", # flake8-implicit-str-concat + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "PT", # flake8-pytest-style + # The rest of RET rules don't improve code and may even worsen readability + "RET501", "RET502", "RET503", # flake8-return + "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "PTH", # flake8-use-pathlib +# "FIX", # flake8-fixme + "PGH", # pygrep-hooks + "PL", # Pylint + "TRY", # tryceratops + "PERF", # Perflint + "LOG", # flake8-logging + "RUF", # Ruff-specific +] + +ignore = [ + "S101", # Use of `assert` detected + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "B011", # Do not `assert False` + "SIM108", # Use ternary operator + # TODO Remove when we drop support for Python <=3.10 + "SIM117", # Use a single `with` statement with multiple contexts + "PLR0913", # Too many arguments in function definition + "PLR2004", # Magic value used in comparison + # TODO Reduce complexity + "PLR0911", "PLR0912", "PLR0915", # Too many ... + # Too many false positives + "PLW2901", # ... loop variable ... overwritten by assignment target + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` ] + +pyupgrade.keep-runtime-typing = true + [tool.ruff.lint.pydocstyle] convention = "google" +[tool.ruff.format] +docstring-code-format = true + [tool.pdm] distribution = true diff --git a/tests/consumers/sqs.py b/tests/consumers/sqs.py index 23f1182..6afdbb1 100644 --- a/tests/consumers/sqs.py +++ b/tests/consumers/sqs.py @@ -39,7 +39,7 @@ def local_sqs(): yield conn_params -async def test_normal_case(local_sqs): +async def test_happy_path(local_sqs): queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) # Arrange @@ -74,6 +74,11 @@ async def handle(m: SqsMessage): assert received == sent +async def test_handler_manager(local_sqs): + # Test handler lifecycle (via handler manager) + pass + + async def test_batching(local_sqs): queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) From f7cacebcfbcaba4bc5a89ed2b117770f0aa2b745 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 18 Jul 2025 11:31:15 +0000 Subject: [PATCH 004/286] WIP --- CHANGELOG.md | 4 +- justfile | 10 +- localpost/__init__.py | 12 +- localpost/_debug.py | 8 +- localpost/_utils.py | 49 +- localpost/consumers/_sqs_types.py | 92 + localpost/consumers/kafka.py | 210 +- localpost/consumers/kafka_otel.py | 25 +- localpost/consumers/kafka_protobuf.py | 3 +- localpost/consumers/sqs.py | 425 ++-- localpost/consumers/sqs_otel.py | 20 +- localpost/consumers/stream.py | 28 +- localpost/consumers/stream_otel.py | 21 +- localpost/flow/__init__.py | 4 +- localpost/flow/_flow.py | 15 +- localpost/flow/_ops.py | 74 +- localpost/flow/_stream.py | 55 +- localpost/hosting/_host.py | 34 +- localpost/hosting/middlewares.py | 2 +- localpost/hosting/services/__init__.py | 0 localpost/hosting/{ => services}/grpc.py | 2 +- localpost/hosting/services/hypercorn.py | 24 + .../hosting/{http.py => services/uvicorn.py} | 9 +- localpost/hosting/utils.py | 4 +- localpost/scheduler/_cond.py | 2 +- localpost/scheduler/_scheduler.py | 16 +- pdm.lock | 1905 +++++++++++------ pyproject.toml | 65 +- tests/conftest.py | 5 + tests/consumers/kafka.py | 15 +- tests/consumers/{sqs.py => sqs_aioboto.py} | 54 +- tests/consumers/sqs_boto.py | 119 + 32 files changed, 2165 insertions(+), 1146 deletions(-) create mode 100644 localpost/consumers/_sqs_types.py create mode 100644 localpost/hosting/services/__init__.py rename localpost/hosting/{ => services}/grpc.py (95%) create mode 100644 localpost/hosting/services/hypercorn.py rename localpost/hosting/{http.py => services/uvicorn.py} (92%) create mode 100644 tests/conftest.py rename tests/consumers/{sqs.py => sqs_aioboto.py} (54%) create mode 100644 tests/consumers/sqs_boto.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b9451..aa0abd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,17 +15,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -## [0.5.0] - 2025-06-25 +## [0.5.0] - 2025-07-18 ### Added - `localpost.consumers.stream` for in-memory queues +- `localpost.hosting.services.hypercorn` for Hypercorn HTTP server - `localpost.debug` context manager, to simplify debugging - More tests ### Changed - `localpost.consumers.kafka` reworked +- `localpost.consumers.sqs` reworked (now with both `boto3` and `aioboto3` support) ## [0.4.0] - 2025-06-23 diff --git a/justfile b/justfile index d9f1b48..a45c990 100755 --- a/justfile +++ b/justfile @@ -4,13 +4,17 @@ default: just --list deps: - uv sync --all-groups --all-extras + pdm install --group :all +# uv sync --all-groups --all-extras deps-upgrade: - uv sync --all-groups --all-extras --upgrade + pdm lock --group :all -v + pdm sync --group :all --clean +# uv sync --all-groups --all-extras --upgrade [doc("Check types (using both PyRight and MyPy)")] types: + -ty check localpost -pyright --pythonpath $(which python) \ localpost -mypy --pretty --strict-bytes --python-executable $(which python) \ @@ -37,8 +41,8 @@ type-coverage: pyright --pythonpath $(which python) --verifytypes localpost localpost/* format: - ruff check --fix localpost ruff format localpost + ruff check --fix localpost format-all: format ruff check --fix examples tests diff --git a/localpost/__init__.py b/localpost/__init__.py index fac4533..46a4051 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -1,12 +1,18 @@ +import logging + from ._debug import debug from ._run import arun, run from ._utils import Result - try: - from .__meta__ import version as __version__ # noqa + from .__meta__ import version as __version__ except ImportError: __version__ = "dev" -__all__ = ["__version__", "arun", "run", "debug", "Result"] +__all__ = ["Result", "__version__", "arun", "debug", "run"] + + +# Set up logging according to the best practices: +# https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library +logging.getLogger("boto3").addHandler(logging.NullHandler()) diff --git a/localpost/_debug.py b/localpost/_debug.py index fbb1088..49f5243 100644 --- a/localpost/_debug.py +++ b/localpost/_debug.py @@ -7,12 +7,15 @@ class DebugState(AbstractContextManager[None, None]): def __init__(self): self._entered = 0 - def __bool__(self): + def __bool__(self) -> bool: return self._entered > 0 def __enter__(self) -> None: self._entered += 1 + async def __aenter__(self) -> None: + return self.__enter__() + def __exit__(self, exc_type, exc_value: BaseException | None, traceback) -> None: self._entered -= 1 if exc_value and self._entered == 0: @@ -21,5 +24,8 @@ def __exit__(self, exc_type, exc_value: BaseException | None, traceback) -> None # Re-raise the original exception for better debugging raise source_exc from source_exc.__cause__ + async def __aexit__(self, exc_type, exc_value: BaseException | None, traceback) -> None: + return self.__exit__(exc_type, exc_value, traceback) + debug = DebugState() diff --git a/localpost/_utils.py b/localpost/_utils.py index 12a10b4..d1a2ec7 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -20,21 +20,21 @@ Protocol, TypeAlias, TypedDict, - Union, + TypeGuard, cast, final, overload, ) import anyio -from anyio import CancelScope, WouldBlock, create_task_group, from_thread, to_thread, CapacityLimiter +from anyio import CancelScope, CapacityLimiter, WouldBlock, create_task_group, from_thread, to_thread from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, MemoryObjectStreamState -from typing_extensions import NotRequired, Self, TypeGuard, TypeVar +from typing_extensions import NotRequired, Self, TypeVar if sys.version_info >= (3, 11): - from builtins import ExceptionGroup # noqa + from builtins import ExceptionGroup else: from exceptiongroup import ExceptionGroup @@ -117,6 +117,14 @@ async def __aexit__(self, exc_type, exc_value, traceback) -> None: cast(_SupportsClose, t).close() +def ensure_int_or_inf(value: int | float, *, min_value: int = 0, name: str = "Value") -> int | float: + if math.isinf(value): + return value + if isinstance(value, int) and value >= min_value: + return value + raise ValueError(f"{name} must be an integer (>={min_value}) or infinity, got {value!r}") + + @final # Actually immutable, but frozen=True has noticeable performance impact @dc.dataclass(slots=True, eq=True, unsafe_hash=True) @@ -142,7 +150,7 @@ def get_callable_return_type(func: Callable[..., Any], /) -> type[Any]: desc = typing.get_type_hints(func, globalns=getattr(func, "__globals__", None)) except (TypeError, NameError): try: - desc = typing.get_type_hints(func.__call__, globalns=getattr(func.__call__, "__globals__", None)) + desc = typing.get_type_hints(func.__call__, globalns=getattr(func.__call__, "__globals__", None)) # type: ignore[operator] except (TypeError, NameError, AttributeError): return type(None) @@ -208,7 +216,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: return value if isinstance(value, str): try: - import pytimeparse2 + import pytimeparse2 # noqa: PLC0415 use_dateutil = pytimeparse2.HAS_RELITIVE_TIMEDELTA try: @@ -227,7 +235,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: def td_str(td: timedelta, /) -> str: try: - from humanize import precisedelta + from humanize import precisedelta # noqa: PLC0415 # 23 seconds or 0.24 seconds return precisedelta(td) @@ -237,9 +245,7 @@ def td_str(td: timedelta, /) -> str: # TODO Rename to DurationFactory -DelayFactory: TypeAlias = Union[ - Callable[[], timedelta], tuple[int, int], tuple[float, float], int, float, timedelta, None -] +DelayFactory: TypeAlias = Callable[[], timedelta] | tuple[int | float, int | float] | int | float | timedelta | None @final @@ -265,7 +271,7 @@ class FixedDelay: def create(cls, value: int | float | timedelta | None) -> Self: if value is None or value == 0: delay = TD_ZERO - elif isinstance(value, (int, float)): + elif isinstance(value, int | float): delay = timedelta(seconds=value) elif isinstance(value, timedelta): delay = value @@ -300,13 +306,10 @@ def sleep(i: timedelta | int | float | None, /) -> Coroutine[Any, Any, None]: @final @dc.dataclass(eq=False) class MemorySendStream(Generic[T], MemoryObjectSendStream[T]): - def send_or_drop(self, item: T) -> None: - try: - self.send_nowait(item) - except WouldBlock: - pass + def send_or_drop_from_thread(self, item: T) -> None: + from_thread.run(self.send_or_drop, item) - async def send_or_drop_async(self, item: T) -> None: + async def send_or_drop(self, item: T) -> None: await checkpoint() try: self.send_nowait(item) @@ -322,7 +325,7 @@ def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjec if max_buffer_size < 0: raise ValueError("max_buffer_size cannot be negative") - state = MemoryObjectStreamState[T](max_buffer_size) + state: MemoryObjectStreamState[T] = MemoryObjectStreamState(max_buffer_size) return MemorySendStream(state), MemoryObjectReceiveStream(state) @@ -426,6 +429,10 @@ async def wait_all(events: Iterable[EventView]) -> None: async def wait_any(*targets: EventView | Callable[[], Awaitable[Any]]) -> None: - async with create_task_group() as tg: - for t in targets: - tg.start_soon(_cancel_when, t, tg.cancel_scope) + try: + async with create_task_group() as tg: + for t in targets: + tg.start_soon(_cancel_when, t, tg.cancel_scope) + except ExceptionGroup as exc_group: + exc = unwrap_exc(exc_group) + raise exc from exc.__cause__ diff --git a/localpost/consumers/_sqs_types.py b/localpost/consumers/_sqs_types.py new file mode 100644 index 0000000..e1fced0 --- /dev/null +++ b/localpost/consumers/_sqs_types.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TypedDict + +try: + from types_boto3_sqs import SQSClient as BotoSqsClient +except ImportError: + # BotoSqsClient: TypeAlias = Any + # Same trick as in types_boto3_sqs stubs + from typing import Any as BotoSqsClient # type: ignore[assignment] # noqa + +try: + from types_aiobotocore_sqs.client import SQSClient as AioBotoSqsClient +except ImportError: + # Same trick as in types_boto3_sqs stubs + from typing import Any as AioBotoSqsClient # type: ignore[assignment] # noqa + +try: + from types_boto3_sqs.literals import MessageSystemAttributeNameType + from types_boto3_sqs.type_defs import ( + MessageAttributeValueOutputTypeDef, + MessageTypeDef, + ReceiveMessageRequestTypeDef, + ) +except ImportError: + try: + from types_aiobotocore_sqs.literals import MessageSystemAttributeNameType + from types_aiobotocore_sqs.type_defs import ( + MessageAttributeValueOutputTypeDef, + MessageTypeDef, + ReceiveMessageRequestTypeDef, + ) + except ImportError: + # Same trick as in types_boto3_sqs stubs + from typing import Any as MessageSystemAttributeNameType # type: ignore[assignment] # noqa + from typing import Any as MessageAttributeValueOutputTypeDef # type: ignore[assignment] # noqa + from typing import Any as MessageTypeDef # type: ignore[assignment] # noqa + from typing import Any as ReceiveMessageRequestTypeDef # type: ignore[assignment] # noqa + + +class LambdaEventRecordMessageAttributeValue(TypedDict): + dataType: str + stringValue: str + binaryValue: bytes + stringListValues: Sequence[str] + binaryListValues: Sequence[bytes] + + +class LambdaEventRecord(TypedDict): + """ + One record in the Lambda event. + + Example: + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": { + "myAttribute": { + "stringValue": "myValue", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String" + } + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + """ + + messageId: str + receiptHandle: str + body: str + attributes: Mapping[MessageSystemAttributeNameType, str] + messageAttributes: Mapping[str, LambdaEventRecordMessageAttributeValue] + md5OfBody: str + eventSource: str + eventSourceARN: str + awsRegion: str + + +class LambdaEvent(TypedDict): + Records: Sequence[LambdaEventRecord] diff --git a/localpost/consumers/kafka.py b/localpost/consumers/kafka.py index f04de54..e1f1a7b 100644 --- a/localpost/consumers/kafka.py +++ b/localpost/consumers/kafka.py @@ -1,22 +1,22 @@ import dataclasses as dc import logging import os -from collections.abc import Callable, Iterable, Mapping, Sequence, Collection -from contextlib import AbstractContextManager, AbstractAsyncContextManager, asynccontextmanager, AsyncExitStack +from collections.abc import Callable, Collection, Mapping, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager, AsyncExitStack, asynccontextmanager from functools import partial -from typing import Any, final, Final +from typing import Any, Final, TypeAlias, cast, final, overload import confluent_kafka -from anyio import from_thread, to_thread, create_task_group, CapacityLimiter, CancelScope +from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread from confluent_kafka import TIMESTAMP_NOT_AVAILABLE -from localpost._utils import EventView -from localpost.flow import AnyHandlerManager, ensure_sync_handler, SyncHandler +from localpost.flow import AnyHandlerManager, SyncHandler, ensure_sync_handler from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager __all__ = [ "KafkaMessage", "KafkaMessages", + "ConsumerClient", "kafka_config_from_env", "kafka_consumer", ] @@ -27,14 +27,27 @@ @final -@dc.dataclass(frozen=True, eq=False, slots=True) class ConsumerClient: - config: Mapping[str, Any] - topics: Sequence[str] - _client: confluent_kafka.Consumer + @classmethod + @asynccontextmanager + async def create(cls, *topics: str, **config): + rdkafka_config = {k.lower().replace("_", "."): v for k, v in config.items()} + # https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#consumer + transport = confluent_kafka.Consumer(rdkafka_config, logger=logger) # type: ignore[call-arg] + client = cls(transport, rdkafka_config, topics) + try: + await to_thread.run_sync(transport.subscribe, client.topics) # type: ignore[call-arg] + yield client + finally: + await to_thread.run_sync(transport.close) - def subscribe(self) -> None: - self._client.subscribe(self.topics) + def __init__(self, transport: confluent_kafka.Consumer, config: Mapping[str, Any], topics: Collection[str]) -> None: + if not topics: + raise ValueError("At least one topic must be specified") + + self.config: Mapping[str, Any] = dict(config) + self.topics: Sequence[str] = list(topics) + self._client = transport def poll(self) -> confluent_kafka.Message | None: return self._client.poll(CHECK_INTERVAL) # Interrupt periodically, so we can respect the cancellation @@ -48,6 +61,9 @@ def store_offset(self, message: confluent_kafka.Message) -> None: self._client.store_offsets(message) +ClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient]] + + @final @dc.dataclass(frozen=True, eq=False, slots=True) class KafkaMessage(Sequence["KafkaMessage"], AbstractContextManager[bytes, None]): @@ -55,18 +71,20 @@ class KafkaMessage(Sequence["KafkaMessage"], AbstractContextManager[bytes, None] _client: ConsumerClient def __repr__(self): - return f"{self.__class__.__name__}(topic={self.payload.topic()!r}" + return f"{self.__class__.__name__}(topic={self.payload.topic()!r})" def __len__(self): return 1 - def __getitem__(self, _): - return self + def __getitem__(self, i): + if i == 0: + return self + raise IndexError() def __enter__(self) -> bytes: return self.value - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__(self, exc_type, _, __) -> None: if exc_type is None: self.try_ack() @@ -109,26 +127,32 @@ class KafkaMessages(Sequence[KafkaMessage], AbstractContextManager[Sequence[byte source: Sequence[KafkaMessage] - def __post_init__(self): - if len(self.source) == 0: + def __init__(self, source: Sequence[KafkaMessage]) -> None: + if isinstance(source, KafkaMessages): + source = source.source + if len(source) == 0: raise ValueError(f"{self.__class__.__name__} must not be empty") + object.__setattr__(self, "source", source) - @property - def payload(self) -> Sequence[confluent_kafka.Message]: - return [msg.payload for msg in self.source] + def __repr__(self): + return f"{self.__class__.__name__}(len={len(self)})" + + def __len__(self): + return len(self.payload) + + def __getitem__(self, i) -> KafkaMessage: + return self.source[i] # type: ignore[return-value] def __enter__(self) -> Sequence[bytes]: return [msg.value for msg in self.source] - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__(self, exc_type, _, __) -> None: if exc_type is None: self.try_ack() - def __getitem__(self, item): - return self.payload[item] - - def __len__(self): - return len(self.payload) + @property + def payload(self) -> Sequence[confluent_kafka.Message]: + return [msg.payload for msg in self.source] def try_ack(self) -> None: for message in self.payload: @@ -139,128 +163,120 @@ def ack(self) -> None: message.ack() -def _consume_sync( - client: ConsumerClient, - message_handler: SyncHandler[KafkaMessage], - shutdown_scope: CancelScope +async def _consume_sync( + client: ConsumerClient, message_handler: SyncHandler[KafkaMessage], shutdown_scope: CancelScope ) -> None: - while not shutdown_scope.cancel_called: - from_thread.check_cancelled() - poll_res = client.poll() - if poll_res is None: - continue - if error := poll_res.error(): - if error.retriable(): - logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) + def consume(): + while True: + from_thread.check_cancelled() + poll_res = client.poll() + if poll_res is None: continue - if error.fatal(): - raise RuntimeError(error.str()) - message = KafkaMessage(poll_res, client) - message_handler(message) + if error := poll_res.error(): + if error.retriable(): + logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) + continue + if error.fatal(): + raise RuntimeError(error.str()) + message = KafkaMessage(poll_res, client) + message_handler(message) + + # In Trio shutdown_scope.cancel_called can only be checked in async context + with shutdown_scope: + await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) @final class KafkaConsumerService(ExposedServiceBase): def __init__( self, - cf: Callable[[], AbstractAsyncContextManager[ConsumerClient]], + cf: ClientFactory, handler_m: AnyHandlerManager[KafkaMessage], /, *, - consumers: int, + num_consumers: int, ): super().__init__() - if consumers < 1: + if num_consumers < 1: raise ValueError("Number of consumers must be at least 1") - self.handler_m = handler_m self.client_factory = cf - self.num_consumers = consumers + self.handler_m = handler_m + self.num_consumers = num_consumers async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: assert self.num_consumers > 0 self._lifetime = service_lifetime # Expose service lifetime events - threads_limiter = CapacityLimiter(self.num_consumers) - run_thread = partial(to_thread.run_sync, limiter=threads_limiter) - # Graceful shutdown scopes, one per consumer task (thread) consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] - # Although Confluent Kafka's Consumer is thread-safe, it's not intended to be used concurrently: - # - by default, a message received from poll() is automatically acknowledged - # - OTEL instrumentation stores the current span in an object field, so concurrent calls to poll() will mess - # up the traces - clients = [] async with AsyncExitStack() as client_stack, self.handler_m as handler: logger.debug("Creating Kafka clients (1 per consumer)...") - for _ in consumer_scopes: - clients.append(await client_stack.enter_async_context(self.client_factory())) - logger.debug("Subscribing Kafka clients to topics...") - async with create_task_group() as tg: - for c in clients: - tg.start_soon(run_thread, c.subscribe) + # Although Confluent Kafka's Consumer is thread-safe, it's not intended to be used concurrently: + # - by default, a message received from poll() is automatically acknowledged + # - OTEL instrumentation stores the current span in an object field, so concurrent calls to poll() will + # mess up the traces + clients = [await client_stack.enter_async_context(self.client_factory()) for _ in range(self.num_consumers)] message_handler = ensure_sync_handler(handler) service_lifetime.set_started() # Start pulling messages only after the whole app is started await service_lifetime.host.started async with create_task_group() as tg: - for c, cs in zip(clients, consumer_scopes): - tg.start_soon(run_thread,_consume_sync, c, message_handler, cs) + for c, cs in zip(clients, consumer_scopes, strict=False): + tg.start_soon(_consume_sync, c, message_handler, cs) await service_lifetime.shutting_down for cs in consumer_scopes: cs.cancel() -@dc.dataclass(frozen=True, slots=True) -class ClientFactory: - config: Mapping[str, Any] - topics: Collection[str] - - @asynccontextmanager - async def __call__(self): - transport = confluent_kafka.Consumer( - self.config, - logger=logger, # noqa - ) - client = ConsumerClient(dict(self.config), list(self.topics), transport) - try: - yield client - finally: - await to_thread.run_sync(transport.close) +def kafka_client_factory(*topics: str, **config) -> ClientFactory: + return partial(ConsumerClient.create, *topics, **config) def kafka_config_from_env(**overrides) -> dict[str, Any]: """ - Construct a configuration dictionary for KAFKA_* environment variables. + Construct a configuration dictionary from KAFKA_* environment variables. - When translating Kafka's properties, use upper case instead and replace the . with _ (KAFKA_BOOTSTRAP_SERVERS -> - bootstrap.servers, etc.). + When translating Kafka's properties, use upper case and replace "." with "_": + - bootstrap.servers -> KAFKA_BOOTSTRAP_SERVERS + - max.in.flight.requests.per.connection -> KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION + - ... - Properties reference: https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md. + Properties reference: https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md + See also: https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#pythonclient-configuration """ def _read_env_vars(): for var_name, var_val in os.environ.items(): if var_name.startswith("KAFKA_"): - yield var_name[6:].lower().replace("_", "."), var_val + yield var_name[6:].lower(), var_val conf_from_env = dict(_read_env_vars()) - conf_from_args = {k.replace("_", "."): v for k, v in overrides.items()} - return conf_from_env | conf_from_args + return conf_from_env | overrides + + +@overload +def kafka_consumer( + cf: ClientFactory, /, *, num_consumers: int = 1 +) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: + """Decorator to create a Kafka consumer hosted service.""" + + +@overload +def kafka_consumer( + *topics: str, num_consumers: int = 1, **config: Any +) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: + """Decorator to create a Kafka consumer hosted service.""" # PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function def kafka_consumer( - topics: str | Iterable[str], client_config: Mapping[str, Any] | None = None, /, *, consumers: int = 1 + *topics_or_cf: Any, num_consumers: int = 1, **config: Any ) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: - """ Decorator to create a Kafka consumer hosted service. """ - return lambda handler_m: KafkaConsumerService( - ClientFactory( - kafka_config_from_env(**(client_config or {})), - [topics] if isinstance(topics, str) else list(topics) - ), - handler_m, - consumers=consumers, - ) + if len(topics_or_cf) == 1 and callable(topics_or_cf[0]): + cf = cast(ClientFactory, topics_or_cf[0]) + else: + cf = kafka_client_factory(*topics_or_cf, **config) + return lambda handler_m: KafkaConsumerService(cf, handler_m, num_consumers=num_consumers) diff --git a/localpost/consumers/kafka_otel.py b/localpost/consumers/kafka_otel.py index 7c25adf..d9fb932 100644 --- a/localpost/consumers/kafka_otel.py +++ b/localpost/consumers/kafka_otel.py @@ -1,11 +1,11 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Callable, Sequence from contextlib import AbstractContextManager, contextmanager from typing import TypeVar from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( # noqa +from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( create_messaging_client_consumed_messages, create_messaging_client_operation_duration, ) @@ -18,7 +18,6 @@ from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware T = TypeVar("T", KafkaMessage, Sequence[KafkaMessage]) -R = TypeVar("R", Awaitable[None], None) __all__ = ["trace"] @@ -37,20 +36,26 @@ def create_message_tracer( messages_consumed = create_messaging_client_consumed_messages(meter) @contextmanager - def call_tracer(message: KafkaMessage | Sequence[KafkaMessage]): - is_batch = isinstance(message, KafkaMessage) - topic = message.payload.topic() if isinstance(message, KafkaMessage) else message[0].payload.topic() + def call_tracer(messages: Sequence[KafkaMessage]): + assert len(messages) > 0, "Message batch must not be empty" + is_batch = isinstance(messages, KafkaMessage) + message = messages[0] + topic = message.payload.topic() + # https://opentelemetry.io/docs/specs/semconv/messaging/kafka/#apache-kafka-with-quarkus-or-spring-boot-example attrs: dict[str, AttributeValue] = { - "messaging.operation.type": "process", "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.operation.type": "process", "messaging.destination.name": topic, + "messaging.consumer.group.name": message._client.config.get("group.id", "unknown"), } if is_batch: - attrs["messaging.batch.message_count"] = len(message) + attrs["messaging.batch.message_count"] = len(messages) else: - attrs["messaging.kafka.partition"] = (message.payload.partition(),) + attrs["messaging.kafka.offset"] = message.payload.offset() + attrs["messaging.kafka.partition"] = message.payload.partition() - messages_consumed.add(len(message) if is_batch else 1, attrs) + messages_consumed.add(len(messages), attrs) with tracer.start_as_current_span(f"process {topic}", kind=SpanKind.CONSUMER, attributes=attrs): with rec_duration(m_process_duration, attrs): yield diff --git a/localpost/consumers/kafka_protobuf.py b/localpost/consumers/kafka_protobuf.py index c53b9f3..08cbdd0 100644 --- a/localpost/consumers/kafka_protobuf.py +++ b/localpost/consumers/kafka_protobuf.py @@ -7,7 +7,8 @@ from __future__ import annotations import warnings -from typing import Callable, ParamSpec, TypeAlias, TypeVar +from collections.abc import Callable +from typing import ParamSpec, TypeAlias, TypeVar from confluent_kafka.schema_registry.protobuf import ProtobufDeserializer from confluent_kafka.serialization import MessageField, SerializationContext diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py index d3e6c6a..550e167 100644 --- a/localpost/consumers/sqs.py +++ b/localpost/consumers/sqs.py @@ -3,76 +3,194 @@ import dataclasses as dc import logging import math -from collections.abc import Callable, Iterable, Mapping, Sequence -from contextlib import AbstractAsyncContextManager, asynccontextmanager, AbstractContextManager -from typing import TYPE_CHECKING, Final, TypeAlias, TypedDict, cast, final +from collections.abc import Callable, Collection, Iterable, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager +from functools import partial +from typing import Final, TypeAlias, cast, final, overload +from urllib.parse import urlparse -from anyio import CancelScope, create_task_group, to_thread +from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread from localpost import flow from localpost._utils import MemoryStream, is_async_callable -from localpost.flow import AnyHandlerManager, AsyncHandlerManager, FlowHandlerManager, ensure_async_handler_manager, \ - AsyncHandler, ensure_async_handler -from localpost.flow._stream import create_stream_consumer, BatchReceiver +from localpost.flow import ( + AnyHandlerManager, + AsyncHandler, + FlowHandlerManager, + SyncHandler, + ensure_async_handler, + ensure_sync_handler, +) +from localpost.flow._stream import BatchReceiver, create_stream_consumer from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager from localpost.hosting.utils import ThreadSafeMemorySendStream, ThreadSafeSendStream -if TYPE_CHECKING: - from types_boto3_sqs import SQSClient as SyncTransport - from types_aiobotocore_sqs import SQSClient as AsyncTransport - from types_aiobotocore_sqs.literals import MessageSystemAttributeNameType - from types_aiobotocore_sqs.type_defs import MessageTypeDef, ReceiveMessageRequestTypeDef +from ._sqs_types import ( + AioBotoSqsClient, + BotoSqsClient, + LambdaEvent, + LambdaEventRecord, + LambdaEventRecordMessageAttributeValue, + MessageTypeDef, + ReceiveMessageRequestTypeDef, +) __all__ = [ "SqsMessage", "SqsMessages", + "ConsumerClient", + "AsyncConsumerClient", "async_client_factory", - "sqs_queue_consumer", "lambda_handler", + "sqs_queue_consumer", ] logger = logging.getLogger(__name__) -_EMPTY_RECEIVE: Final[Sequence["MessageTypeDef"]] = () +# Timeout for a sync pull request, to check the application state (and exit gracefully on shutdown) +CHECK_INTERVAL: Final = 3.0 # seconds + +_EMPTY_RECEIVE: Final[Sequence[MessageTypeDef]] = () @final -@dc.dataclass(frozen=True, eq=False, slots=True) class ConsumerClient: - queue_name: str - queue_url: str - _client: "SyncTransport" - receive_req_template: "ReceiveMessageRequestTypeDef" = dc.field(default_factory=lambda: { - "QueueUrl": "", # Will be filled in later - "MessageAttributeNames": ["All"], - "MaxNumberOfMessages": 10, - "WaitTimeSeconds": 20, - }) + @classmethod + @asynccontextmanager + async def create( + cls, + queue_name_or_url: str, + client: BotoSqsClient | None = None, + /, + *, + req_template: ReceiveMessageRequestTypeDef | None = None, + ): + def _queue_url_from_name(n): + return transport.get_queue_url(QueueName=n)["QueueUrl"] - def receive(self, receive_req: "ReceiveMessageRequestTypeDef") -> Sequence["MessageTypeDef"]: - pass # TODO Implement + def get_client() -> BotoSqsClient: + if client is None: + try: + import boto3 - def delete(self, messages: SqsMessage | Iterable[SqsMessage]) -> None: - pass # TODO Implement + return boto3.client("sqs") + except ImportError: + from botocore.session import get_session + + return get_session().create_client("sqs") # type: ignore[return-value] + return client + + transport = get_client() + if "/" in queue_name_or_url: + queue_url = queue_name_or_url + queue_name = _queue_name_from_url(queue_url) + else: + queue_name = queue_name_or_url + queue_url = await to_thread.run_sync(_queue_url_from_name, queue_name) + yield cls( + transport, + queue_name, + queue_url, + req_template + or { + "QueueUrl": queue_name, + "MessageAttributeNames": ["All"], + "MaxNumberOfMessages": 10, + "WaitTimeSeconds": int(CHECK_INTERVAL), # Long polling + }, + ) + + def __init__( + self, + client: BotoSqsClient, + queue_name: str, + queue_url: str, + receive_req: ReceiveMessageRequestTypeDef, + /, + ) -> None: + self._client = client + self.queue_name = queue_name + self.queue_url = queue_url + self.receive_req: ReceiveMessageRequestTypeDef = receive_req | {"QueueUrl": queue_url} + + def receive(self) -> Sequence[MessageTypeDef]: + # TODO Check HTTP status and retry on errors (exponential backoff) + pull_resp = self._client.receive_message(**self.receive_req) + return pull_resp.get("Messages", _EMPTY_RECEIVE) + + def delete(self, workload: Iterable[SqsMessage], /) -> None: + if isinstance(workload, SqsMessage): + self._client.delete_message( + QueueUrl=self.queue_url, + ReceiptHandle=workload.receipt_handle, + ) + else: + self._client.delete_message_batch( + QueueUrl=self.queue_url, + Entries=[ # type: ignore + {"Id": str(i), "ReceiptHandle": message.receipt_handle} for i, message in enumerate(workload) + ], + ) @final -@dc.dataclass(frozen=True, eq=False, slots=True) class AsyncConsumerClient: - queue_name: str - queue_url: str - _client: "AsyncTransport" - receive_req: "ReceiveMessageRequestTypeDef" = dc.field(default_factory=lambda: { - "QueueUrl": "", # Will be filled in later - "MessageAttributeNames": ["All"], - "MaxNumberOfMessages": 10, - "WaitTimeSeconds": 20, - }) - - def __post_init__(self): - object.__setattr__(self, "receive_req", self.receive_req | {"QueueUrl": self.queue_url}) - - async def receive(self) -> Sequence["MessageTypeDef"]: + @classmethod + @asynccontextmanager + async def create( + cls, + queue_name_or_url: str, + client: AbstractAsyncContextManager[AioBotoSqsClient] | None = None, + /, + *, + req_template: ReceiveMessageRequestTypeDef | None = None, + ): + def get_client() -> AbstractAsyncContextManager[AioBotoSqsClient]: + if client is None: + try: + import aioboto3 + + return aioboto3.Session().client("sqs") + except ImportError: + from aiobotocore.session import get_session + + return get_session().create_client("sqs") + return client + + async with get_client() as transport: + if "/" in queue_name_or_url: + queue_url = queue_name_or_url + queue_name = _queue_name_from_url(queue_url) + else: + queue_name = queue_name_or_url + queue_url = (await transport.get_queue_url(QueueName=queue_name))["QueueUrl"] + yield cls( + transport, + queue_name, + queue_url, + req_template + or { + "QueueUrl": queue_name, + "MessageAttributeNames": ["All"], + "MaxNumberOfMessages": 10, + "WaitTimeSeconds": 20, + }, + ) + + def __init__( + self, + client: AioBotoSqsClient, + queue_name: str, + queue_url: str, + receive_req: ReceiveMessageRequestTypeDef, + /, + ) -> None: + self._client = client + self.queue_name = queue_name + self.queue_url = queue_url + self.receive_req: ReceiveMessageRequestTypeDef = receive_req | {"QueueUrl": queue_url} + + async def receive(self) -> Sequence[MessageTypeDef]: # TODO Check HTTP status and retry on errors (exponential backoff) pull_resp = await self._client.receive_message(**self.receive_req) return pull_resp.get("Messages", _EMPTY_RECEIVE) @@ -87,26 +205,25 @@ async def delete(self, workload: Iterable[SqsMessage], /) -> None: await self._client.delete_message_batch( QueueUrl=self.queue_url, Entries=[ # type: ignore - {"Id": str(i), "ReceiptHandle": message.receipt_handle} - for i, message in enumerate(workload) + {"Id": str(i), "ReceiptHandle": message.receipt_handle} for i, message in enumerate(workload) ], ) -ClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient]] -AsyncClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]] + +AnyClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient | AsyncConsumerClient]] @final @dc.dataclass(frozen=True, slots=True) class SqsMessage(Sequence["SqsMessage"], AbstractContextManager[str, None]): - payload: "MessageTypeDef" + payload: MessageTypeDef """ Raw message data from the SQS queue. See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_Message.html. """ - client: AsyncConsumerClient + client: ConsumerClient | AsyncConsumerClient ack_queue: ThreadSafeSendStream[SqsMessage] def __repr__(self): @@ -115,8 +232,10 @@ def __repr__(self): def __len__(self): return 1 - def __getitem__(self, _): - return self + def __getitem__(self, i): + if i == 0: + return self + raise IndexError() def __enter__(self) -> str: return self.body @@ -138,15 +257,35 @@ def body(self) -> str: assert "Body" in self.payload return self.payload["Body"] + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-message-metadata.html#message-attribute-data-types @property - def attributes(self): - return self.payload.get("MessageAttributes", {}) + def attributes(self) -> dict[str, str | bool | int | float | bytes]: + def extract_attrs(): + for attr_name, msg_attr in self.payload.get("MessageAttributes", {}).items(): + attr_type = msg_attr.get("DataType", "").lower() + str_val = msg_attr.get("StringValue") + if attr_type.startswith("string.bool"): + assert str_val + yield attr_name, str_val.lower() in ("true", "1", "yes") + elif attr_type.startswith("string"): + assert str_val + yield attr_name, str_val + elif attr_type.startswith("number"): + assert str_val + try: + yield attr_name, int(str_val) + except ValueError: + yield attr_name, float(str_val) + elif attr_type.startswith("binary"): + yield attr_name, msg_attr["BinaryValue"] # type: ignore + + return dict(extract_attrs()) @final @dc.dataclass(frozen=True, slots=True) class SqsMessages(Sequence[SqsMessage], AbstractContextManager[Sequence[str], None]): - """ Non-empty batch of SQS messages. """ + """Non-empty batch of SQS messages.""" source: Sequence[SqsMessage] @@ -158,13 +297,14 @@ def __init__(self, source: Sequence[SqsMessage]): object.__setattr__(self, "source", source) def __repr__(self): - return f"{self.__class__.__name__}(len={len(self)})" + queue_name = self.source[0].client.queue_name + return f"{self.__class__.__name__}(len={len(self)}, queue_name={queue_name!r})" def __len__(self): return len(self.source) - def __getitem__(self, item) -> SqsMessage: - return self.source[item] # type: ignore[return-value] + def __getitem__(self, i) -> SqsMessage: + return self.source[i] # type: ignore[return-value] def __enter__(self) -> Sequence[str]: return [msg.body for msg in self.source] @@ -175,55 +315,43 @@ def __exit__(self, exc_type, _, __) -> None: message.ack() @property - def payload(self) -> Sequence["MessageTypeDef"]: + def payload(self) -> Sequence[MessageTypeDef]: return [msg.payload for msg in self.source] def _queue_name_from_url(url: str) -> str: - from urllib.parse import urlparse - parse_result = urlparse(url) return parse_result.path.split("/")[-1] -def client_factory(queue_name_or_url: str, /) -> ClientFactory: - """ Default SQS client factory. """ - import boto3 - - @asynccontextmanager - async def with_client(): - transport: "SyncTransport" = boto3.client("sqs") - - def _queue_url_from_name(n): - return transport.get_queue_url(QueueName=n)["QueueUrl"] - - if "/" in queue_name_or_url: - queue_url = queue_name_or_url - queue_name = _queue_name_from_url(queue_url) - else: - queue_name = queue_name_or_url - queue_url = await to_thread.run_sync(_queue_url_from_name, queue_name) - yield ConsumerClient(queue_name, queue_url, transport) +def client_factory(queue_name_or_url: str, /) -> Callable[[], AbstractAsyncContextManager[ConsumerClient]]: + """Default SQS client factory.""" + return partial(ConsumerClient.create, queue_name_or_url) - return with_client # type: ignore[return-value] +def async_client_factory(queue_name_or_url: str, /) -> Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]]: + """Default SQS async (aioboto3) client factory.""" + return partial(AsyncConsumerClient.create, queue_name_or_url) -def async_client_factory(queue_name_or_url: str, /) -> AsyncClientFactory: - """ Default SQS async client factory. """ - from aiobotocore.session import get_session - @asynccontextmanager - async def with_client(): - async with get_session().create_client("sqs") as transport: - if "/" in queue_name_or_url: - queue_url = queue_name_or_url - queue_name = _queue_name_from_url(queue_url) - else: - queue_name = queue_name_or_url - queue_url = (await transport.get_queue_url(QueueName=queue_name))["QueueUrl"] - yield AsyncConsumerClient(queue_name, queue_url, transport) +async def _consume_sync( + client: ConsumerClient, + message_handler: SyncHandler[SqsMessage], + ack_queue: ThreadSafeSendStream[SqsMessage], + shutdown_scope: CancelScope, +): + def consume(): + while True: + from_thread.check_cancelled() + messages = client.receive() + if not messages: + continue # No messages received (empty queue or shutdown) + for m in messages: + message_handler(SqsMessage(m, client, ack_queue)) - return with_client # type: ignore[return-value] + # In Trio shutdown_scope.cancel_called can only be checked in async context + with shutdown_scope: + await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) # See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py @@ -247,106 +375,83 @@ async def _consume_async( class SqsConsumerService(ExposedServiceBase): def __init__( self, - cf: AsyncClientFactory, - handler_m: AsyncHandlerManager[SqsMessage], + cf: AnyClientFactory, + handler_m: AnyHandlerManager[SqsMessage], /, *, - consumers: int, + num_consumers: int, ): super().__init__() - if consumers < 1: + + if num_consumers < 1: raise ValueError("Number of consumers must be at least 1") self.client_factory = cf self.handler_m = handler_m - self.num_consumers = consumers + self.num_consumers = num_consumers async def __call__(self, service_lifetime: ServiceLifetimeManager): + assert self.num_consumers > 0 self._lifetime = service_lifetime # Expose service lifetime events ack_queue_writer, ack_queue_reader = MemoryStream.create(math.inf) - batched_ack_queue_reader = BatchReceiver(ack_queue_reader, batch_size=10, batch_window=1.0) + batched_ack_queue_reader = BatchReceiver[SqsMessage, list[SqsMessage]]( + ack_queue_reader, batch_size=10, batch_window=1.0 + ) robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) + async def acknowledge_messages(messages: Collection[SqsMessage]) -> None: + if isinstance(client, ConsumerClient): + await to_thread.run_sync(client.delete, messages) + else: + await client.delete(messages) + # Graceful shutdown scopes, one per consumer task (thread) consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] async with ( self.client_factory() as client, - create_stream_consumer(batched_ack_queue_reader, client.delete, concurrency=math.inf), + create_stream_consumer(batched_ack_queue_reader, acknowledge_messages, concurrency=math.inf), ack_queue_writer, self.handler_m as handler, - create_task_group() as tg): - message_handler = ensure_async_handler(handler) + create_task_group() as tg, + ): service_lifetime.set_started() # Start pulling messages only after the whole app is started await service_lifetime.host.started - for consumer_scope in consumer_scopes: - tg.start_soon(_consume_async, client, message_handler, robust_ack_queue, consumer_scope) + if isinstance(client, ConsumerClient): + sync_m_handler = ensure_sync_handler(handler) + for cs in consumer_scopes: + tg.start_soon(_consume_sync, client, sync_m_handler, robust_ack_queue, cs) + else: + async_m_handler = ensure_async_handler(handler) + for cs in consumer_scopes: + tg.start_soon(_consume_async, client, async_m_handler, robust_ack_queue, cs) await service_lifetime.shutting_down - for consumer_scope in consumer_scopes: - consumer_scope.cancel() + for cs in consumer_scopes: + cs.cancel() -# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function +@overload def sqs_queue_consumer( - cf: AsyncClientFactory, /, *, consumers: int = 1 + queue_name_or_url: str, /, *, num_consumers: int = 1 ) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: - """ Decorator to create an SQS queue consumer hosted service. """ - return lambda handler: SqsConsumerService( - cf, ensure_async_handler_manager(handler), - consumers=consumers, - ) - - -class LambdaEventRecordMessageAttributeValue(TypedDict): - dataType: str - stringValue: str - binaryValue: bytes - stringListValues: Sequence[str] - binaryListValues: Sequence[bytes] - + """Decorator to create an SQS queue consumer (boto3) hosted service.""" -class LambdaEventRecord(TypedDict): - """ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "myAttribute": { - "stringValue": "myValue", - "stringListValues": [], - "binaryListValues": [], - "dataType": "String" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2" - } - """ - messageId: str - receiptHandle: str - body: str - attributes: Mapping[MessageSystemAttributeNameType, str] - messageAttributes: Mapping[str, LambdaEventRecordMessageAttributeValue] - md5OfBody: str - eventSource: str - eventSourceARN: str - awsRegion: str +@overload +def sqs_queue_consumer( + cf: AnyClientFactory, /, *, num_consumers: int = 1 +) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: + """Decorator to create an SQS queue consumer hosted service.""" -class LambdaEvent(TypedDict): - Records: Sequence[LambdaEventRecord] +# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function +def sqs_queue_consumer( + cf_or_q: AnyClientFactory | str, /, *, num_consumers: int = 1 +) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: + cf = client_factory(cf_or_q) if isinstance(cf_or_q, str) else cf_or_q + return lambda handler_m: SqsConsumerService(cf, handler_m, num_consumers=num_consumers) def _message2lambda(m: SqsMessage, /) -> LambdaEventRecord: @@ -394,7 +499,7 @@ def lambda_handler( @flow.handler def _handler(workload: Sequence[SqsMessage]) -> None: - lambda_event: LambdaEvent = {"Records": []} + lambda_event = cast(LambdaEvent, {"Records": []}) messages = SqsMessages(workload) lambda_event["Records"] = [_message2lambda(m) for m in messages] with messages: diff --git a/localpost/consumers/sqs_otel.py b/localpost/consumers/sqs_otel.py index 6917f45..edf1268 100644 --- a/localpost/consumers/sqs_otel.py +++ b/localpost/consumers/sqs_otel.py @@ -1,11 +1,11 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Callable, Sequence from contextlib import AbstractContextManager, contextmanager from typing import TypeVar from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( # noqa +from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( create_messaging_client_consumed_messages, create_messaging_client_operation_duration, ) @@ -18,7 +18,6 @@ from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware T = TypeVar("T", SqsMessage, Sequence[SqsMessage]) -R = TypeVar("R", Awaitable[None], None) __all__ = ["trace"] @@ -37,18 +36,21 @@ def create_message_tracer( messages_consumed = create_messaging_client_consumed_messages(meter) @contextmanager - def call_tracer(message: SqsMessage | Sequence[SqsMessage]): - is_batch = not isinstance(message, SqsMessage) - queue_name = message.queue_name if isinstance(message, SqsMessage) else message[0].queue_name + def call_tracer(messages: Sequence[SqsMessage]): + assert len(messages) > 0, "Message batch must not be empty" + is_batch = not isinstance(messages, SqsMessage) + message = messages[0] + queue_name = message.client.queue_name attrs: dict[str, AttributeValue] = { - "messaging.operation.type": "process", "messaging.system": "aws_sqs", + "messaging.operation.name": "process", + "messaging.operation.type": "process", "messaging.destination.name": queue_name, } if is_batch: - attrs["messaging.batch.message_count"] = len(message) + attrs["messaging.batch.message_count"] = len(messages) - messages_consumed.add(len(message) if is_batch else 1, attrs) + messages_consumed.add(len(messages), attrs) with tracer.start_as_current_span(f"process {queue_name}", kind=SpanKind.CONSUMER, attributes=attrs): with rec_duration(m_process_duration, attrs): yield diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py index 975e19c..db8a0ec 100644 --- a/localpost/consumers/stream.py +++ b/localpost/consumers/stream.py @@ -1,6 +1,6 @@ import math from collections.abc import Callable -from typing import TypeVar, Generic +from typing import Generic, TypeVar from anyio.abc import ObjectReceiveStream @@ -33,21 +33,31 @@ def __init__( self.process_leftovers = process_leftovers async def __call__(self, service_lifetime: ServiceLifetimeManager): - async with self.handler_m as handler, create_stream_consumer( - self.target, ensure_async_handler(handler, max_threads=self.concurrency), concurrency=self.concurrency, - ) as consumer_state: + receiver = self.target + async with ( + receiver, + self.handler_m as handler, + create_stream_consumer( + receiver, + ensure_async_handler(handler, max_threads=self.concurrency), + concurrency=self.concurrency, + ) as consumer_state, + ): service_lifetime.set_started() if not self.process_leftovers: await wait_any(service_lifetime.shutting_down, consumer_state.closed) - await self.target.aclose() + await receiver.aclose() # Otherwise just wait for the stream to be exhausted and closed def stream_consumer( - target: ObjectReceiveStream[T], /, *, concurrency: int = 1, process_leftovers: bool = True, + target: ObjectReceiveStream[T], + /, + *, + concurrency: int = 1, + process_leftovers: bool = True, ) -> Callable[[AnyHandlerManager[T]], StreamConsumerService[T]]: - """ Decorator to create a stream consumer hosted service. """ + """Decorator to create a stream consumer hosted service.""" return lambda handler_m: StreamConsumerService( - target, handler_m, - concurrency=concurrency, process_leftovers=process_leftovers + target, handler_m, concurrency=concurrency, process_leftovers=process_leftovers ) diff --git a/localpost/consumers/stream_otel.py b/localpost/consumers/stream_otel.py index 79ccf31..a5f66d2 100644 --- a/localpost/consumers/stream_otel.py +++ b/localpost/consumers/stream_otel.py @@ -1,30 +1,28 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Collection +from collections.abc import Callable, Collection from contextlib import AbstractContextManager, contextmanager -from typing import Any +from typing import TypeVar from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( # noqa +from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( create_messaging_client_consumed_messages, create_messaging_client_operation_duration, ) from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider from opentelemetry.util.types import AttributeValue -from typing_extensions import TypeVar from localpost import __version__ from localpost._otel_utils import rec_duration from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware -T = TypeVar("T", default=Any) -TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) -R = TypeVar("R", Awaitable[None], None) +T = TypeVar("T") +TC = TypeVar("TC", bound=Collection[object]) __all__ = ["trace", "trace_batch"] -def create_message_tracer( +def _create_message_tracer( queue_name: str, batched: bool, tp: TracerProvider | None, @@ -40,8 +38,9 @@ def create_message_tracer( messages_consumed = create_messaging_client_consumed_messages(meter) @contextmanager - def call_tracer(message: T): + def call_tracer(message): attrs: dict[str, AttributeValue] = { + "messaging.operation.name": "process", "messaging.operation.type": "process", "messaging.system": "localpost_streams", "messaging.destination.name": queue_name, @@ -62,7 +61,7 @@ def trace( ) -> HandlerDecorator[T, T]: @handler_middleware async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(stream_name, False, tp, mp) + call_tracer = _create_message_tracer(stream_name, False, tp, mp) async def _handle_async(item): with call_tracer(item): @@ -82,7 +81,7 @@ def trace_batch( ) -> HandlerDecorator[TC, TC]: @handler_middleware async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(stream_name, False, tp, mp) + call_tracer = _create_message_tracer(stream_name, True, tp, mp) async def _handle_async(item): with call_tracer(item): diff --git a/localpost/flow/__init__.py b/localpost/flow/__init__.py index dc1d573..cf8e900 100644 --- a/localpost/flow/__init__.py +++ b/localpost/flow/__init__.py @@ -9,10 +9,10 @@ HandlerMiddleware, SyncHandler, SyncHandlerManager, - ensure_async_handler_manager, ensure_async_handler, - ensure_sync_handler_manager, + ensure_async_handler_manager, ensure_sync_handler, + ensure_sync_handler_manager, handler, handler_manager, handler_middleware, diff --git a/localpost/flow/_flow.py b/localpost/flow/_flow.py index 6327636..6994c61 100644 --- a/localpost/flow/_flow.py +++ b/localpost/flow/_flow.py @@ -44,7 +44,9 @@ HandlerMiddleware: TypeAlias = Callable[ ["FlowHandler[T]"], # Next handler, as FlowHandler[T] - AsyncGenerator[Callable[[T2], Awaitable[None] | None]], # FlowHandler[T2] or a handler func + AsyncGenerator[ + Callable[[T2], Awaitable[None] | None] | tuple[Callable[[T2], Awaitable[None]], Callable[[T2], None]], None + ], # FlowHandler[T2] or a handler func ] _HandlerMiddleware: TypeAlias = Callable[ ["FlowHandler[T]"], # Next handler, as FlowHandler[T] @@ -76,7 +78,8 @@ def handler(func: AnyHandler[T]) -> FlowHandlerManager[T]: Wrap a function, so it can be used as a flow handler. """ assert callable(func) - assert not inspect.isgeneratorfunction(func) and not inspect.isasyncgenfunction(func) + assert not inspect.isgeneratorfunction(func) + assert not inspect.isasyncgenfunction(func) return FlowHandlerManager(lambda: nullcontext(FlowHandler.ensure(func))) @@ -102,7 +105,10 @@ async def _handler_manager(*args, **kwargs): def ensure_async_handler( - h: AnyHandler[T], /, *, max_threads: int | float | CapacityLimiter | None = None, + h: AnyHandler[T], + /, + *, + max_threads: int | float | CapacityLimiter | None = None, ) -> AsyncHandler[T]: if isinstance(h, FlowHandler): handle_async = h.async_h if h.is_async else ensure_async_callable(h.sync_h, max_threads=max_threads) @@ -154,7 +160,8 @@ def _handler_middleware(m: HandlerMiddleware[T, T2]) -> _HandlerMiddleware[T, T2 @asynccontextmanager async def _handler_manager(next_h: FlowHandler[T]): async with source(next_h) as h: - if isinstance(h, tuple) and len(h) == 2: + if isinstance(h, tuple): + assert len(h) == 2 async_h, sync_h = h # If the handler is a tuple, it should be (async_h, sync_h) yield next_h.create(async_h=async_h, sync_h=sync_h) else: diff --git a/localpost/flow/_ops.py b/localpost/flow/_ops.py index 953f8c3..4f3b9f4 100644 --- a/localpost/flow/_ops.py +++ b/localpost/flow/_ops.py @@ -2,7 +2,7 @@ import math import time -from collections.abc import Awaitable, Callable, Iterable, Sequence, Collection +from collections.abc import Awaitable, Callable, Collection, Iterable, Sequence from typing import Any, Literal, overload from anyio import fail_after @@ -13,9 +13,11 @@ MemoryStream, ensure_async_callable, ensure_delay_factory, + ensure_int_or_inf, ensure_sync_callable, sleep, ) + from ._flow import ( FlowHandler, HandlerDecorator, @@ -23,7 +25,7 @@ handler_middleware, logger, ) -from ._stream import create_stream_consumer, BatchReceiver +from ._stream import BatchReceiver, create_stream_consumer T = TypeVar("T", default=Any) T2 = TypeVar("T2", default=Any) @@ -59,13 +61,13 @@ async def middleware(next_h: FlowHandler): async def _handle_async(item): try: await next_h.async_h(item) - except Exception: # noqa + except Exception: h_logger.exception("Error while processing a message") def _handle_sync(item): try: next_h.sync_h(item) - except Exception: # noqa + except Exception: h_logger.exception("Error while processing a message") yield _handle_async, _handle_sync @@ -105,34 +107,35 @@ def _handle_sync(item): def buffer( - capacity: float, + capacity: int | float, /, *, - concurrency: int = 1, + concurrency: int | float = 1, process_leftovers: bool = True, full_mode: Literal["wait", "drop"] = "wait", ) -> HandlerDecorator[Any, Any]: - """ Buffer items in an async in-memory stream. """ - if capacity < 0: - raise ValueError("Buffer capacity must be greater than or equal to 0") - if concurrency < 1: - raise ValueError("Concurrency must be greater than or equal to 1") + """Buffer items in an async in-memory stream.""" @handler_middleware async def middleware(next_h: FlowHandler): buffer_writer, buffer_reader = MemoryStream.create(capacity) stream_h = ensure_async_handler(next_h, max_threads=concurrency) - consumer = create_stream_consumer(buffer_reader, stream_h, concurrency, process_leftovers) - async with consumer, buffer_writer: # As usual, order matters - if math.isinf(capacity) or full_mode == "drop": - yield buffer_writer.send_or_drop_async, buffer_writer.send_or_drop - else: - yield buffer_writer.send - + # As usual, order matters + async with create_stream_consumer(buffer_reader, stream_h, concurrency=concurrency): + async with buffer_writer: + if math.isinf(capacity) or full_mode == "drop": + yield buffer_writer.send_or_drop, buffer_writer.send_or_drop_from_thread + else: + yield buffer_writer.send + if not process_leftovers: + buffer_reader.close() + + ensure_int_or_inf(capacity, min_value=0, name="Buffer capacity") + ensure_int_or_inf(concurrency, min_value=1, name="Concurrency") return middleware -# Implement later +# Maybe implement later # def sync_buffer( # capacity: float, # /, @@ -172,7 +175,7 @@ def batch( def batch( batch_size: int, batch_window: int | float, # Seconds - items_f: Callable[[Sequence[T]], TC] | None = None, + items_f: Callable[[list[T]], TC] | None = None, /, *, capacity: int | float = 0, @@ -186,31 +189,32 @@ def batch( """ if batch_size < 1: raise ValueError("Batch size must be greater than or equal to 1") - if batch_window < 0: + if batch_window <= 0: raise ValueError("Batch window must be greater than 0") - if capacity < 0: - raise ValueError("Buffer capacity must be greater than or equal to 0") + ensure_int_or_inf(capacity, min_value=0, name="Buffer capacity") @handler_middleware - async def _middleware(next_h: FlowHandler[Sequence[object]]): + async def _middleware(next_h: FlowHandler[Collection[object]]): buffer_writer, buffer_reader = MemoryStream[T].create(capacity) - buffer_batch_reader = BatchReceiver(buffer_reader, - batch_size, batch_window, items_f=items_f or (lambda x: x)) + buffer_batch_reader = BatchReceiver[T, TC]( + buffer_reader, batch_size=batch_size, batch_window=batch_window, items_f=items_f + ) stream_h = ensure_async_handler(next_h) - consumer = create_stream_consumer(buffer_batch_reader, stream_h, - concurrency=1, - process_leftovers=process_leftovers) - async with consumer, buffer_writer: # As usual, order matters - if math.isinf(capacity) or full_mode == "drop": - yield buffer_writer.send_or_drop_async, buffer_writer.send_or_drop - else: - yield buffer_writer.send + async with create_stream_consumer(buffer_batch_reader, stream_h, concurrency=1): + async with buffer_writer: + if math.isinf(capacity) or full_mode == "drop": + yield buffer_writer.send_or_drop, buffer_writer.send_or_drop_from_thread + else: + yield buffer_writer.send + if not process_leftovers: + buffer_reader.close() return _middleware def timeout(duration: float, /) -> HandlerDecorator[T, T]: - """ Async timeout middleware. """ + """Async timeout middleware.""" + @handler_middleware async def middleware(next_h: FlowHandler[T]): async def _handle_async(item: T): diff --git a/localpost/flow/_stream.py b/localpost/flow/_stream.py index 08f925e..39fcff6 100644 --- a/localpost/flow/_stream.py +++ b/localpost/flow/_stream.py @@ -1,20 +1,22 @@ import dataclasses as dc import math -from collections.abc import Callable, Collection +from collections.abc import AsyncGenerator, Callable, Collection from contextlib import asynccontextmanager -from typing import Any, Generic +from typing import Any, Generic, cast, final -from anyio import EndOfStream, ClosedResourceError, create_task_group, move_on_after +from anyio import ClosedResourceError, EndOfStream, create_task_group, move_on_after from anyio.abc import ObjectReceiveStream from typing_extensions import TypeVar -from localpost._utils import start_task_soon, EventView, Event +from localpost._utils import Event, EventView, start_task_soon + from ._flow import AsyncHandler, logger T = TypeVar("T", default=Any) TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) +@final @dc.dataclass(frozen=True, eq=False, slots=True) class StreamConsumerState: closed: EventView @@ -27,14 +29,12 @@ async def create_stream_consumer( /, *, concurrency: int | float = 1, - process_leftovers: bool = True, # FIXME Remove -): - if math.isinf(concurrency): - async def handle(item: T) -> None: - # Infinite concurrency, just spawn a new task for each item - tg.start_soon(h, item) - else: - handle = h +) -> AsyncGenerator[StreamConsumerState, None]: + async def schedule_handler(item: T) -> None: + # Infinite concurrency, just spawn a new task for each item + tg.start_soon(h, item) + + handle = schedule_handler if math.isinf(concurrency) else h async def source_items(): try: @@ -53,28 +53,35 @@ async def consume(): closed = Event() async with stream, create_task_group() as tg: - for _ in range(1 if math.isinf(concurrency) else concurrency): + for _ in range(1 if math.isinf(concurrency) else cast(int, concurrency)): start_task_soon(tg, consume) yield StreamConsumerState(closed) - -@dc.dataclass(frozen=True, eq=False, slots=True) +@final class BatchReceiver(Generic[T, TC], ObjectReceiveStream[TC]): - source: ObjectReceiveStream[T] - batch_size: int - batch_window: float - items_f: Callable[[list[T]], TC] = lambda x: x - - def __post_init__(self): - if self.batch_size < 1: + def __init__( + self, + source: ObjectReceiveStream[T], + /, + *, + batch_size: int, + batch_window: float, + items_f: Callable[[list[T]], TC] | None = None, + ): + if batch_size < 1: raise ValueError("Batch size must be at least 1") - if self.batch_window <= 0: + if batch_window <= 0: raise ValueError("Batch window must be greater than 0 seconds") + self.source = source + self.batch_size = batch_size + self.batch_window = batch_window + self.items_f: Callable[[list[T]], TC] = items_f if items_f is not None else lambda x: cast(TC, x) + async def receive(self) -> TC: while True: - items = [] + items: list[T] = [] try: with move_on_after(self.batch_window): while len(items) < self.batch_size: diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index cda2fb2..f8d5f2a 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -15,6 +15,7 @@ TYPE_CHECKING, Any, ClassVar, + Concatenate, Final, Literal, ParamSpec, @@ -22,7 +23,6 @@ TypeAlias, TypedDict, TypeVar, - Union, cast, final, overload, @@ -37,9 +37,9 @@ ) from anyio.abc import TaskGroup, TaskStatus from anyio.from_thread import BlockingPortal, start_blocking_portal -from typing_extensions import Concatenate, Self +from typing_extensions import Self -from localpost._debug import debug +from localpost._debug import DebugState, debug from localpost._utils import ( NO_OP_TS, Event, @@ -98,8 +98,8 @@ class Stopped: exception: BaseException | None = None -ServiceState = Union[Starting, Running, ShuttingDown, Stopped] -HostState = Union[Created, Starting, Running, ShuttingDown, Stopped] +ServiceState = Starting | Running | ShuttingDown | Stopped +HostState = Created | Starting | Running | ShuttingDown | Stopped # Just a dict, ready for (JSON) serialization @@ -223,23 +223,9 @@ def start_child_service( # type: ignore[misc] name: str | None = None, ) -> ServiceLifetime: ... - # 1. PyCharm (at least 2024.03) has a bug when calling a TypeVarTuple-parameterized function with 0 arguments (see - # https://youtrack.jetbrains.com/issue/PY-63820), - # 2. mypy (at least 1.15.0) does not like overloads ("error: Incompatible return value type ... [return-value]"), - # So just skip complex typing here, for now - def start_child_service( # type: ignore[misc] - self, - func: ServiceFunc, - /, - *func_args, - name: str | None = None, - ) -> ServiceLifetime: ... # Everything that can be used as a hosted service, see HostedService.create() -ServiceFunc: TypeAlias = Union[ - Callable[..., Awaitable[None]], - Callable[..., None], -] +ServiceFunc: TypeAlias = Callable[..., Awaitable[None]] | Callable[..., None] if TYPE_CHECKING: HostedServiceFunc: TypeAlias = Callable[Concatenate[ServiceLifetimeManager, ...], Awaitable[None]] @@ -411,13 +397,13 @@ def start_service(): async def _run_service( - svc_lifetime: _ServiceLifetime, svc_func, func_args: Iterable[Any], raise_on_error: bool + svc_lifetime: _ServiceLifetime, svc_func, svc_func_args: Iterable[Any], raise_on_error: bool | DebugState ) -> None: svc_name = svc_lifetime.name logger.debug(f"Starting {svc_name}...") try: async with svc_lifetime.tg: # Used for the service itself and its child services - await svc_func(svc_lifetime, *func_args) + await svc_func(svc_lifetime, *svc_func_args) svc_lifetime.set_shutting_down() for child in svc_lifetime.child_services: child.shutdown() @@ -513,7 +499,7 @@ def __init__(self, s: HostedServiceFunc, /, **attrs): del attrs["name"] source, attrs = self._unwrap(s, attrs) if not callable(source): - raise ValueError(f"Invalid service source: {source}") + raise TypeError(f"Invalid service source: {source}") object.__setattr__(self, "source", source) object.__setattr__(self, "_attrs", attrs) @@ -859,7 +845,7 @@ def __call__(self, sl: ServiceLifetimeManager) -> Awaitable[None]: @asynccontextmanager async def _aserve_in(self, portal: BlockingPortal, exec_tg: TaskGroup | None = None): # A premature optimization, to save one task group nesting level - tg: TaskGroup = exec_tg if exec_tg else portal._task_group # noqa + tg: TaskGroup = exec_tg if exec_tg else portal._task_group self._lifetime = sl = _ServiceLifetime(self.name, self, tg) self._exec_context = _HostExecContext(sl, portal, threading.get_ident(), sl.tg.cancel_scope) tg.start_soon(_run_service, sl, self._prepare_for_run(), (), debug) diff --git a/localpost/hosting/middlewares.py b/localpost/hosting/middlewares.py index 2f26694..da7fae1 100644 --- a/localpost/hosting/middlewares.py +++ b/localpost/hosting/middlewares.py @@ -9,8 +9,8 @@ __all__ = [ "lifespan", - "start_timeout", "shutdown_timeout", + "start_timeout", ] diff --git a/localpost/hosting/services/__init__.py b/localpost/hosting/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/hosting/grpc.py b/localpost/hosting/services/grpc.py similarity index 95% rename from localpost/hosting/grpc.py rename to localpost/hosting/services/grpc.py index 9effca5..95ea78f 100644 --- a/localpost/hosting/grpc.py +++ b/localpost/hosting/services/grpc.py @@ -4,7 +4,7 @@ from localpost._utils import wait_any -from ._host import ServiceLifetimeManager +from .._host import ServiceLifetimeManager @final diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py new file mode 100644 index 0000000..23da148 --- /dev/null +++ b/localpost/hosting/services/hypercorn.py @@ -0,0 +1,24 @@ +from collections.abc import Callable +from typing import Any, final + +import hypercorn +from sniffio import current_async_library + +from .._host import ServiceLifetimeManager + + +@final +class HypercornService: + def __init__(self, app: Callable[..., Any], config: hypercorn.Config): + self.app = app + self.config = config + self.name = "hypercorn" + + # See https://hypercorn.readthedocs.io/en/latest/how_to_guides/api_usage.html + async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: + if current_async_library() == "trio": + from hypercorn.trio import serve + else: + from hypercorn.asyncio import serve # type: ignore[assignment] + + await serve(self.app, self.config, shutdown_trigger=service_lifetime.shutting_down.wait) diff --git a/localpost/hosting/http.py b/localpost/hosting/services/uvicorn.py similarity index 92% rename from localpost/hosting/http.py rename to localpost/hosting/services/uvicorn.py index 8a8acf8..fd3cb85 100644 --- a/localpost/hosting/http.py +++ b/localpost/hosting/services/uvicorn.py @@ -1,5 +1,8 @@ +from __future__ import annotations + +from collections.abc import Callable from os import getenv -from typing import Any, Callable, cast, final +from typing import Any, cast, final import uvicorn from anyio import create_task_group @@ -7,7 +10,7 @@ from localpost._utils import start_task_soon -from ._host import ServiceLifetimeManager +from .._host import ServiceLifetimeManager # Also see /health endpoint in http_app.py example @@ -31,7 +34,7 @@ def for_app(cls, app: Callable[..., Any]) -> Self: # It is hard to use server.serve() directly, because it overrides the signal handlers. A possible workaround is # to call it in a separate thread, but currently it looks like an overkill. # See uvicorn.Server._serve() for the original implementation. - async def __call__(self, service_lifetime: ServiceLifetimeManager): + async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: config = self.config server = uvicorn.Server(config) diff --git a/localpost/hosting/utils.py b/localpost/hosting/utils.py index c259c9f..daa7767 100644 --- a/localpost/hosting/utils.py +++ b/localpost/hosting/utils.py @@ -1,5 +1,5 @@ import dataclasses as dc -from typing import TypeVar, final, Protocol +from typing import Protocol, TypeVar, final from anyio.streams.memory import MemoryObjectSendStream @@ -7,7 +7,7 @@ T = TypeVar("T", contravariant=True) -__all__ = ["ThreadSafeSendStream", "ThreadSafeMemorySendStream"] +__all__ = ["ThreadSafeMemorySendStream", "ThreadSafeSendStream"] class ThreadSafeSendStream(Protocol[T]): diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index 6c579d4..e0bec76 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -21,12 +21,12 @@ ClosingContext, EventView, MemoryStream, + Result, cancellable_from, ensure_td, sleep, start_task_soon, td_str, - Result ) from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Task, Trigger diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 8dd2041..9323144 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -6,12 +6,13 @@ import math from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, ExitStack -from typing import Any, Generic, Protocol, TypeAlias, TypeVar, Union, cast, final +from typing import Any, Generic, Protocol, TypeAlias, TypeVar, cast, final -from anyio import BrokenResourceError, WouldBlock, create_memory_object_stream, to_thread +from anyio import BrokenResourceError, WouldBlock, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( + MemoryStream, Result, def_full_name, is_async_callable, @@ -31,12 +32,7 @@ R = TypeVar("R") DecF = TypeVar("DecF", bound=Callable[..., Any]) -TaskHandler: TypeAlias = Union[ - Callable[[T], Awaitable[R]], - Callable[[], Awaitable[R]], - Callable[[T], R], - Callable[[], R], -] +TaskHandler: TypeAlias = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] logger = logging.getLogger("localpost.scheduler") @@ -74,7 +70,7 @@ def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStr # there is a free reader. We do not want to block the task execution flow in any way, so: # - the buffer is unbounded by default # - if the buffer is full, the result is dropped (see publish method below) - send_stream, receive_stream = create_memory_object_stream[Result[R]](buffer_max_size) + send_stream, receive_stream = MemoryStream[Result[R]].create(buffer_max_size) self._subscribers.append(self._cm.enter_context(send_stream)) return receive_stream @@ -130,7 +126,7 @@ def __call__(self, *args, **kwargs) -> Trigger[T]: return self.tf(*args, **kwargs) def __truediv__(self, middleware: TriggerFactoryMiddleware[T, T2]) -> ScheduledTaskTemplate[T2]: - from ._trigger import trigger_factory_middleware + from ._trigger import trigger_factory_middleware # noqa: PLC0415 return self // trigger_factory_middleware(middleware) diff --git a/pdm.lock b/pdm.lock index ec42cb6..748da86 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "cron", "dev", "dev-consumers", "dev-otel", "dev-types", "examples", "grpc", "http", "integration-tests", "kafka", "nats", "rabbitmq", "scheduler", "sqs", "tests", "unit-tests"] +groups = ["default", "azure-queue", "azure-servicebus", "cron", "dev", "dev-consumers", "dev-hosting-grpc", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "kafka", "nats", "pubsub", "rabbitmq", "scheduler", "sqs", "tests", "unit-tests"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:e81209488e404ddd541f07180a6ce3343eea258fcd615c45b0f908bdb6b3d0fd" +content_hash = "sha256:440b34dcf15d89513862f0ce2faf2234bf1d10c7671b96e7b1357b919ae3329d" [[metadata.targets]] requires_python = ">=3.10" @@ -27,12 +27,27 @@ files = [ {file = "aio_pika-9.5.5.tar.gz", hash = "sha256:3d2f25838860fa7e209e21fc95555f558401f9b49a832897419489f1c9e1d6a4"}, ] +[[package]] +name = "aioboto3" +version = "15.0.0" +requires_python = "<4.0,>=3.9" +summary = "Async boto3 wrapper" +groups = ["dev-consumers"] +dependencies = [ + "aiobotocore[boto3]==2.23.0", + "aiofiles>=23.2.1", +] +files = [ + {file = "aioboto3-15.0.0-py3-none-any.whl", hash = "sha256:9cf54b3627c8b34bb82eaf43ab327e7027e37f92b1e10dd5cfe343cd512568d0"}, + {file = "aioboto3-15.0.0.tar.gz", hash = "sha256:dce40b701d1f8e0886dc874d27cd9799b8bf6b32d63743f57e7bef7e4a562756"}, +] + [[package]] name = "aiobotocore" version = "2.23.0" requires_python = ">=3.9" summary = "Async client for aws services using botocore and aiohttp" -groups = ["sqs"] +groups = ["dev-consumers"] dependencies = [ "aiohttp<4.0.0,>=3.9.2", "aioitertools<1.0.0,>=0.5.1", @@ -47,12 +62,39 @@ files = [ {file = "aiobotocore-2.23.0.tar.gz", hash = "sha256:0333931365a6c7053aee292fe6ef50c74690c4ae06bb019afdf706cb6f2f5e32"}, ] +[[package]] +name = "aiobotocore" +version = "2.23.0" +extras = ["boto3"] +requires_python = ">=3.9" +summary = "Async client for aws services using botocore and aiohttp" +groups = ["dev-consumers"] +dependencies = [ + "aiobotocore==2.23.0", + "boto3<1.38.28,>=1.38.23", +] +files = [ + {file = "aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:8202cebbf147804a083a02bc282fbfda873bfdd0065fd34b64784acb7757b66e"}, + {file = "aiobotocore-2.23.0.tar.gz", hash = "sha256:0333931365a6c7053aee292fe6ef50c74690c4ae06bb019afdf706cb6f2f5e32"}, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +requires_python = ">=3.8" +summary = "File support for asyncio." +groups = ["dev-consumers"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" requires_python = ">=3.9" summary = "Happy Eyeballs for asyncio" -groups = ["sqs"] +groups = ["dev-consumers"] files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, @@ -60,13 +102,13 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.13" +version = "3.12.14" requires_python = ">=3.9" summary = "Async http client/server framework (asyncio)" -groups = ["sqs"] +groups = ["dev-consumers"] dependencies = [ "aiohappyeyeballs>=2.5.0", - "aiosignal>=1.1.2", + "aiosignal>=1.4.0", "async-timeout<6.0,>=4.0; python_version < \"3.11\"", "attrs>=17.3.0", "frozenlist>=1.1.1", @@ -75,75 +117,75 @@ dependencies = [ "yarl<2.0,>=1.17.0", ] files = [ - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6"}, - {file = "aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad"}, - {file = "aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3"}, - {file = "aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd"}, - {file = "aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5"}, - {file = "aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf"}, - {file = "aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3"}, - {file = "aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd"}, - {file = "aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706"}, - {file = "aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, + {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, + {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, + {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, + {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, + {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, + {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, + {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, + {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, + {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, ] [[package]] @@ -151,7 +193,7 @@ name = "aioitertools" version = "0.12.0" requires_python = ">=3.8" summary = "itertools and builtins for AsyncIO and mixed iterables" -groups = ["sqs"] +groups = ["dev-consumers"] dependencies = [ "typing-extensions>=4.0; python_version < \"3.10\"", ] @@ -178,16 +220,17 @@ files = [ [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" requires_python = ">=3.9" summary = "aiosignal: a list of registered asynchronous callbacks" -groups = ["sqs"] +groups = ["dev-consumers"] dependencies = [ "frozenlist>=1.1.0", + "typing-extensions>=4.2; python_version < \"3.13\"", ] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] [[package]] @@ -278,7 +321,7 @@ name = "async-timeout" version = "5.0.1" requires_python = ">=3.8" summary = "Timeout context manager for asyncio programs" -groups = ["sqs"] +groups = ["dev-consumers"] marker = "python_version < \"3.11\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, @@ -290,7 +333,7 @@ name = "attrs" version = "25.3.0" requires_python = ">=3.8" summary = "Classes Without Boilerplate" -groups = ["dev-consumers", "sqs", "tests"] +groups = ["dev-consumers", "tests"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, @@ -312,7 +355,7 @@ files = [ [[package]] name = "aws-lambda-powertools" -version = "3.15.1" +version = "3.16.0" requires_python = "<4.0.0,>=3.9" summary = "Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverless best practices and increase developer velocity." groups = ["dev-consumers"] @@ -321,8 +364,75 @@ dependencies = [ "typing-extensions<5.0.0,>=4.11.0", ] files = [ - {file = "aws_lambda_powertools-3.15.1-py3-none-any.whl", hash = "sha256:673ccfb27087d2940e798b2618e9cb992981ccb1d0310bc17c4d1c69ebcbec11"}, - {file = "aws_lambda_powertools-3.15.1.tar.gz", hash = "sha256:31c19887e012287da539f8c319da04e3f50c5f37d7b7ea1f0cd1c011f7ff05a8"}, + {file = "aws_lambda_powertools-3.16.0-py3-none-any.whl", hash = "sha256:b37843d4873b88fedd8eed507fdbf3eb7193e48a6b662b1e7b41e741cdcc765b"}, + {file = "aws_lambda_powertools-3.16.0.tar.gz", hash = "sha256:df0e5b6e575bf8fd30101d3c39358deb3ccbed195756343e44af34d05345f128"}, +] + +[[package]] +name = "azure-core" +version = "1.35.0" +requires_python = ">=3.9" +summary = "Microsoft Azure Core Library for Python" +groups = ["azure-queue", "azure-servicebus"] +dependencies = [ + "requests>=2.21.0", + "six>=1.11.0", + "typing-extensions>=4.6.0", +] +files = [ + {file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"}, + {file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"}, +] + +[[package]] +name = "azure-identity" +version = "1.23.0" +requires_python = ">=3.9" +summary = "Microsoft Azure Identity Library for Python" +groups = ["azure-queue", "azure-servicebus"] +dependencies = [ + "azure-core>=1.31.0", + "cryptography>=2.5", + "msal-extensions>=1.2.0", + "msal>=1.30.0", + "typing-extensions>=4.0.0", +] +files = [ + {file = "azure_identity-1.23.0-py3-none-any.whl", hash = "sha256:dbbeb64b8e5eaa81c44c565f264b519ff2de7ff0e02271c49f3cb492762a50b0"}, + {file = "azure_identity-1.23.0.tar.gz", hash = "sha256:d9cdcad39adb49d4bb2953a217f62aec1f65bbb3c63c9076da2be2a47e53dde4"}, +] + +[[package]] +name = "azure-servicebus" +version = "7.14.2" +requires_python = ">=3.8" +summary = "Microsoft Azure Service Bus Client Library for Python" +groups = ["azure-servicebus"] +dependencies = [ + "azure-core>=1.28.0", + "isodate>=0.6.0", + "typing-extensions>=4.6.0", +] +files = [ + {file = "azure_servicebus-7.14.2-py3-none-any.whl", hash = "sha256:154bdde487ae3bd581fd49d207da4892091d9b61ab95f4cd4deab6dd4bb4649a"}, + {file = "azure_servicebus-7.14.2.tar.gz", hash = "sha256:4014b7ac882e0d9ff876a3302818607e1a640b93e9d482073d639f5b04266e5c"}, +] + +[[package]] +name = "azure-storage-queue" +version = "12.12.0" +requires_python = ">=3.8" +summary = "Microsoft Azure Azure Queue Storage Client Library for Python" +groups = ["azure-queue"] +dependencies = [ + "azure-core>=1.30.0", + "cryptography>=2.1.4", + "isodate>=0.6.1", + "typing-extensions>=4.6.0", +] +files = [ + {file = "azure_storage_queue-12.12.0-py3-none-any.whl", hash = "sha256:9305f724e0df6a93e3645bf075b5a7e3fc0a1eb1ee47c85912c7aff6b6fd490d"}, + {file = "azure_storage_queue-12.12.0.tar.gz", hash = "sha256:baf2f1bc82b7d4f5291922c3ea4f23ce2243e942dbe7494fca1782290b37f1e4"}, ] [[package]] @@ -344,7 +454,7 @@ name = "boto3" version = "1.38.27" requires_python = ">=3.9" summary = "The AWS SDK for Python" -groups = ["integration-tests"] +groups = ["dev-consumers", "integration-tests"] dependencies = [ "botocore<1.39.0,>=1.38.27", "jmespath<2.0.0,>=0.7.1", @@ -360,7 +470,7 @@ name = "botocore" version = "1.38.27" requires_python = ">=3.9" summary = "Low-level, data-driven core of boto 3." -groups = ["integration-tests", "sqs"] +groups = ["dev-consumers", "integration-tests", "sqs"] dependencies = [ "jmespath<2.0.0,>=0.7.1", "python-dateutil<3.0.0,>=2.1", @@ -374,38 +484,38 @@ files = [ [[package]] name = "botocore-stubs" -version = "1.38.30" -requires_python = ">=3.8" +version = "1.38.46" +requires_python = ">=3.9" summary = "Type annotations and code completion for botocore" groups = ["dev-types"] dependencies = [ "types-awscrt", ] files = [ - {file = "botocore_stubs-1.38.30-py3-none-any.whl", hash = "sha256:2efb8bdf36504aff596c670d875d8f7dd15205277c15c4cea54afdba8200c266"}, - {file = "botocore_stubs-1.38.30.tar.gz", hash = "sha256:291d7bf39a316c00a8a55b7255489b02c0cea1a343482e7784e8d1e235bae995"}, + {file = "botocore_stubs-1.38.46-py3-none-any.whl", hash = "sha256:cc21d9a7dd994bdd90872db4664d817c4719b51cda8004fd507a4bf65b085a75"}, + {file = "botocore_stubs-1.38.46.tar.gz", hash = "sha256:a04e69766ab8bae338911c1897492f88d05cd489cd75f06e6eb4f135f9da8c7b"}, ] [[package]] name = "cachetools" -version = "6.1.0" -requires_python = ">=3.9" +version = "5.5.2" +requires_python = ">=3.7" summary = "Extensible memoizing collections and decorators" -groups = ["dev-consumers"] +groups = ["dev-consumers", "pubsub"] files = [ - {file = "cachetools-6.1.0-py3-none-any.whl", hash = "sha256:1c7bb3cf9193deaf3508b7c5f2a79986c13ea38965c5adcff1f84519cf39163e"}, - {file = "cachetools-6.1.0.tar.gz", hash = "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587"}, + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, ] [[package]] name = "certifi" -version = "2025.6.15" +version = "2025.7.9" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["dev-consumers", "dev-otel", "integration-tests"] +groups = ["azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "integration-tests", "pubsub"] files = [ - {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, - {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, + {file = "certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39"}, + {file = "certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079"}, ] [[package]] @@ -413,7 +523,7 @@ name = "cffi" version = "1.17.1" requires_python = ">=3.8" summary = "Foreign Function Interface for Python calling C code." -groups = ["dev-consumers", "tests"] +groups = ["azure-queue", "azure-servicebus", "dev-consumers", "tests"] marker = "os_name == \"nt\" and implementation_name != \"pypy\" or platform_python_implementation != \"PyPy\"" dependencies = [ "pycparser", @@ -473,7 +583,7 @@ name = "charset-normalizer" version = "3.4.2" requires_python = ">=3.7" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["dev-otel", "integration-tests"] +groups = ["azure-queue", "azure-servicebus", "dev-otel", "integration-tests", "pubsub"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, @@ -536,7 +646,7 @@ name = "click" version = "8.2.1" requires_python = ">=3.10" summary = "Composable command line interface toolkit" -groups = ["http"] +groups = ["dev-hosting-http"] dependencies = [ "colorama; platform_system == \"Windows\"", ] @@ -550,7 +660,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["dev", "http", "tests", "unit-tests"] +groups = ["dev", "dev-hosting-http", "tests", "unit-tests"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -558,38 +668,38 @@ files = [ [[package]] name = "confluent-kafka" -version = "2.10.1" +version = "2.11.0" requires_python = ">=3.7" summary = "Confluent's Python client for Apache Kafka" groups = ["dev-consumers", "kafka"] files = [ - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad6a9ba5aba014b3a45d560edccd8864be5cc4a9402ba8520201b18c18b7c899"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:682b67ec638142fe090e71f76d698484d3199e70b67474e16938c196e635fa4b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2b7e85876c8b714e20a54cdc313509767885d00b9ced3cbff2285d881853151b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f3aec168ec1817d89102e0d517f8523fc7efbf0058859dcf5aa42cd9d99a12fa"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:95d72e1b9576bc4cd269dbba0dda65047d316ff2fab84c88aa3417f2a3aae0b6"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:147c83ea89b0e93ecc7dac6d42eebc4fc541cfb1f0f7d8ab05fdda76b7d7adcc"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33c2ca92920d0c7e5079f9814523ed709b16d369ab0cca1941abc72b703a8c59"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0addf6fe866ade4d13f94057a6908dff7ddec7d68002a3f6b596bc72d975ed8b"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f17b45fcdecf0d58c584d5ae95257078759796aaed73ebbe1d32431228c50467"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:fdd032a8c7637263defcf577b36d1d4dba6495877e49662e4af4cc15e6950c77"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d5303e42f94fc044e27e0224476173fe454d039f7cad5011bae5775086161c23"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a277eec260fd048cfcc32a95da9e14e0701d7c04b276dcc79de7f09fdfa74235"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c4282213d9066ba37d5508d01c1e1e63963f4ea2273d918ebcf8990311347ad"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:08aef1602a5dd2fc448dac5009b345d93304d8ab53640c8a63f06714f4420294"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:352152e0562a5feb2fa4ec4f91b801fed56695f5508946ff084aa23b4495b201"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:325b774ebf37d5d5e7fdcda58bea2925c5bf536da365db0e80e4e3e1d1e4b748"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:d2190269874554e5b2dd8b8d08968d3f205107fbe58c7199bde7c89aabeabff2"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3a10d099efbdddd58d71172db50de784991c4f2d47a55c09e5023e943aef88bf"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:deb4d95114ada2020abc24b48c3861dd96f164955652e7bd9f9b8e84b257c03a"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:b8040e89e89d462331fc5c46d6f6b79d22f1b9482afc8005776029965b86b41c"}, - {file = "confluent_kafka-2.10.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a7724d6fb4526240980fcae7900c62ffb93ce5c5926a4e19133ec79e26558b61"}, - {file = "confluent_kafka-2.10.1.tar.gz", hash = "sha256:536c76f6c39a93c367a70016781cf7e73808300228b8e33b444c846fdef801e4"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae672723577775aba560da34e376ffa4a038ff3e07c8513920b81903f8f9c4e8"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc8346a55c4b3f4e4ed938243997e3223e0f00c93c48d52a86343943c0316a6c"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5dccc94019fbca33bcc49d993641b7145c448255206480a38eee452869653b33"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a13398b5b6e025e710b3123c17db8dbc4db78c708916bb602211257fcd7f27c8"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:08e0b0b7fb21ddd83801231f5f32b7e7ba4f7c9a7dee9034a1e36e6b6479143a"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f54f077904cc8541be03e16bee9bf37207c96e6bbd3c5b3d1b5fbd878e40580"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:455a368d35dc4444d4d8f442b5715e3b43310b60bb0f99837556830bd32defdf"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:707d7a39755986605402dd308013e6a142b8a5105d51f863b17f4bc283f4ac85"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:655b5d3a90955dd4ee15e59d89f33b5eaae14e7122e9fecb93b4c03ef426ec97"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:6dc952c45108351b3995b85e23d99c2251a5160e3ed3d9a7768300d8c97eca56"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7ab9b40866d783cd410019b0380b0db815ac36ecf07be2be80a429e41d7caf98"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:70682b25b5ce835de54b6eb803a75e550a953e494fe348960ed8c534cb54ea50"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:80edbdf59d312772a4c5646807cb2a0b5f5a2c7f945df070aac1782a7790f432"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1ac35a551c8df8313695bff3e81dd8ef3d9041b0edc8827883ef8015819bc59c"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:be6273ae93a9076c28ccda39dacae98405af696c97bab02a8a78abd02fe3add4"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:e58b263d8e02c54396e7159d2d6668c9e6f2e7faa06c40026df205ff81b6188e"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a29ac34c7dd3ab51c2e7cc16c37baed13369a487d7f953ff4e3a8fff08642969"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2552147d0929db95ace291e460c652981721e0e437a074445fbafdcee8b6817c"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ed0981e686bdc4ee86034113a750242fe1b405826bf4a553498bf558429188af"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6d6ec6e791b1c97668ff0823dff1dc005ab418b353a430fede7b5cd6c5490497"}, + {file = "confluent_kafka-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:9f44a476241a81e6b3259d3f3f00aa0e94ed64a924131f83cb3b640ecdfcd633"}, + {file = "confluent_kafka-2.11.0.tar.gz", hash = "sha256:d95512838eebd42a4657ce891dfa32bcbc06906e3391b328de24f7731b0c2755"}, ] [[package]] name = "confluent-kafka" -version = "2.10.1" +version = "2.11.0" extras = ["protobuf", "schemaregistry"] requires_python = ">=3.7" summary = "Confluent's Python client for Apache Kafka" @@ -601,172 +711,172 @@ dependencies = [ "authlib>=1.0.0", "cachetools", "cachetools", - "confluent-kafka==2.10.1", + "confluent-kafka==2.11.0", "googleapis-common-protos", "httpx>=0.26", "httpx>=0.26", "protobuf", ] files = [ - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ad6a9ba5aba014b3a45d560edccd8864be5cc4a9402ba8520201b18c18b7c899"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:682b67ec638142fe090e71f76d698484d3199e70b67474e16938c196e635fa4b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2b7e85876c8b714e20a54cdc313509767885d00b9ced3cbff2285d881853151b"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:f3aec168ec1817d89102e0d517f8523fc7efbf0058859dcf5aa42cd9d99a12fa"}, - {file = "confluent_kafka-2.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:95d72e1b9576bc4cd269dbba0dda65047d316ff2fab84c88aa3417f2a3aae0b6"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:147c83ea89b0e93ecc7dac6d42eebc4fc541cfb1f0f7d8ab05fdda76b7d7adcc"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:33c2ca92920d0c7e5079f9814523ed709b16d369ab0cca1941abc72b703a8c59"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0addf6fe866ade4d13f94057a6908dff7ddec7d68002a3f6b596bc72d975ed8b"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f17b45fcdecf0d58c584d5ae95257078759796aaed73ebbe1d32431228c50467"}, - {file = "confluent_kafka-2.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:fdd032a8c7637263defcf577b36d1d4dba6495877e49662e4af4cc15e6950c77"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d5303e42f94fc044e27e0224476173fe454d039f7cad5011bae5775086161c23"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a277eec260fd048cfcc32a95da9e14e0701d7c04b276dcc79de7f09fdfa74235"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c4282213d9066ba37d5508d01c1e1e63963f4ea2273d918ebcf8990311347ad"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:08aef1602a5dd2fc448dac5009b345d93304d8ab53640c8a63f06714f4420294"}, - {file = "confluent_kafka-2.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:352152e0562a5feb2fa4ec4f91b801fed56695f5508946ff084aa23b4495b201"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:325b774ebf37d5d5e7fdcda58bea2925c5bf536da365db0e80e4e3e1d1e4b748"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:d2190269874554e5b2dd8b8d08968d3f205107fbe58c7199bde7c89aabeabff2"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3a10d099efbdddd58d71172db50de784991c4f2d47a55c09e5023e943aef88bf"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:deb4d95114ada2020abc24b48c3861dd96f164955652e7bd9f9b8e84b257c03a"}, - {file = "confluent_kafka-2.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:b8040e89e89d462331fc5c46d6f6b79d22f1b9482afc8005776029965b86b41c"}, - {file = "confluent_kafka-2.10.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a7724d6fb4526240980fcae7900c62ffb93ce5c5926a4e19133ec79e26558b61"}, - {file = "confluent_kafka-2.10.1.tar.gz", hash = "sha256:536c76f6c39a93c367a70016781cf7e73808300228b8e33b444c846fdef801e4"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae672723577775aba560da34e376ffa4a038ff3e07c8513920b81903f8f9c4e8"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc8346a55c4b3f4e4ed938243997e3223e0f00c93c48d52a86343943c0316a6c"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5dccc94019fbca33bcc49d993641b7145c448255206480a38eee452869653b33"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a13398b5b6e025e710b3123c17db8dbc4db78c708916bb602211257fcd7f27c8"}, + {file = "confluent_kafka-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:08e0b0b7fb21ddd83801231f5f32b7e7ba4f7c9a7dee9034a1e36e6b6479143a"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f54f077904cc8541be03e16bee9bf37207c96e6bbd3c5b3d1b5fbd878e40580"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:455a368d35dc4444d4d8f442b5715e3b43310b60bb0f99837556830bd32defdf"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:707d7a39755986605402dd308013e6a142b8a5105d51f863b17f4bc283f4ac85"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:655b5d3a90955dd4ee15e59d89f33b5eaae14e7122e9fecb93b4c03ef426ec97"}, + {file = "confluent_kafka-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:6dc952c45108351b3995b85e23d99c2251a5160e3ed3d9a7768300d8c97eca56"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7ab9b40866d783cd410019b0380b0db815ac36ecf07be2be80a429e41d7caf98"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:70682b25b5ce835de54b6eb803a75e550a953e494fe348960ed8c534cb54ea50"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:80edbdf59d312772a4c5646807cb2a0b5f5a2c7f945df070aac1782a7790f432"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1ac35a551c8df8313695bff3e81dd8ef3d9041b0edc8827883ef8015819bc59c"}, + {file = "confluent_kafka-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:be6273ae93a9076c28ccda39dacae98405af696c97bab02a8a78abd02fe3add4"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:e58b263d8e02c54396e7159d2d6668c9e6f2e7faa06c40026df205ff81b6188e"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a29ac34c7dd3ab51c2e7cc16c37baed13369a487d7f953ff4e3a8fff08642969"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2552147d0929db95ace291e460c652981721e0e437a074445fbafdcee8b6817c"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ed0981e686bdc4ee86034113a750242fe1b405826bf4a553498bf558429188af"}, + {file = "confluent_kafka-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6d6ec6e791b1c97668ff0823dff1dc005ab418b353a430fede7b5cd6c5490497"}, + {file = "confluent_kafka-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:9f44a476241a81e6b3259d3f3f00aa0e94ed64a924131f83cb3b640ecdfcd633"}, + {file = "confluent_kafka-2.11.0.tar.gz", hash = "sha256:d95512838eebd42a4657ce891dfa32bcbc06906e3391b328de24f7731b0c2755"}, ] [[package]] name = "coverage" -version = "7.9.1" +version = "7.9.2" requires_python = ">=3.9" summary = "Code coverage measurement for Python" groups = ["tests", "unit-tests"] files = [ - {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, - {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, - {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, - {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, - {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, - {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, - {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, - {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, - {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, - {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, - {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, - {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, - {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, - {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, - {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, - {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, - {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, - {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, - {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, + {file = "coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912"}, + {file = "coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f"}, + {file = "coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f"}, + {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf"}, + {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547"}, + {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45"}, + {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2"}, + {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e"}, + {file = "coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e"}, + {file = "coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c"}, + {file = "coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba"}, + {file = "coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa"}, + {file = "coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a"}, + {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc"}, + {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2"}, + {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c"}, + {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd"}, + {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74"}, + {file = "coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6"}, + {file = "coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7"}, + {file = "coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62"}, + {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, + {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, + {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, + {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, + {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, + {file = "coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038"}, + {file = "coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d"}, + {file = "coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3"}, + {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14"}, + {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6"}, + {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b"}, + {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d"}, + {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868"}, + {file = "coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a"}, + {file = "coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b"}, + {file = "coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694"}, + {file = "coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5"}, + {file = "coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b"}, + {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3"}, + {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8"}, + {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46"}, + {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584"}, + {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e"}, + {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac"}, + {file = "coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926"}, + {file = "coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd"}, + {file = "coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb"}, + {file = "coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050"}, + {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, + {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, ] [[package]] name = "coverage" -version = "7.9.1" +version = "7.9.2" extras = ["toml"] requires_python = ">=3.9" summary = "Code coverage measurement for Python" groups = ["tests", "unit-tests"] dependencies = [ - "coverage==7.9.1", + "coverage==7.9.2", "tomli; python_full_version <= \"3.11.0a6\"", ] files = [ - {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, - {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, - {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, - {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, - {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, - {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, - {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, - {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, - {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, - {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, - {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, - {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, - {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, - {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, - {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, - {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, - {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, - {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, - {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, + {file = "coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912"}, + {file = "coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f"}, + {file = "coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f"}, + {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf"}, + {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547"}, + {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45"}, + {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2"}, + {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e"}, + {file = "coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e"}, + {file = "coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c"}, + {file = "coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba"}, + {file = "coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa"}, + {file = "coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a"}, + {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc"}, + {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2"}, + {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c"}, + {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd"}, + {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74"}, + {file = "coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6"}, + {file = "coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7"}, + {file = "coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62"}, + {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, + {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, + {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, + {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, + {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, + {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, + {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, + {file = "coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038"}, + {file = "coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d"}, + {file = "coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3"}, + {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14"}, + {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6"}, + {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b"}, + {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d"}, + {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868"}, + {file = "coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a"}, + {file = "coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b"}, + {file = "coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694"}, + {file = "coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5"}, + {file = "coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b"}, + {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3"}, + {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8"}, + {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46"}, + {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584"}, + {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e"}, + {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac"}, + {file = "coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926"}, + {file = "coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd"}, + {file = "coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb"}, + {file = "coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050"}, + {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, + {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, ] [[package]] @@ -786,51 +896,51 @@ files = [ [[package]] name = "cryptography" -version = "45.0.4" +version = "45.0.5" requires_python = "!=3.9.0,!=3.9.1,>=3.7" summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -groups = ["dev-consumers", "tests"] +groups = ["azure-queue", "azure-servicebus", "dev-consumers", "tests"] dependencies = [ "cffi>=1.14; platform_python_implementation != \"PyPy\"", ] files = [ - {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, - {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, - {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, - {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, - {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, - {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, - {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, - {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, - {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, - {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, - {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, + {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"}, + {file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"}, + {file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"}, + {file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"}, + {file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"}, + {file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"}, + {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, ] [[package]] @@ -854,7 +964,7 @@ name = "exceptiongroup" version = "1.3.0" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" -groups = ["default", "dev-consumers", "examples", "rabbitmq", "tests", "unit-tests"] +groups = ["default", "dev-consumers", "dev-hosting-http", "examples", "rabbitmq", "tests", "unit-tests"] dependencies = [ "typing-extensions>=4.6.0; python_version < \"3.13\"", ] @@ -903,18 +1013,18 @@ files = [ [[package]] name = "fastapi-slim" -version = "0.115.13" +version = "0.116.1" requires_python = ">=3.8" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" groups = ["examples"] dependencies = [ "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", - "starlette<0.47.0,>=0.40.0", + "starlette<0.48.0,>=0.40.0", "typing-extensions>=4.8.0", ] files = [ - {file = "fastapi_slim-0.115.13-py3-none-any.whl", hash = "sha256:f54d4aacd1928db5a171dc499be84f4f70fa55e297561718d7d162bfb85b4b27"}, - {file = "fastapi_slim-0.115.13.tar.gz", hash = "sha256:b2b18fd04bd52b304ae8c0f58e2d3a65775612c9755c3d3ec7732b2e0eb7c262"}, + {file = "fastapi_slim-0.116.1-py3-none-any.whl", hash = "sha256:37517a302492c30014979cff8d85f42ae5fbdbe4f8b5d0ceed81745bb3b23149"}, + {file = "fastapi_slim-0.116.1.tar.gz", hash = "sha256:5dca6046d3a3e35eb733188649a9f883c9d49474b9b3255ecb8ec52a820fbb9f"}, ] [[package]] @@ -932,7 +1042,7 @@ name = "frozenlist" version = "1.7.0" requires_python = ">=3.9" summary = "A list-like structure which implements collections.abc.MutableSequence" -groups = ["sqs"] +groups = ["dev-consumers"] files = [ {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, @@ -1023,12 +1133,92 @@ files = [ {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, ] +[[package]] +name = "google-api-core" +version = "2.25.1" +requires_python = ">=3.7" +summary = "Google API client core library" +groups = ["pubsub"] +dependencies = [ + "google-auth<3.0.0,>=2.14.1", + "googleapis-common-protos<2.0.0,>=1.56.2", + "proto-plus<2.0.0,>=1.22.3", + "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", + "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.19.5", + "requests<3.0.0,>=2.18.0", +] +files = [ + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, +] + +[[package]] +name = "google-api-core" +version = "2.25.1" +extras = ["grpc"] +requires_python = ">=3.7" +summary = "Google API client core library" +groups = ["pubsub"] +dependencies = [ + "google-api-core==2.25.1", + "grpcio-status<2.0.0,>=1.33.2", + "grpcio-status<2.0.0,>=1.49.1; python_version >= \"3.11\"", + "grpcio<2.0.0,>=1.33.2", + "grpcio<2.0.0,>=1.49.1; python_version >= \"3.11\"", +] +files = [ + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +requires_python = ">=3.7" +summary = "Google Authentication Library" +groups = ["pubsub"] +dependencies = [ + "cachetools<6.0,>=2.0.0", + "pyasn1-modules>=0.2.1", + "rsa<5,>=3.1.4", +] +files = [ + {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, + {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, +] + +[[package]] +name = "google-cloud-pubsub" +version = "2.31.0" +requires_python = ">=3.7" +summary = "Google Cloud Pub/Sub API client library" +groups = ["pubsub"] +dependencies = [ + "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0", + "google-auth<3.0.0,>=2.14.1", + "grpc-google-iam-v1<1.0.0,>=0.12.4", + "grpcio-status>=1.33.2", + "grpcio<2.0.0,>=1.51.3", + "opentelemetry-api<=1.22.0; python_version <= \"3.7\"", + "opentelemetry-api>=1.27.0; python_version >= \"3.8\"", + "opentelemetry-sdk<=1.22.0; python_version <= \"3.7\"", + "opentelemetry-sdk>=1.27.0; python_version >= \"3.8\"", + "proto-plus<2.0.0,>=1.22.0", + "proto-plus<2.0.0,>=1.22.2; python_version >= \"3.11\"", + "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", + "protobuf!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", +] +files = [ + {file = "google_cloud_pubsub-2.31.0-py3-none-any.whl", hash = "sha256:2dbae4ef079403615856868f8bae20550eb25c0f89396a185ce2c72b029fb060"}, + {file = "google_cloud_pubsub-2.31.0.tar.gz", hash = "sha256:d190cae8f18039d7d7301d3511d3a15cbe1df3d4fc89b871807b86f66349aaea"}, +] + [[package]] name = "googleapis-common-protos" version = "1.70.0" requires_python = ">=3.7" summary = "Common protobufs used in Google APIs" -groups = ["dev-consumers", "dev-otel"] +groups = ["dev-consumers", "dev-otel", "pubsub"] dependencies = [ "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", ] @@ -1037,109 +1227,157 @@ files = [ {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +extras = ["grpc"] +requires_python = ">=3.7" +summary = "Common protobufs used in Google APIs" +groups = ["pubsub"] +dependencies = [ + "googleapis-common-protos==1.70.0", + "grpcio<2.0.0,>=1.44.0", +] +files = [ + {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, + {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.2" +requires_python = ">=3.7" +summary = "IAM API client library" +groups = ["pubsub"] +dependencies = [ + "googleapis-common-protos[grpc]<2.0.0,>=1.56.0", + "grpcio<2.0.0,>=1.44.0", + "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", +] +files = [ + {file = "grpc_google_iam_v1-0.14.2-py3-none-any.whl", hash = "sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351"}, + {file = "grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20"}, +] + [[package]] name = "grpcio" -version = "1.73.0" +version = "1.73.1" requires_python = ">=3.9" summary = "HTTP/2-based RPC framework" -groups = ["dev-consumers", "dev-otel", "grpc"] -files = [ - {file = "grpcio-1.73.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:d050197eeed50f858ef6c51ab09514856f957dba7b1f7812698260fc9cc417f6"}, - {file = "grpcio-1.73.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ebb8d5f4b0200916fb292a964a4d41210de92aba9007e33d8551d85800ea16cb"}, - {file = "grpcio-1.73.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:c0811331b469e3f15dda5f90ab71bcd9681189a83944fd6dc908e2c9249041ef"}, - {file = "grpcio-1.73.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12787c791c3993d0ea1cc8bf90393647e9a586066b3b322949365d2772ba965b"}, - {file = "grpcio-1.73.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c17771e884fddf152f2a0df12478e8d02853e5b602a10a9a9f1f52fa02b1d32"}, - {file = "grpcio-1.73.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:275e23d4c428c26b51857bbd95fcb8e528783597207ec592571e4372b300a29f"}, - {file = "grpcio-1.73.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9ffc972b530bf73ef0f948f799482a1bf12d9b6f33406a8e6387c0ca2098a833"}, - {file = "grpcio-1.73.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d269df64aff092b2cec5e015d8ae09c7e90888b5c35c24fdca719a2c9f35"}, - {file = "grpcio-1.73.0-cp310-cp310-win32.whl", hash = "sha256:072d8154b8f74300ed362c01d54af8b93200c1a9077aeaea79828d48598514f1"}, - {file = "grpcio-1.73.0-cp310-cp310-win_amd64.whl", hash = "sha256:ce953d9d2100e1078a76a9dc2b7338d5415924dc59c69a15bf6e734db8a0f1ca"}, - {file = "grpcio-1.73.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:51036f641f171eebe5fa7aaca5abbd6150f0c338dab3a58f9111354240fe36ec"}, - {file = "grpcio-1.73.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d12bbb88381ea00bdd92c55aff3da3391fd85bc902c41275c8447b86f036ce0f"}, - {file = "grpcio-1.73.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:483c507c2328ed0e01bc1adb13d1eada05cc737ec301d8e5a8f4a90f387f1790"}, - {file = "grpcio-1.73.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c201a34aa960c962d0ce23fe5f423f97e9d4b518ad605eae6d0a82171809caaa"}, - {file = "grpcio-1.73.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:859f70c8e435e8e1fa060e04297c6818ffc81ca9ebd4940e180490958229a45a"}, - {file = "grpcio-1.73.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e2459a27c6886e7e687e4e407778425f3c6a971fa17a16420227bda39574d64b"}, - {file = "grpcio-1.73.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e0084d4559ee3dbdcce9395e1bc90fdd0262529b32c417a39ecbc18da8074ac7"}, - {file = "grpcio-1.73.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef5fff73d5f724755693a464d444ee0a448c6cdfd3c1616a9223f736c622617d"}, - {file = "grpcio-1.73.0-cp311-cp311-win32.whl", hash = "sha256:965a16b71a8eeef91fc4df1dc40dc39c344887249174053814f8a8e18449c4c3"}, - {file = "grpcio-1.73.0-cp311-cp311-win_amd64.whl", hash = "sha256:b71a7b4483d1f753bbc11089ff0f6fa63b49c97a9cc20552cded3fcad466d23b"}, - {file = "grpcio-1.73.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fb9d7c27089d9ba3746f18d2109eb530ef2a37452d2ff50f5a6696cd39167d3b"}, - {file = "grpcio-1.73.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:128ba2ebdac41e41554d492b82c34586a90ebd0766f8ebd72160c0e3a57b9155"}, - {file = "grpcio-1.73.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:068ecc415f79408d57a7f146f54cdf9f0acb4b301a52a9e563973dc981e82f3d"}, - {file = "grpcio-1.73.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ddc1cfb2240f84d35d559ade18f69dcd4257dbaa5ba0de1a565d903aaab2968"}, - {file = "grpcio-1.73.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53007f70d9783f53b41b4cf38ed39a8e348011437e4c287eee7dd1d39d54b2f"}, - {file = "grpcio-1.73.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4dd8d8d092efede7d6f48d695ba2592046acd04ccf421436dd7ed52677a9ad29"}, - {file = "grpcio-1.73.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:70176093d0a95b44d24baa9c034bb67bfe2b6b5f7ebc2836f4093c97010e17fd"}, - {file = "grpcio-1.73.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:085ebe876373ca095e24ced95c8f440495ed0b574c491f7f4f714ff794bbcd10"}, - {file = "grpcio-1.73.0-cp312-cp312-win32.whl", hash = "sha256:cfc556c1d6aef02c727ec7d0016827a73bfe67193e47c546f7cadd3ee6bf1a60"}, - {file = "grpcio-1.73.0-cp312-cp312-win_amd64.whl", hash = "sha256:bbf45d59d090bf69f1e4e1594832aaf40aa84b31659af3c5e2c3f6a35202791a"}, - {file = "grpcio-1.73.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:da1d677018ef423202aca6d73a8d3b2cb245699eb7f50eb5f74cae15a8e1f724"}, - {file = "grpcio-1.73.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:36bf93f6a657f37c131d9dd2c391b867abf1426a86727c3575393e9e11dadb0d"}, - {file = "grpcio-1.73.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:d84000367508ade791d90c2bafbd905574b5ced8056397027a77a215d601ba15"}, - {file = "grpcio-1.73.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c98ba1d928a178ce33f3425ff823318040a2b7ef875d30a0073565e5ceb058d9"}, - {file = "grpcio-1.73.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a73c72922dfd30b396a5f25bb3a4590195ee45ecde7ee068acb0892d2900cf07"}, - {file = "grpcio-1.73.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:10e8edc035724aba0346a432060fd192b42bd03675d083c01553cab071a28da5"}, - {file = "grpcio-1.73.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f5cdc332b503c33b1643b12ea933582c7b081957c8bc2ea4cc4bc58054a09288"}, - {file = "grpcio-1.73.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:07ad7c57233c2109e4ac999cb9c2710c3b8e3f491a73b058b0ce431f31ed8145"}, - {file = "grpcio-1.73.0-cp313-cp313-win32.whl", hash = "sha256:0eb5df4f41ea10bda99a802b2a292d85be28958ede2a50f2beb8c7fc9a738419"}, - {file = "grpcio-1.73.0-cp313-cp313-win_amd64.whl", hash = "sha256:38cf518cc54cd0c47c9539cefa8888549fcc067db0b0c66a46535ca8032020c4"}, - {file = "grpcio-1.73.0.tar.gz", hash = "sha256:3af4c30918a7f0d39de500d11255f8d9da4f30e94a2033e70fe2a720e184bd8e"}, +groups = ["dev-consumers", "dev-hosting-grpc", "dev-otel", "pubsub"] +files = [ + {file = "grpcio-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:2d70f4ddd0a823436c2624640570ed6097e40935c9194482475fe8e3d9754d55"}, + {file = "grpcio-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3841a8a5a66830261ab6a3c2a3dc539ed84e4ab019165f77b3eeb9f0ba621f26"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:628c30f8e77e0258ab788750ec92059fc3d6628590fb4b7cea8c102503623ed7"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67a0468256c9db6d5ecb1fde4bf409d016f42cef649323f0a08a72f352d1358b"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b84d65bbdebd5926eb5c53b0b9ec3b3f83408a30e4c20c373c5337b4219ec5"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c54796ca22b8349cc594d18b01099e39f2b7ffb586ad83217655781a350ce4da"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:75fc8e543962ece2f7ecd32ada2d44c0c8570ae73ec92869f9af8b944863116d"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6a6037891cd2b1dd1406b388660522e1565ed340b1fea2955b0234bdd941a862"}, + {file = "grpcio-1.73.1-cp310-cp310-win32.whl", hash = "sha256:cce7265b9617168c2d08ae570fcc2af4eaf72e84f8c710ca657cc546115263af"}, + {file = "grpcio-1.73.1-cp310-cp310-win_amd64.whl", hash = "sha256:6a2b372e65fad38842050943f42ce8fee00c6f2e8ea4f7754ba7478d26a356ee"}, + {file = "grpcio-1.73.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:ba2cea9f7ae4bc21f42015f0ec98f69ae4179848ad744b210e7685112fa507a1"}, + {file = "grpcio-1.73.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d74c3f4f37b79e746271aa6cdb3a1d7e4432aea38735542b23adcabaaee0c097"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5b9b1805a7d61c9e90541cbe8dfe0a593dfc8c5c3a43fe623701b6a01b01d710"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3215f69a0670a8cfa2ab53236d9e8026bfb7ead5d4baabe7d7dc11d30fda967"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc5eccfd9577a5dc7d5612b2ba90cca4ad14c6d949216c68585fdec9848befb1"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc7d7fd520614fce2e6455ba89791458020a39716951c7c07694f9dbae28e9c0"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:105492124828911f85127e4825d1c1234b032cb9d238567876b5515d01151379"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:610e19b04f452ba6f402ac9aa94eb3d21fbc94553368008af634812c4a85a99e"}, + {file = "grpcio-1.73.1-cp311-cp311-win32.whl", hash = "sha256:d60588ab6ba0ac753761ee0e5b30a29398306401bfbceffe7d68ebb21193f9d4"}, + {file = "grpcio-1.73.1-cp311-cp311-win_amd64.whl", hash = "sha256:6957025a4608bb0a5ff42abd75bfbb2ed99eda29d5992ef31d691ab54b753643"}, + {file = "grpcio-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf"}, + {file = "grpcio-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:277b426a0ed341e8447fbf6c1d6b68c952adddf585ea4685aa563de0f03df887"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:96c112333309493c10e118d92f04594f9055774757f5d101b39f8150f8c25582"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f48e862aed925ae987eb7084409a80985de75243389dc9d9c271dd711e589918"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83a6c2cce218e28f5040429835fa34a29319071079e3169f9543c3fbeff166d2"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:65b0458a10b100d815a8426b1442bd17001fdb77ea13665b2f7dc9e8587fdc6b"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0a9f3ea8dce9eae9d7cb36827200133a72b37a63896e0e61a9d5ec7d61a59ab1"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de18769aea47f18e782bf6819a37c1c528914bfd5683b8782b9da356506190c8"}, + {file = "grpcio-1.73.1-cp312-cp312-win32.whl", hash = "sha256:24e06a5319e33041e322d32c62b1e728f18ab8c9dbc91729a3d9f9e3ed336642"}, + {file = "grpcio-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:303c8135d8ab176f8038c14cc10d698ae1db9c480f2b2823f7a987aa2a4c5646"}, + {file = "grpcio-1.73.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b310824ab5092cf74750ebd8a8a8981c1810cb2b363210e70d06ef37ad80d4f9"}, + {file = "grpcio-1.73.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:8f5a6df3fba31a3485096ac85b2e34b9666ffb0590df0cd044f58694e6a1f6b5"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:052e28fe9c41357da42250a91926a3e2f74c046575c070b69659467ca5aa976b"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c0bf15f629b1497436596b1cbddddfa3234273490229ca29561209778ebe182"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab860d5bfa788c5a021fba264802e2593688cd965d1374d31d2b1a34cacd854"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ad1d958c31cc91ab050bd8a91355480b8e0683e21176522bacea225ce51163f2"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f43ffb3bd415c57224c7427bfb9e6c46a0b6e998754bfa0d00f408e1873dcbb5"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:686231cdd03a8a8055f798b2b54b19428cdf18fa1549bee92249b43607c42668"}, + {file = "grpcio-1.73.1-cp313-cp313-win32.whl", hash = "sha256:89018866a096e2ce21e05eabed1567479713ebe57b1db7cbb0f1e3b896793ba4"}, + {file = "grpcio-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:4a68f8c9966b94dff693670a5cf2b54888a48a5011c5d9ce2295a1a1465ee84f"}, + {file = "grpcio-1.73.1.tar.gz", hash = "sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87"}, +] + +[[package]] +name = "grpcio-status" +version = "1.73.1" +requires_python = ">=3.9" +summary = "Status proto mapping for gRPC" +groups = ["pubsub"] +dependencies = [ + "googleapis-common-protos>=1.5.5", + "grpcio>=1.73.1", + "protobuf<7.0.0,>=6.30.0", +] +files = [ + {file = "grpcio_status-1.73.1-py3-none-any.whl", hash = "sha256:538595c32a6c819c32b46a621a51e9ae4ffcd7e7e1bce35f728ef3447e9809b6"}, + {file = "grpcio_status-1.73.1.tar.gz", hash = "sha256:928f49ccf9688db5f20cd9e45c4578a1d01ccca29aeaabf066f2ac76aa886668"}, ] [[package]] name = "grpcio-tools" -version = "1.71.0" +version = "1.73.1" requires_python = ">=3.9" summary = "Protobuf code generator for gRPC" groups = ["dev-consumers"] dependencies = [ - "grpcio>=1.71.0", - "protobuf<6.0dev,>=5.26.1", + "grpcio>=1.73.1", + "protobuf<7.0.0,>=6.30.0", "setuptools", ] files = [ - {file = "grpcio_tools-1.71.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:f4ad7f0d756546902597053d70b3af2606fbd70d7972876cd75c1e241d22ae00"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:64bdb291df61cf570b5256777ad5fe2b1db6d67bc46e55dc56a0a862722ae329"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:8dd9795e982d77a4b496f7278b943c2563d9afde2069cdee78c111a40cc4d675"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1b5860c41a36b26fec4f52998f1a451d0525a5c9a4fb06b6ea3e9211abdb925"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3059c14035e5dc03d462f261e5900b9a077fd1a36976c3865b8507474520bad4"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f360981b215b1d5aff9235b37e7e1826246e35bbac32a53e41d4e990a37b8f4c"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bfe3888c3bbe16a5aa39409bc38744a31c0c3d2daa2b0095978c56e106c85b42"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:145985c0bf12131f0a1503e65763e0f060473f7f3928ed1ff3fb0e8aad5bc8ac"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-win32.whl", hash = "sha256:82c430edd939bb863550ee0fecf067d78feff828908a1b529bbe33cc57f2419c"}, - {file = "grpcio_tools-1.71.0-cp310-cp310-win_amd64.whl", hash = "sha256:83e90724e3f02415c628e4ead1d6ffe063820aaaa078d9a39176793df958cd5a"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:1f19b16b49afa5d21473f49c0966dd430c88d089cd52ac02404d8cef67134efb"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:459c8f5e00e390aecd5b89de67deb3ec7188a274bc6cb50e43cef35ab3a3f45d"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:edab7e6518de01196be37f96cb1e138c3819986bf5e2a6c9e1519b4d716b2f5a"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8b93b9f6adc7491d4c10144c0643409db298e5e63c997106a804f6f0248dbaf4"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ae5f2efa9e644c10bf1021600bfc099dfbd8e02b184d2d25dc31fcd6c2bc59e"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:65aa082f4435571d65d5ce07fc444f23c3eff4f3e34abef599ef8c9e1f6f360f"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1331e726e08b7bdcbf2075fcf4b47dff07842b04845e6e220a08a4663e232d7f"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6693a7d3ba138b0e693b3d1f687cdd9db9e68976c3fa2b951c17a072fea8b583"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-win32.whl", hash = "sha256:6d11ed3ff7b6023b5c72a8654975324bb98c1092426ba5b481af406ff559df00"}, - {file = "grpcio_tools-1.71.0-cp311-cp311-win_amd64.whl", hash = "sha256:072b2a5805ac97e4623b3aa8f7818275f3fb087f4aa131b0fce00471065f6eaa"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:61c0409d5bdac57a7bd0ce0ab01c1c916728fe4c8a03d77a25135ad481eb505c"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:28784f39921d061d2164a9dcda5164a69d07bf29f91f0ea50b505958292312c9"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:192808cf553cedca73f0479cc61d5684ad61f24db7a5f3c4dfe1500342425866"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:989ee9da61098230d3d4c8f8f8e27c2de796f1ff21b1c90110e636d9acd9432b"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:541a756276c8a55dec991f6c0106ae20c8c8f5ce8d0bdbfcb01e2338d1a8192b"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:870c0097700d13c403e5517cb7750ab5b4a791ce3e71791c411a38c5468b64bd"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:abd57f615e88bf93c3c6fd31f923106e3beb12f8cd2df95b0d256fa07a7a0a57"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:753270e2d06d37e6d7af8967d1d059ec635ad215882041a36294f4e2fd502b2e"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-win32.whl", hash = "sha256:0e647794bd7138b8c215e86277a9711a95cf6a03ff6f9e555d54fdf7378b9f9d"}, - {file = "grpcio_tools-1.71.0-cp312-cp312-win_amd64.whl", hash = "sha256:48debc879570972d28bfe98e4970eff25bb26da3f383e0e49829b2d2cd35ad87"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:9a78d07d6c301a25ef5ede962920a522556a1dfee1ccc05795994ceb867f766c"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:580ac88141c9815557e63c9c04f5b1cdb19b4db8d0cb792b573354bde1ee8b12"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f7c678e68ece0ae908ecae1c4314a0c2c7f83e26e281738b9609860cc2c82d96"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56ecd6cc89b5e5eed1de5eb9cafce86c9c9043ee3840888cc464d16200290b53"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52a041afc20ab2431d756b6295d727bd7adee813b21b06a3483f4a7a15ea15f"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a1712f12102b60c8d92779b89d0504e0d6f3a59f2b933e5622b8583f5c02992"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:41878cb7a75477e62fdd45e7e9155b3af1b7a5332844021e2511deaf99ac9e6c"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:682e958b476049ccc14c71bedf3f979bced01f6e0c04852efc5887841a32ad6b"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-win32.whl", hash = "sha256:0ccfb837152b7b858b9f26bb110b3ae8c46675d56130f6c2f03605c4f129be13"}, - {file = "grpcio_tools-1.71.0-cp313-cp313-win_amd64.whl", hash = "sha256:ffff9bc5eacb34dd26b487194f7d44a3e64e752fc2cf049d798021bf25053b87"}, - {file = "grpcio_tools-1.71.0.tar.gz", hash = "sha256:38dba8e0d5e0fb23a034e09644fdc6ed862be2371887eee54901999e8f6792a8"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:0731b21a7f3988a9f8c244ffe3940a0579e5b5f2a99d08448459e0b49350d47a"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:15e47b19b70ce6100e9843570e16b0561045c37b5e9d390f1cb54292c99b51b6"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:fbe6cd3e863928b5c127d4956c60e44101f495ddcb69738290db6ef497ce505c"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71c35a6b4d125bec877daefaf7dedb566d37ed4e903a45b74e491683e006afa4"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9486235da85a80accaeaab5829c09e19b70ee96ff100d3f7b342ec9344d96134"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:86dfd562a9ebb74849aca345237cf87c2732067e410752ff809b8bfdf7aa5f49"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bde8101bc7a60de6297916a468bf7900be6e2c0f9965a1a6591aa06bd02e2df3"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b41e2417003b34bf672aa4ec5a48e22d9fc28f7de5f25d9a01fb1e3dcc86af6a"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-win32.whl", hash = "sha256:bb05d02ca7d603260555cc0bbec616b116f741561d5b5c78a65bc3fae5982d5e"}, + {file = "grpcio_tools-1.73.1-cp310-cp310-win_amd64.whl", hash = "sha256:5f0cd287545c9430e3e395181ee11ca9b7bef4c41b1c28afa9174ea5a868dcda"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:2bc4a46177df43853b070a4b6b6106d9829a639bc8b9516a005a879d3e5da0f9"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:448b38ec72ed62c932d48e49e0facbb51e8045065dabf3bb63149810971a51e7"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:249be7616b323b8af72a02bd218bfbba388010e6ccb471c57d42e49b620686f7"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:129dd1e46386da74d78d588c5a7f5c51d8dc2ec40fea95f9a012f655767fd5f3"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d53cd62c30fbd9597c05066a45e5750a11ec566c5fe0d17878e169bd2c66157"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d8f4e2ee735dc6033b6f3379584759cc95259581c9bc58db5dd0e69cc71310f1"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b2795d85da34846bbe6693f0da4c1f9bfa8a77e6cbd85cb83eb1231f1315a2ae"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:40834bf3551a6936151f4ba7eba4a37c8ff66eacb0bb5affa4630afbe78f061d"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-win32.whl", hash = "sha256:0b809d936463fabeda6999eb681b5d4869dd8e5fe4d2fb9dbfee0d2a4c0ce67a"}, + {file = "grpcio_tools-1.73.1-cp311-cp311-win_amd64.whl", hash = "sha256:e0257401088f29315fe0ad7f388ad061cf5b57a89e9abf039f8d9c7a54e9580d"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:23af3de80c89ee803d0143c6293f8e979a851f0414702b5de973e205fca69db2"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fbf17ad6fff6c9002d28bc3632216eafb59631308b05c4cf80e72d21c33f7dc9"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:aa05ecd0b2ca583862d107eea4d9480a2d89987ae46bc02944cc450a122f833b"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664fbec02f93d14bb74c442e06ba20a4448a20236ed3d6ac5d2c4ef82a33a8b4"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60aa528d7576c932f43172723188f53aaa7bdb307e52d22f2e5ab831a3667693"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3893517010bcdb8cb4949715786bc5953fe7df6b575f8b1725531ed492b1080c"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:654083a2d4e83679d4fb6ac46a2748c1b57ecf45be5cbe88d88a1a4aef6b3281"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cd8b1077849acd69923b08a8e5221050e5201eb65907487ee6d69aa06b583b46"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-win32.whl", hash = "sha256:0273f64c8db4a52a3a99fd34c83a1a1723bd3ac6806d3054a93f08044609fa65"}, + {file = "grpcio_tools-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:0626266b0df489d6e8bdd4178b1c78cac9963fde4c5ba6b205b329e46696b334"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:4687200e18d316ef1e28824bf9c62c2c6a3a2aacf4e0d292306258ef05955587"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b86daf2dba207aace486cf5752c9c5d35864cd67f1df1429f7341af3984dadc7"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:44ec622d210740ed6c4300ea51fc43faaa0c58f4e2c4b03e2fe5a452b8e440ce"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ceac9073030012596cb7923643552329e48fb911eea3225624b8ef34bae92e1"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43329b47e982798c9eb18f652fc10fef5d22f9df51001a10f1ece01d9d203c04"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f0afb000ff67f7665f3eedd797c23b403a6bdae829d6e610944aeb6959193698"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:40c5def66b69c8c8f8a6af8d47a5e6b5825055c7ef7cf2b1b58c57ddc86bf3be"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9a0eb7d88c9f1992afb3021e45721971e49d612b03fa5d38cce6e4369509f32c"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-win32.whl", hash = "sha256:34480c6964d16b7fa0012b9fc574efa9e2f71c80f8bc9da0887c300abb7130f3"}, + {file = "grpcio_tools-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:2b005373ad23dc0f25d8d6ec6d219fc3a6831b8d0f487be8f9bb2315307ba1c1"}, + {file = "grpcio_tools-1.73.1.tar.gz", hash = "sha256:6e06adec3b0870f5947953b0ef8dbdf2cebcdff61fb1fe08120cc7483c7978aa"}, ] [[package]] @@ -1147,12 +1385,38 @@ name = "h11" version = "0.16.0" requires_python = ">=3.8" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["dev-consumers", "http"] +groups = ["dev-consumers", "dev-hosting-http"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "h2" +version = "4.2.0" +requires_python = ">=3.9" +summary = "Pure-Python HTTP/2 protocol implementation" +groups = ["dev-hosting-http"] +dependencies = [ + "hpack<5,>=4.1", + "hyperframe<7,>=6.1", +] +files = [ + {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, + {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, +] + +[[package]] +name = "hpack" +version = "4.1.0" +requires_python = ">=3.9" +summary = "Pure-Python HPACK header encoding" +groups = ["dev-hosting-http"] +files = [ + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1196,9 +1460,41 @@ files = [ {file = "humanize-4.12.3.tar.gz", hash = "sha256:8430be3a615106fdfceb0b2c1b41c4c98c6b0fc5cc59663a5539b111dd325fb0"}, ] +[[package]] +name = "hypercorn" +version = "0.17.3" +requires_python = ">=3.8" +summary = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" +groups = ["dev-hosting-http"] +dependencies = [ + "exceptiongroup>=1.1.0; python_version < \"3.11\"", + "h11", + "h2>=3.1.0", + "priority", + "taskgroup; python_version < \"3.11\"", + "tomli; python_version < \"3.11\"", + "typing-extensions; python_version < \"3.11\"", + "wsproto>=0.14.0", +] +files = [ + {file = "hypercorn-0.17.3-py3-none-any.whl", hash = "sha256:059215dec34537f9d40a69258d323f56344805efb462959e727152b0aa504547"}, + {file = "hypercorn-0.17.3.tar.gz", hash = "sha256:1b37802ee3ac52d2d85270700d565787ab16cf19e1462ccfa9f089ca17574165"}, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +requires_python = ">=3.9" +summary = "Pure-Python HTTP/2 framing" +groups = ["dev-hosting-http"] +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + [[package]] name = "hypothesis" -version = "6.135.14" +version = "6.135.29" requires_python = ">=3.9" summary = "A library for property-based testing" groups = ["tests"] @@ -1208,13 +1504,13 @@ dependencies = [ "sortedcontainers<3.0.0,>=2.1.0", ] files = [ - {file = "hypothesis-6.135.14-py3-none-any.whl", hash = "sha256:0dd5b8095e36bd288367c631f864a16c30500b01b17943dcea681233f7421860"}, - {file = "hypothesis-6.135.14.tar.gz", hash = "sha256:2666df50b3cc40ea08b161a5389d6a1cd5aa3cab0dd8fde0ae339389714a4f67"}, + {file = "hypothesis-6.135.29-py3-none-any.whl", hash = "sha256:db4e08bcc19235d2cf37801d6ccdaca757f86dc34a64e09c85fced15bb7e836a"}, + {file = "hypothesis-6.135.29.tar.gz", hash = "sha256:871acb38ff61346a420267f81f4ba05ad9a85d08965211edf9b29bc0c1ad9d7b"}, ] [[package]] name = "icecream" -version = "2.1.4" +version = "2.1.5" summary = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call." groups = ["dev"] dependencies = [ @@ -1224,8 +1520,8 @@ dependencies = [ "pygments>=2.2.0", ] files = [ - {file = "icecream-2.1.4-py3-none-any.whl", hash = "sha256:7bb715f69102cae871b3a361c3b656536db02cfcadac9664c673581cac4df4fd"}, - {file = "icecream-2.1.4.tar.gz", hash = "sha256:58755e58397d5350a76f25976dee7b607f5febb3c6e1cddfe6b1951896e91573"}, + {file = "icecream-2.1.5-py3-none-any.whl", hash = "sha256:c020917c5cb180a528dbba170250ccd8b74ebc75f2a7a8e839819bf959b9f80c"}, + {file = "icecream-2.1.5.tar.gz", hash = "sha256:14d21e3383326a69a8c1a3bcf11f83283459f0d269ece5af83fce2c0d663efec"}, ] [[package]] @@ -1233,7 +1529,7 @@ name = "idna" version = "3.10" requires_python = ">=3.6" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default", "dev-consumers", "dev-otel", "examples", "integration-tests", "rabbitmq", "sqs", "tests"] +groups = ["default", "azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "examples", "integration-tests", "pubsub", "rabbitmq", "tests"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1244,7 +1540,7 @@ name = "importlib-metadata" version = "8.7.0" requires_python = ">=3.9" summary = "Read metadata from Python packages" -groups = ["dev-otel"] +groups = ["dev-otel", "pubsub"] dependencies = [ "typing-extensions>=3.6.4; python_version < \"3.8\"", "zipp>=3.20", @@ -1265,6 +1561,17 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "isodate" +version = "0.7.2" +requires_python = ">=3.7" +summary = "An ISO 8601 date/time/duration parser and formatter" +groups = ["azure-queue", "azure-servicebus"] +files = [ + {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, + {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -1276,108 +1583,138 @@ files = [ {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, ] +[[package]] +name = "msal" +version = "1.32.3" +requires_python = ">=3.7" +summary = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." +groups = ["azure-queue", "azure-servicebus"] +dependencies = [ + "PyJWT[crypto]<3,>=1.0.0", + "cryptography<47,>=2.5", + "requests<3,>=2.0.0", +] +files = [ + {file = "msal-1.32.3-py3-none-any.whl", hash = "sha256:b2798db57760b1961b142f027ffb7c8169536bf77316e99a0df5c4aaebb11569"}, + {file = "msal-1.32.3.tar.gz", hash = "sha256:5eea038689c78a5a70ca8ecbe1245458b55a857bd096efb6989c69ba15985d35"}, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +requires_python = ">=3.9" +summary = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." +groups = ["azure-queue", "azure-servicebus"] +dependencies = [ + "msal<2,>=1.29", +] +files = [ + {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, + {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, +] + [[package]] name = "multidict" -version = "6.5.0" +version = "6.6.3" requires_python = ">=3.9" summary = "multidict implementation" -groups = ["rabbitmq", "sqs"] +groups = ["dev-consumers", "rabbitmq"] dependencies = [ "typing-extensions>=4.1.0; python_version < \"3.11\"", ] files = [ - {file = "multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469"}, - {file = "multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9"}, - {file = "multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69"}, - {file = "multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4"}, - {file = "multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4"}, - {file = "multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877"}, - {file = "multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138"}, - {file = "multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0"}, - {file = "multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf"}, - {file = "multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851"}, - {file = "multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743"}, - {file = "multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b"}, - {file = "multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af"}, - {file = "multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06"}, - {file = "multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461"}, - {file = "multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1"}, - {file = "multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1"}, - {file = "multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4"}, - {file = "multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc"}, - {file = "multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b"}, + {file = "multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318"}, + {file = "multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485"}, + {file = "multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183"}, + {file = "multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5"}, + {file = "multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2"}, + {file = "multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10"}, + {file = "multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5"}, + {file = "multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17"}, + {file = "multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6"}, + {file = "multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e"}, + {file = "multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9"}, + {file = "multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c"}, + {file = "multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e"}, + {file = "multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d"}, + {file = "multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb"}, + {file = "multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a"}, + {file = "multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc"}, ] [[package]] @@ -1385,172 +1722,203 @@ name = "nats-py" version = "2.10.0" requires_python = ">=3.7" summary = "NATS client for Python" -groups = ["integration-tests", "nats"] +groups = ["nats"] files = [ {file = "nats_py-2.10.0.tar.gz", hash = "sha256:9d44265a097edb30d40e214c1dd1a7405c1451d33480ce714c041fb73bb66a10"}, ] [[package]] name = "opentelemetry-api" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Python API" -groups = ["dev-otel"] +groups = ["dev-otel", "pubsub"] dependencies = [ "importlib-metadata<8.8.0,>=6.0", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c"}, - {file = "opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3"}, + {file = "opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06"}, + {file = "opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe"}, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Collector Exporters" groups = ["dev-otel"] dependencies = [ - "opentelemetry-exporter-otlp-proto-grpc==1.34.1", - "opentelemetry-exporter-otlp-proto-http==1.34.1", + "opentelemetry-exporter-otlp-proto-grpc==1.35.0", + "opentelemetry-exporter-otlp-proto-http==1.35.0", ] files = [ - {file = "opentelemetry_exporter_otlp-1.34.1-py3-none-any.whl", hash = "sha256:f4a453e9cde7f6362fd4a090d8acf7881d1dc585540c7b65cbd63e36644238d4"}, - {file = "opentelemetry_exporter_otlp-1.34.1.tar.gz", hash = "sha256:71c9ad342d665d9e4235898d205db17c5764cd7a69acb8a5dcd6d5e04c4c9988"}, + {file = "opentelemetry_exporter_otlp-1.35.0-py3-none-any.whl", hash = "sha256:8e6bb9025f6238db7d69bba7ee37c77e4858d0a1ff22a9e126f7c9e017e83afe"}, + {file = "opentelemetry_exporter_otlp-1.35.0.tar.gz", hash = "sha256:f94feff09b3524df867c7876b79c96cef20068106cb5efe55340e8d08192c8a4"}, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Protobuf encoding" groups = ["dev-otel"] dependencies = [ - "opentelemetry-proto==1.34.1", + "opentelemetry-proto==1.35.0", ] files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.35.0-py3-none-any.whl", hash = "sha256:863465de697ae81279ede660f3918680b4480ef5f69dcdac04f30722ed7b74cc"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.35.0.tar.gz", hash = "sha256:6f6d8c39f629b9fa5c79ce19a2829dbd93034f8ac51243cdf40ed2196f00d7eb"}, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Collector Protobuf over gRPC Exporter" groups = ["dev-otel"] dependencies = [ - "googleapis-common-protos~=1.52", + "googleapis-common-protos~=1.57", "grpcio<2.0.0,>=1.63.2; python_version < \"3.13\"", "grpcio<2.0.0,>=1.66.2; python_version >= \"3.13\"", "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.34.1", - "opentelemetry-proto==1.34.1", - "opentelemetry-sdk~=1.34.1", - "typing-extensions>=4.5.0", + "opentelemetry-exporter-otlp-proto-common==1.35.0", + "opentelemetry-proto==1.35.0", + "opentelemetry-sdk~=1.35.0", + "typing-extensions>=4.6.0", ] files = [ - {file = "opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6"}, - {file = "opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.35.0-py3-none-any.whl", hash = "sha256:ee31203eb3e50c7967b8fa71db366cc355099aca4e3726e489b248cdb2fd5a62"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.35.0.tar.gz", hash = "sha256:ac4c2c3aa5674642db0df0091ab43ec08bbd91a9be469c8d9b18923eb742b9cc"}, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Collector Protobuf over HTTP Exporter" groups = ["dev-otel"] dependencies = [ "googleapis-common-protos~=1.52", "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.34.1", - "opentelemetry-proto==1.34.1", - "opentelemetry-sdk~=1.34.1", + "opentelemetry-exporter-otlp-proto-common==1.35.0", + "opentelemetry-proto==1.35.0", + "opentelemetry-sdk~=1.35.0", "requests~=2.7", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.35.0-py3-none-any.whl", hash = "sha256:9a001e3df3c7f160fb31056a28ed7faa2de7df68877ae909516102ae36a54e1d"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.35.0.tar.gz", hash = "sha256:cf940147f91b450ef5f66e9980d40eb187582eed399fa851f4a7a45bb880de79"}, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.55b1" +version = "0.56b0" requires_python = ">=3.9" summary = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" groups = ["dev-otel"] dependencies = [ "opentelemetry-api~=1.4", - "opentelemetry-semantic-conventions==0.55b1", + "opentelemetry-semantic-conventions==0.56b0", "packaging>=18.0", "wrapt<2.0.0,>=1.0.0", ] files = [ - {file = "opentelemetry_instrumentation-0.55b1-py3-none-any.whl", hash = "sha256:cbb1496b42bc394e01bc63701b10e69094e8564e281de063e4328d122cc7a97e"}, - {file = "opentelemetry_instrumentation-0.55b1.tar.gz", hash = "sha256:2dc50aa207b9bfa16f70a1a0571e011e737a9917408934675b89ef4d5718c87b"}, + {file = "opentelemetry_instrumentation-0.56b0-py3-none-any.whl", hash = "sha256:948967f7c8f5bdc6e43512ba74c9ae14acb48eb72a35b61afe8db9909f743be3"}, + {file = "opentelemetry_instrumentation-0.56b0.tar.gz", hash = "sha256:d2dbb3021188ca0ec8c5606349ee9a2919239627e8341d4d37f1d21ec3291d11"}, +] + +[[package]] +name = "opentelemetry-instrumentation-botocore" +version = "0.56b0" +requires_python = ">=3.9" +summary = "OpenTelemetry Botocore instrumentation" +groups = ["dev-otel"] +dependencies = [ + "opentelemetry-api~=1.30", + "opentelemetry-instrumentation==0.56b0", + "opentelemetry-propagator-aws-xray~=1.0", + "opentelemetry-semantic-conventions==0.56b0", +] +files = [ + {file = "opentelemetry_instrumentation_botocore-0.56b0-py3-none-any.whl", hash = "sha256:7ccacdcb5b858d931b0b526b2e3e857c06f89c67847252fd048002b295862bf8"}, + {file = "opentelemetry_instrumentation_botocore-0.56b0.tar.gz", hash = "sha256:c2dd342eec24acdac7cb6e59c268f523c1e1165b6c7f2ac1f339623a2e1d6118"}, ] [[package]] name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.55b1" +version = "0.56b0" requires_python = ">=3.9" summary = "OpenTelemetry Confluent Kafka instrumentation" groups = ["dev-otel"] dependencies = [ "opentelemetry-api~=1.12", - "opentelemetry-instrumentation==0.55b1", + "opentelemetry-instrumentation==0.56b0", "wrapt<2.0.0,>=1.0.0", ] files = [ - {file = "opentelemetry_instrumentation_confluent_kafka-0.55b1-py3-none-any.whl", hash = "sha256:0e101e4541b1ec0122fabb749ceb859eeb0a992041f10ab8669e1fd53e9a5f5c"}, - {file = "opentelemetry_instrumentation_confluent_kafka-0.55b1.tar.gz", hash = "sha256:d95d2740720ee9f52db35c90af5e4ce5d3b49edd2332dbe69da2431525bbf226"}, + {file = "opentelemetry_instrumentation_confluent_kafka-0.56b0-py3-none-any.whl", hash = "sha256:dc52fba7ddf01d8f13476b81024a8cd8d3dc191f6b8684b6c0a829e6c81d0dd2"}, + {file = "opentelemetry_instrumentation_confluent_kafka-0.56b0.tar.gz", hash = "sha256:f57ae3abef376f37c8d14769a5cfe5c40f2b337f3db0bf63f24b4e827aec87e2"}, +] + +[[package]] +name = "opentelemetry-propagator-aws-xray" +version = "1.0.2" +requires_python = ">=3.8" +summary = "AWS X-Ray Propagator for OpenTelemetry" +groups = ["dev-otel"] +dependencies = [ + "opentelemetry-api~=1.12", +] +files = [ + {file = "opentelemetry_propagator_aws_xray-1.0.2-py3-none-any.whl", hash = "sha256:1c99181ee228e99bddb638a0c911a297fa21f1c3a0af951f841e79919b5f1934"}, + {file = "opentelemetry_propagator_aws_xray-1.0.2.tar.gz", hash = "sha256:6b2cee5479d2ef0172307b66ed2ed151f598a0fd29b3c01133ac87ca06326260"}, ] [[package]] name = "opentelemetry-proto" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Python Proto" groups = ["dev-otel"] dependencies = [ - "protobuf<6.0,>=5.0", + "protobuf<7.0,>=5.0", ] files = [ - {file = "opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2"}, - {file = "opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e"}, + {file = "opentelemetry_proto-1.35.0-py3-none-any.whl", hash = "sha256:98fffa803164499f562718384e703be8d7dfbe680192279a0429cb150a2f8809"}, + {file = "opentelemetry_proto-1.35.0.tar.gz", hash = "sha256:532497341bd3e1c074def7c5b00172601b28bb83b48afc41a4b779f26eb4ee05"}, ] [[package]] name = "opentelemetry-sdk" -version = "1.34.1" +version = "1.35.0" requires_python = ">=3.9" summary = "OpenTelemetry Python SDK" -groups = ["dev-otel"] +groups = ["dev-otel", "pubsub"] dependencies = [ - "opentelemetry-api==1.34.1", - "opentelemetry-semantic-conventions==0.55b1", + "opentelemetry-api==1.35.0", + "opentelemetry-semantic-conventions==0.56b0", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e"}, - {file = "opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d"}, + {file = "opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800"}, + {file = "opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954"}, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.55b1" +version = "0.56b0" requires_python = ">=3.9" summary = "OpenTelemetry Semantic Conventions" -groups = ["dev-otel"] +groups = ["dev-otel", "pubsub"] dependencies = [ - "opentelemetry-api==1.34.1", + "opentelemetry-api==1.35.0", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed"}, - {file = "opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3"}, + {file = "opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2"}, + {file = "opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea"}, ] [[package]] @@ -1600,12 +1968,23 @@ files = [ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] +[[package]] +name = "priority" +version = "2.0.0" +requires_python = ">=3.6.1" +summary = "A pure-Python implementation of the HTTP/2 priority tree" +groups = ["dev-hosting-http"] +files = [ + {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, + {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, +] + [[package]] name = "propcache" version = "0.3.2" requires_python = ">=3.9" summary = "Accelerated property cache" -groups = ["rabbitmq", "sqs"] +groups = ["dev-consumers", "rabbitmq"] files = [ {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, @@ -1691,20 +2070,34 @@ files = [ {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, ] +[[package]] +name = "proto-plus" +version = "1.26.1" +requires_python = ">=3.7" +summary = "Beautiful, Pythonic protocol buffers" +groups = ["pubsub"] +dependencies = [ + "protobuf<7.0.0,>=3.19.0", +] +files = [ + {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, + {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, +] + [[package]] name = "protobuf" -version = "5.29.5" -requires_python = ">=3.8" +version = "6.31.1" +requires_python = ">=3.9" summary = "" -groups = ["dev-consumers", "dev-otel"] +groups = ["dev-consumers", "dev-otel", "pubsub"] files = [ - {file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"}, - {file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"}, - {file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"}, - {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"}, - {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"}, - {file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"}, - {file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"}, + {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, + {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, + {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, + {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, + {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, ] [[package]] @@ -1825,12 +2218,37 @@ files = [ {file = "psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700"}, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +requires_python = ">=3.8" +summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +groups = ["pubsub"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +requires_python = ">=3.8" +summary = "A collection of ASN.1-based protocols modules" +groups = ["pubsub"] +dependencies = [ + "pyasn1<0.7.0,>=0.6.1", +] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + [[package]] name = "pycparser" version = "2.22" requires_python = ">=3.8" summary = "C parser in Python" -groups = ["dev-consumers", "tests"] +groups = ["azure-queue", "azure-servicebus", "dev-consumers", "tests"] marker = "os_name == \"nt\" and implementation_name != \"pypy\" or platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, @@ -1945,13 +2363,40 @@ files = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" requires_python = ">=3.8" summary = "Pygments is a syntax highlighting package written in Python." groups = ["dev", "tests", "unit-tests"] files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +requires_python = ">=3.9" +summary = "JSON Web Token implementation in Python" +groups = ["azure-queue", "azure-servicebus"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +extras = ["crypto"] +requires_python = ">=3.9" +summary = "JSON Web Token implementation in Python" +groups = ["azure-queue", "azure-servicebus"] +dependencies = [ + "PyJWT==2.10.1", + "cryptography>=3.4.0", +] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, ] [[package]] @@ -2006,7 +2451,7 @@ files = [ [[package]] name = "pytest-xdist" -version = "3.7.0" +version = "3.8.0" requires_python = ">=3.9" summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" groups = ["tests"] @@ -2015,8 +2460,8 @@ dependencies = [ "pytest>=7.0.0", ] files = [ - {file = "pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0"}, - {file = "pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126"}, + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, ] [[package]] @@ -2024,7 +2469,7 @@ name = "python-dateutil" version = "2.9.0.post0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Extensions to the standard Python datetime module" -groups = ["cron", "integration-tests", "sqs"] +groups = ["cron", "dev-consumers", "integration-tests", "sqs"] dependencies = [ "six>=1.5", ] @@ -2035,13 +2480,13 @@ files = [ [[package]] name = "python-dotenv" -version = "1.1.0" +version = "1.1.1" requires_python = ">=3.9" summary = "Read key-value pairs from a .env file and set them as environment variables" groups = ["integration-tests"] files = [ - {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, - {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, ] [[package]] @@ -2091,7 +2536,7 @@ name = "requests" version = "2.32.4" requires_python = ">=3.8" summary = "Python HTTP for Humans." -groups = ["dev-otel", "integration-tests"] +groups = ["azure-queue", "azure-servicebus", "dev-otel", "integration-tests", "pubsub"] dependencies = [ "certifi>=2017.4.17", "charset-normalizer<4,>=2", @@ -2103,12 +2548,26 @@ files = [ {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] +[[package]] +name = "rsa" +version = "4.9.1" +requires_python = "<4,>=3.6" +summary = "Pure-Python RSA implementation" +groups = ["pubsub"] +dependencies = [ + "pyasn1>=0.1.3", +] +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + [[package]] name = "s3transfer" version = "0.13.0" requires_python = ">=3.9" summary = "An Amazon S3 Transfer Manager" -groups = ["integration-tests"] +groups = ["dev-consumers", "integration-tests"] dependencies = [ "botocore<2.0a.0,>=1.37.4", ] @@ -2117,6 +2576,80 @@ files = [ {file = "s3transfer-0.13.0.tar.gz", hash = "sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177"}, ] +[[package]] +name = "setproctitle" +version = "1.3.6" +requires_python = ">=3.8" +summary = "A Python module to customize the process title" +groups = ["tests"] +files = [ + {file = "setproctitle-1.3.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ebcf34b69df4ca0eabaaaf4a3d890f637f355fed00ba806f7ebdd2d040658c26"}, + {file = "setproctitle-1.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1aa1935aa2195b76f377e5cb018290376b7bf085f0b53f5a95c0c21011b74367"}, + {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13624d9925bb481bc0ccfbc7f533da38bfbfe6e80652314f789abc78c2e513bd"}, + {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97a138fa875c6f281df7720dac742259e85518135cd0e3551aba1c628103d853"}, + {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86e9e82bfab579327dbe9b82c71475165fbc8b2134d24f9a3b2edaf200a5c3d"}, + {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6af330ddc2ec05a99c3933ab3cba9365357c0b8470a7f2fa054ee4b0984f57d1"}, + {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:109fc07b1cd6cef9c245b2028e3e98e038283342b220def311d0239179810dbe"}, + {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7df5fcc48588f82b6cc8073db069609ddd48a49b1e9734a20d0efb32464753c4"}, + {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:2407955dc359d735a20ac6e797ad160feb33d529a2ac50695c11a1ec680eafab"}, + {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:38ca045626af693da042ac35d7332e7b9dbd52e6351d6973b310612e3acee6d6"}, + {file = "setproctitle-1.3.6-cp310-cp310-win32.whl", hash = "sha256:9483aa336687463f5497dd37a070094f3dff55e2c888994f8440fcf426a1a844"}, + {file = "setproctitle-1.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:4efc91b437f6ff2578e89e3f17d010c0a0ff01736606473d082913ecaf7859ba"}, + {file = "setproctitle-1.3.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a1d856b0f4e4a33e31cdab5f50d0a14998f3a2d726a3fd5cb7c4d45a57b28d1b"}, + {file = "setproctitle-1.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50706b9c0eda55f7de18695bfeead5f28b58aa42fd5219b3b1692d554ecbc9ec"}, + {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af188f3305f0a65c3217c30c6d4c06891e79144076a91e8b454f14256acc7279"}, + {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cce0ed8b3f64c71c140f0ec244e5fdf8ecf78ddf8d2e591d4a8b6aa1c1214235"}, + {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70100e2087fe05359f249a0b5f393127b3a1819bf34dec3a3e0d4941138650c9"}, + {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1065ed36bd03a3fd4186d6c6de5f19846650b015789f72e2dea2d77be99bdca1"}, + {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4adf6a0013fe4e0844e3ba7583ec203ca518b9394c6cc0d3354df2bf31d1c034"}, + {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb7452849f6615871eabed6560ffedfe56bc8af31a823b6be4ce1e6ff0ab72c5"}, + {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a094b7ce455ca341b59a0f6ce6be2e11411ba6e2860b9aa3dbb37468f23338f4"}, + {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ad1c2c2baaba62823a7f348f469a967ece0062140ca39e7a48e4bbb1f20d54c4"}, + {file = "setproctitle-1.3.6-cp311-cp311-win32.whl", hash = "sha256:8050c01331135f77ec99d99307bfbc6519ea24d2f92964b06f3222a804a3ff1f"}, + {file = "setproctitle-1.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:9b73cf0fe28009a04a35bb2522e4c5b5176cc148919431dcb73fdbdfaab15781"}, + {file = "setproctitle-1.3.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af44bb7a1af163806bbb679eb8432fa7b4fb6d83a5d403b541b675dcd3798638"}, + {file = "setproctitle-1.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cca16fd055316a48f0debfcbfb6af7cea715429fc31515ab3fcac05abd527d8"}, + {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea002088d5554fd75e619742cefc78b84a212ba21632e59931b3501f0cfc8f67"}, + {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb465dd5825356c1191a038a86ee1b8166e3562d6e8add95eec04ab484cfb8a2"}, + {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2c8e20487b3b73c1fa72c56f5c89430617296cd380373e7af3a538a82d4cd6d"}, + {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d6252098e98129a1decb59b46920d4eca17b0395f3d71b0d327d086fefe77d"}, + {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf355fbf0d4275d86f9f57be705d8e5eaa7f8ddb12b24ced2ea6cbd68fdb14dc"}, + {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e288f8a162d663916060beb5e8165a8551312b08efee9cf68302687471a6545d"}, + {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b2e54f4a2dc6edf0f5ea5b1d0a608d2af3dcb5aa8c8eeab9c8841b23e1b054fe"}, + {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b6f4abde9a2946f57e8daaf1160b2351bcf64274ef539e6675c1d945dbd75e2a"}, + {file = "setproctitle-1.3.6-cp312-cp312-win32.whl", hash = "sha256:db608db98ccc21248370d30044a60843b3f0f3d34781ceeea67067c508cd5a28"}, + {file = "setproctitle-1.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:082413db8a96b1f021088e8ec23f0a61fec352e649aba20881895815388b66d3"}, + {file = "setproctitle-1.3.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e2a9e62647dc040a76d55563580bf3bb8fe1f5b6ead08447c2ed0d7786e5e794"}, + {file = "setproctitle-1.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:751ba352ed922e0af60458e961167fa7b732ac31c0ddd1476a2dfd30ab5958c5"}, + {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7890e291bf4708e3b61db9069ea39b3ab0651e42923a5e1f4d78a7b9e4b18301"}, + {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2b17855ed7f994f3f259cf2dfbfad78814538536fa1a91b50253d84d87fd88d"}, + {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e51ec673513465663008ce402171192a053564865c2fc6dc840620871a9bd7c"}, + {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63cc10352dc6cf35a33951656aa660d99f25f574eb78132ce41a85001a638aa7"}, + {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dba8faee2e4a96e934797c9f0f2d093f8239bf210406a99060b3eabe549628e"}, + {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e3e44d08b61de0dd6f205528498f834a51a5c06689f8fb182fe26f3a3ce7dca9"}, + {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:de004939fc3fd0c1200d26ea9264350bfe501ffbf46c8cf5dc7f345f2d87a7f1"}, + {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f8194b4d631b003a1176a75d1acd545e04b1f54b821638e098a93e6e62830ef"}, + {file = "setproctitle-1.3.6-cp313-cp313-win32.whl", hash = "sha256:d714e002dd3638170fe7376dc1b686dbac9cb712cde3f7224440af722cc9866a"}, + {file = "setproctitle-1.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:b70c07409d465f3a8b34d52f863871fb8a00755370791d2bd1d4f82b3cdaf3d5"}, + {file = "setproctitle-1.3.6-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:23a57d3b8f1549515c2dbe4a2880ebc1f27780dc126c5e064167563e015817f5"}, + {file = "setproctitle-1.3.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81c443310831e29fabbd07b75ebbfa29d0740b56f5907c6af218482d51260431"}, + {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d88c63bd395c787b0aa81d8bbc22c1809f311032ce3e823a6517b711129818e4"}, + {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73f14b86d0e2858ece6bf5807c9889670e392c001d414b4293d0d9b291942c3"}, + {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3393859eb8f19f5804049a685bf286cb08d447e28ba5c6d8543c7bf5500d5970"}, + {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:785cd210c0311d9be28a70e281a914486d62bfd44ac926fcd70cf0b4d65dff1c"}, + {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c051f46ed1e13ba8214b334cbf21902102807582fbfaf0fef341b9e52f0fafbf"}, + {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:49498ebf68ca3e75321ffe634fcea5cc720502bfaa79bd6b03ded92ce0dc3c24"}, + {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4431629c178193f23c538cb1de3da285a99ccc86b20ee91d81eb5f1a80e0d2ba"}, + {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d136fbf8ad4321716e44d6d6b3d8dffb4872626010884e07a1db54b7450836cf"}, + {file = "setproctitle-1.3.6-cp313-cp313t-win32.whl", hash = "sha256:d483cc23cc56ab32911ea0baa0d2d9ea7aa065987f47de847a0a93a58bf57905"}, + {file = "setproctitle-1.3.6-cp313-cp313t-win_amd64.whl", hash = "sha256:74973aebea3543ad033b9103db30579ec2b950a466e09f9c2180089e8346e0ec"}, + {file = "setproctitle-1.3.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3cde5b83ec4915cd5e6ae271937fd60d14113c8f7769b4a20d51769fe70d8717"}, + {file = "setproctitle-1.3.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:797f2846b546a8741413c57d9fb930ad5aa939d925c9c0fa6186d77580035af7"}, + {file = "setproctitle-1.3.6-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac3eb04bcf0119aadc6235a2c162bae5ed5f740e3d42273a7228b915722de20"}, + {file = "setproctitle-1.3.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0e6b5633c94c5111f7137f875e8f1ff48f53b991d5d5b90932f27dc8c1fa9ae4"}, + {file = "setproctitle-1.3.6.tar.gz", hash = "sha256:c9f32b96c700bb384f33f7cf07954bb609d35dd82752cef57fb2ee0968409169"}, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -2133,7 +2666,7 @@ name = "six" version = "1.17.0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Python 2 and 3 compatibility utilities" -groups = ["cron", "integration-tests", "sqs"] +groups = ["azure-queue", "azure-servicebus", "cron", "dev-consumers", "integration-tests", "sqs"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2162,17 +2695,17 @@ files = [ [[package]] name = "starlette" -version = "0.46.2" +version = "0.47.1" requires_python = ">=3.9" summary = "The little ASGI library that shines." groups = ["examples"] dependencies = [ "anyio<5,>=3.6.2", - "typing-extensions>=3.10.0; python_version < \"3.10\"", + "typing-extensions>=4.10.0; python_version < \"3.13\"", ] files = [ - {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, - {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, + {file = "starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527"}, + {file = "starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b"}, ] [[package]] @@ -2190,34 +2723,32 @@ files = [ ] [[package]] -name = "testcontainers" -version = "4.10.0" -requires_python = "<4.0,>=3.9" -summary = "Python library for throwaway instances of anything that can run in a Docker container" -groups = ["integration-tests"] +name = "taskgroup" +version = "0.2.2" +summary = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout" +groups = ["dev-hosting-http"] +marker = "python_version < \"3.11\"" dependencies = [ - "docker", - "python-dotenv", - "typing-extensions", - "urllib3", - "wrapt", + "exceptiongroup", + "typing-extensions<5,>=4.12.2", ] files = [ - {file = "testcontainers-4.10.0-py3-none-any.whl", hash = "sha256:31ed1a81238c7e131a2a29df6db8f23717d892b592fa5a1977fd0dcd0c23fc23"}, - {file = "testcontainers-4.10.0.tar.gz", hash = "sha256:03f85c3e505d8b4edeb192c72a961cebbcba0dd94344ae778b4a159cb6dcf8d3"}, + {file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"}, + {file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"}, ] [[package]] name = "testcontainers" version = "4.10.0" -extras = ["kafka", "localstack", "nats"] requires_python = "<4.0,>=3.9" summary = "Python library for throwaway instances of anything that can run in a Docker container" groups = ["integration-tests"] dependencies = [ - "boto3", - "nats-py", - "testcontainers==4.10.0", + "docker", + "python-dotenv", + "typing-extensions", + "urllib3", + "wrapt", ] files = [ {file = "testcontainers-4.10.0-py3-none-any.whl", hash = "sha256:31ed1a81238c7e131a2a29df6db8f23717d892b592fa5a1977fd0dcd0c23fc23"}, @@ -2229,7 +2760,7 @@ name = "tomli" version = "2.2.1" requires_python = ">=3.8" summary = "A lil' TOML parser" -groups = ["tests", "unit-tests"] +groups = ["dev-hosting-http", "tests", "unit-tests"] marker = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, @@ -2314,66 +2845,99 @@ files = [ ] [[package]] -name = "types-aiobotocore" -version = "2.23.0" +name = "types-aioboto3-lite" +version = "15.0.0" requires_python = ">=3.8" -summary = "Type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" +summary = "Lite type annotations for aioboto3 15.0.0 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ "botocore-stubs", + "types-aiobotocore-lite", + "types-s3transfer", "typing-extensions>=4.1.0; python_version < \"3.12\"", ] files = [ - {file = "types_aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:f62d9a58d730bbde825355feedb045713b6de2feab4c1b68b071afd1da60dea8"}, - {file = "types_aiobotocore-2.23.0.tar.gz", hash = "sha256:d363a584726b9582e18cadbed9f88053ac4b488373e0e643d292b57b2f182ac8"}, + {file = "types_aioboto3_lite-15.0.0-py3-none-any.whl", hash = "sha256:90857d96330e5fd8fe7aa1838b7f638a4ed5264bab3ac453a55ae51e9479c208"}, + {file = "types_aioboto3_lite-15.0.0.tar.gz", hash = "sha256:b178dba5dc1ada5402f7afc01ca70edf5d0e76d993d27115a42639ed4d62e061"}, ] [[package]] -name = "types-aiobotocore-sqs" +name = "types-aioboto3-lite" +version = "15.0.0" +extras = ["sqs"] +requires_python = ">=3.8" +summary = "Lite type annotations for aioboto3 15.0.0 generated with mypy-boto3-builder 8.11.0" +groups = ["dev-types"] +dependencies = [ + "types-aioboto3-lite==15.0.0", + "types-aiobotocore-sqs<2.24.0,>=2.23.0", +] +files = [ + {file = "types_aioboto3_lite-15.0.0-py3-none-any.whl", hash = "sha256:90857d96330e5fd8fe7aa1838b7f638a4ed5264bab3ac453a55ae51e9479c208"}, + {file = "types_aioboto3_lite-15.0.0.tar.gz", hash = "sha256:b178dba5dc1ada5402f7afc01ca70edf5d0e76d993d27115a42639ed4d62e061"}, +] + +[[package]] +name = "types-aiobotocore-lite" version = "2.23.0" requires_python = ">=3.8" -summary = "Type annotations for aiobotocore SQS 2.23.0 service generated with mypy-boto3-builder 8.11.0" +summary = "Lite type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ - "typing-extensions; python_version < \"3.12\"", + "botocore-stubs", + "typing-extensions>=4.1.0; python_version < \"3.12\"", ] files = [ - {file = "types_aiobotocore_sqs-2.23.0-py3-none-any.whl", hash = "sha256:88f1bd8b0767d40ef8b607c1158f4e80309bf99e9678657cc1fd671efc09caea"}, - {file = "types_aiobotocore_sqs-2.23.0.tar.gz", hash = "sha256:1701584bb8e6b89cbe7297f56205f0d26b42d7e0241ce9aa1779c0433cdbb29c"}, + {file = "types_aiobotocore_lite-2.23.0-py3-none-any.whl", hash = "sha256:2a04fc8f321ce74d08db99b37514c14fc674a9ecde49ce972f3f69bac0d4d64d"}, + {file = "types_aiobotocore_lite-2.23.0.tar.gz", hash = "sha256:a65746c4e4d2fe070a785e8d7206046215711159b1e5c27f294d9a8820d5f553"}, ] [[package]] -name = "types-aiobotocore" +name = "types-aiobotocore-lite" version = "2.23.0" extras = ["sqs"] requires_python = ">=3.8" -summary = "Type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" +summary = "Lite type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ + "types-aiobotocore-lite==2.23.0", "types-aiobotocore-sqs<2.24.0,>=2.23.0", - "types-aiobotocore==2.23.0", ] files = [ - {file = "types_aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:f62d9a58d730bbde825355feedb045713b6de2feab4c1b68b071afd1da60dea8"}, - {file = "types_aiobotocore-2.23.0.tar.gz", hash = "sha256:d363a584726b9582e18cadbed9f88053ac4b488373e0e643d292b57b2f182ac8"}, + {file = "types_aiobotocore_lite-2.23.0-py3-none-any.whl", hash = "sha256:2a04fc8f321ce74d08db99b37514c14fc674a9ecde49ce972f3f69bac0d4d64d"}, + {file = "types_aiobotocore_lite-2.23.0.tar.gz", hash = "sha256:a65746c4e4d2fe070a785e8d7206046215711159b1e5c27f294d9a8820d5f553"}, +] + +[[package]] +name = "types-aiobotocore-sqs" +version = "2.23.0" +requires_python = ">=3.8" +summary = "Type annotations for aiobotocore SQS 2.23.0 service generated with mypy-boto3-builder 8.11.0" +groups = ["dev-types"] +dependencies = [ + "typing-extensions; python_version < \"3.12\"", +] +files = [ + {file = "types_aiobotocore_sqs-2.23.0-py3-none-any.whl", hash = "sha256:88f1bd8b0767d40ef8b607c1158f4e80309bf99e9678657cc1fd671efc09caea"}, + {file = "types_aiobotocore_sqs-2.23.0.tar.gz", hash = "sha256:1701584bb8e6b89cbe7297f56205f0d26b42d7e0241ce9aa1779c0433cdbb29c"}, ] [[package]] name = "types-awscrt" -version = "0.27.2" +version = "0.27.4" requires_python = ">=3.8" summary = "Type annotations and code completion for awscrt" groups = ["dev-types"] files = [ - {file = "types_awscrt-0.27.2-py3-none-any.whl", hash = "sha256:49a045f25bbd5ad2865f314512afced933aed35ddbafc252e2268efa8a787e4e"}, - {file = "types_awscrt-0.27.2.tar.gz", hash = "sha256:acd04f57119eb15626ab0ba9157fc24672421de56e7bd7b9f61681fedee44e91"}, + {file = "types_awscrt-0.27.4-py3-none-any.whl", hash = "sha256:a8c4b9d9ae66d616755c322aba75ab9bd793c6fef448917e6de2e8b8cdf66fb4"}, + {file = "types_awscrt-0.27.4.tar.gz", hash = "sha256:c019ba91a097e8a31d6948f6176ede1312963f41cdcacf82482ac877cbbcf390"}, ] [[package]] -name = "types-boto3" -version = "1.38.41" +name = "types-boto3-lite" +version = "1.39.4" requires_python = ">=3.8" -summary = "Type annotations for boto3 1.38.41 generated with mypy-boto3-builder 8.11.0" +summary = "Lite type annotations for boto3 1.39.4 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ "botocore-stubs", @@ -2381,60 +2945,71 @@ dependencies = [ "typing-extensions>=4.1.0; python_version < \"3.12\"", ] files = [ - {file = "types_boto3-1.38.41-py3-none-any.whl", hash = "sha256:d0f5cbe3861ca2a108990e8c8075f03cd1f1b803b87e3f6bddf0a75c779957a5"}, - {file = "types_boto3-1.38.41.tar.gz", hash = "sha256:cdc55bd5932e9395884dd34c01ec74a6b1102f5cc94162651da35a8a1215c733"}, + {file = "types_boto3_lite-1.39.4-py3-none-any.whl", hash = "sha256:c705dcadfc75645e12b6ce04810f079d216f8284c141e33f62695cdf6340824f"}, + {file = "types_boto3_lite-1.39.4.tar.gz", hash = "sha256:9bfd8e136da1c0f391a57c390e2856ea8ead6e5644cd6ebd3864b8256e0cebef"}, ] [[package]] -name = "types-boto3-sqs" -version = "1.38.0" +name = "types-boto3-lite" +version = "1.39.4" +extras = ["sqs"] requires_python = ">=3.8" -summary = "Type annotations for boto3 SQS 1.38.0 service generated with mypy-boto3-builder 8.10.1" +summary = "Lite type annotations for boto3 1.39.4 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ - "typing-extensions; python_version < \"3.12\"", + "types-boto3-lite==1.39.4", + "types-boto3-sqs<1.40.0,>=1.39.0", ] files = [ - {file = "types_boto3_sqs-1.38.0-py3-none-any.whl", hash = "sha256:3bf05864d2c635ad4ea32554223365b51d945859d2ed87f08aa3f2d09ee08518"}, - {file = "types_boto3_sqs-1.38.0.tar.gz", hash = "sha256:7713556cb9a7ab6b6b21a0a2a194b9c13fdeeb6082b87f58e2179a601d964082"}, + {file = "types_boto3_lite-1.39.4-py3-none-any.whl", hash = "sha256:c705dcadfc75645e12b6ce04810f079d216f8284c141e33f62695cdf6340824f"}, + {file = "types_boto3_lite-1.39.4.tar.gz", hash = "sha256:9bfd8e136da1c0f391a57c390e2856ea8ead6e5644cd6ebd3864b8256e0cebef"}, ] [[package]] -name = "types-boto3" -version = "1.38.41" -extras = ["sqs"] +name = "types-boto3-sqs" +version = "1.39.0" requires_python = ">=3.8" -summary = "Type annotations for boto3 1.38.41 generated with mypy-boto3-builder 8.11.0" +summary = "Type annotations for boto3 SQS 1.39.0 service generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ - "types-boto3-sqs<1.39.0,>=1.38.0", - "types-boto3==1.38.41", + "typing-extensions; python_version < \"3.12\"", ] files = [ - {file = "types_boto3-1.38.41-py3-none-any.whl", hash = "sha256:d0f5cbe3861ca2a108990e8c8075f03cd1f1b803b87e3f6bddf0a75c779957a5"}, - {file = "types_boto3-1.38.41.tar.gz", hash = "sha256:cdc55bd5932e9395884dd34c01ec74a6b1102f5cc94162651da35a8a1215c733"}, + {file = "types_boto3_sqs-1.39.0-py3-none-any.whl", hash = "sha256:bbe1d206443d29660ae2630e4558f90e04f89fe99e7010d0f6be1a921734ced4"}, + {file = "types_boto3_sqs-1.39.0.tar.gz", hash = "sha256:aa39971515da52047db7a944bb2ded0de509f9d5395995821d71966569e32cba"}, ] [[package]] name = "types-croniter" -version = "6.0.0.20250411" +version = "6.0.0.20250626" requires_python = ">=3.9" summary = "Typing stubs for croniter" groups = ["dev-types"] files = [ - {file = "types_croniter-6.0.0.20250411-py3-none-any.whl", hash = "sha256:4acdaccf4190017daa51699bd3110a0617c5df72459e62dea8b72549c62fad90"}, - {file = "types_croniter-6.0.0.20250411.tar.gz", hash = "sha256:ee97025b7768f2cc556ef52a2f10c97c503c1634f372fd3e9683d8f7c960eeb7"}, + {file = "types_croniter-6.0.0.20250626-py3-none-any.whl", hash = "sha256:3e8c37b54b541f323b2c487f3a1c9dcb27a7092333a2d5c09fff0c4d41c68380"}, + {file = "types_croniter-6.0.0.20250626.tar.gz", hash = "sha256:c32243b16d4dfa7c9989a5eadc6762459d093dded023f3c363fdee6b96578a77"}, +] + +[[package]] +name = "types-grpcio" +version = "1.0.0.20250703" +requires_python = ">=3.9" +summary = "Typing stubs for grpcio" +groups = ["dev-types"] +files = [ + {file = "types_grpcio-1.0.0.20250703-py3-none-any.whl", hash = "sha256:78d1bfc33b58a56697ef99e666e34be4c6887631341c75fdd28d58587aef5d9f"}, + {file = "types_grpcio-1.0.0.20250703.tar.gz", hash = "sha256:baf100184e5353cb60f045fb4fd47f37a360bedf0f19581535e4c3a3a1f7912b"}, ] [[package]] name = "types-protobuf" -version = "6.30.2.20250516" +version = "6.30.2.20250703" requires_python = ">=3.9" summary = "Typing stubs for protobuf" groups = ["dev-types"] files = [ - {file = "types_protobuf-6.30.2.20250516-py3-none-any.whl", hash = "sha256:8c226d05b5e8b2623111765fa32d6e648bbc24832b4c2fddf0fa340ba5d5b722"}, - {file = "types_protobuf-6.30.2.20250516.tar.gz", hash = "sha256:aecd1881770a9bb225ede66872ef7f0da4505edd0b193108edd9892e48d49a41"}, + {file = "types_protobuf-6.30.2.20250703-py3-none-any.whl", hash = "sha256:fa5aff9036e9ef432d703abbdd801b436a249b6802e4df5ef74513e272434e57"}, + {file = "types_protobuf-6.30.2.20250703.tar.gz", hash = "sha256:609a974754bbb71fa178fc641f51050395e8e1849f49d0420a6281ed8d1ddf46"}, ] [[package]] @@ -2450,13 +3025,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.14.1" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "dev", "dev-consumers", "dev-otel", "dev-types", "examples", "http", "integration-tests", "rabbitmq", "sqs", "tests", "unit-tests"] +groups = ["default", "azure-queue", "azure-servicebus", "dev", "dev-consumers", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "pubsub", "rabbitmq", "tests", "unit-tests"] files = [ - {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, - {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] [[package]] @@ -2490,7 +3065,7 @@ name = "urllib3" version = "2.5.0" requires_python = ">=3.9" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["dev-otel", "integration-tests", "sqs"] +groups = ["azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "integration-tests", "pubsub", "sqs"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, @@ -2498,18 +3073,18 @@ files = [ [[package]] name = "uvicorn" -version = "0.34.3" +version = "0.35.0" requires_python = ">=3.9" summary = "The lightning-fast ASGI server." -groups = ["http"] +groups = ["dev-hosting-http"] dependencies = [ "click>=7.0", "h11>=0.8", "typing-extensions>=4.0; python_version < \"3.11\"", ] files = [ - {file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"}, - {file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"}, + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [[package]] @@ -2552,7 +3127,7 @@ name = "wrapt" version = "1.17.2" requires_python = ">=3.8" summary = "Module for decorators, wrappers and monkey patching." -groups = ["dev-otel", "integration-tests", "sqs"] +groups = ["dev-consumers", "dev-otel", "integration-tests"] files = [ {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, @@ -2613,12 +3188,26 @@ files = [ {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, ] +[[package]] +name = "wsproto" +version = "1.2.0" +requires_python = ">=3.7.0" +summary = "WebSockets state-machine based protocol implementation" +groups = ["dev-hosting-http"] +dependencies = [ + "h11<1,>=0.9.0", +] +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + [[package]] name = "yarl" version = "1.20.1" requires_python = ">=3.9" summary = "Yet another URL library" -groups = ["rabbitmq", "sqs"] +groups = ["dev-consumers", "rabbitmq"] dependencies = [ "idna>=2.0", "multidict>=4.0", @@ -2719,7 +3308,7 @@ name = "zipp" version = "3.23.0" requires_python = ">=3.9" summary = "Backport of pathlib-compatible object wrapper for zip files" -groups = ["dev-otel"] +groups = ["dev-otel", "pubsub"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, diff --git a/pyproject.toml b/pyproject.toml index 3da6c58..384fa84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,12 +47,6 @@ dependencies = [ ] [project.optional-dependencies] -http = [ # Hosting service - "uvicorn ~=0.30", -] -grpc = [ # Hosting service - "grpcio ~=1.68", -] cron = [ "croniter >=2.0,<4.0", ] @@ -62,13 +56,13 @@ scheduler = [ # TODO Better name "pytimeparse2 ~=1.6", ] sqs = [ - "aiobotocore ~=2.15", - "botocore ~=1.37", -# "aws-lambda-powertools", # To migrate from Lambda + "botocore ~=1.38", # Sync SQS client (default) +# "boto3 ~=1.38", # In case boto3 is available, the default session will be used +# "aiobotocore", # Async SQS client +# "aioboto3 >=14.1", # In case aioboto3 is available, the default session will be used ] kafka = [ "confluent-kafka ~=2.4", -# "confluent-kafka[schemaregistry,protobuf] ~=2.4", ] nats = [ "nats-py ~=2.8", @@ -80,9 +74,12 @@ rabbitmq = [ pubsub = [ "google-cloud-pubsub ~=2.28", ] -azure = [ +azure-queue = [ "azure-identity", "azure-storage-queue ~=12.8", +] +azure-servicebus = [ + "azure-identity", "azure-servicebus ~=7.10", ] @@ -93,35 +90,47 @@ dev = [ "icecream ~=2.1", "structlog ~=25.0", ] +dev-hosting-http = [ + "uvicorn ~=0.30", + "hypercorn ~=0.17", +] +dev-hosting-grpc = [ + "grpcio ~=1.68", +] dev-consumers = [ - "aws-lambda-powertools", + "boto3 ~=1.38", + "aws-lambda-powertools ~=3.10", + "aiobotocore ~=2.21", + "aioboto3 >=14.1", "confluent-kafka[schemaregistry,protobuf]", "grpcio-tools ~=1.68", ] dev-otel = [ "opentelemetry-exporter-otlp", # Both gRPC and HTTP "opentelemetry-instrumentation-confluent-kafka >=0.50b0", - "opentelemetry-instrumentation-aiokafka >=0.50b0", +# "opentelemetry-instrumentation-aiokafka >=0.50b0", "opentelemetry-instrumentation-botocore >=0.50b0", - "protobuf ~=5.0", # See https://github.com/open-telemetry/opentelemetry-python/issues/4639#issuecomment-2997003823 + "protobuf ~=6.29", # See https://github.com/open-telemetry/opentelemetry-python/issues/4639#issuecomment-2997003823 ] dev-types = [ "types-croniter", # "types-confluent-kafka", # Many signatures do not match to the actual implementation -# "types-protobuf", -# "grpc-stubs", # Hasn't been updated for 2+ years... - "types-boto3-lite[sqs] ~=1.37", - "types-aiobotocore[sqs] ~=2.15", + "types-protobuf ~=6.29", # Must be in sync with protobuf version, see above + "types-grpcio", # Replaces grpc-stubs, see: https://github.com/python/typeshed/pull/11204 + "types-boto3-lite[sqs] ~=1.38", + "types-aiobotocore-lite[sqs] ~=2.21", + "types-aioboto3-lite[sqs] >=14.1", ] examples = [ "fast-depends ~=2.4", "fastapi-slim ~=0.111", - 'psycopg[binary,pool] ~=3.2', + "psycopg[binary,pool] ~=3.2", ] tests = [ "pytest ~=8.1", "anyio[test]", "pytest-xdist", + "setproctitle", # For pytest-xdist, to have descriptive process names ] unit-tests = [ "pytest-mock ~=3.14", @@ -148,6 +157,8 @@ line-length = 120 format.docstring-code-format = true [tool.ruff.lint] +pyupgrade.keep-runtime-typing = true +pydocstyle.convention = "google" # For the reference, check https://docs.astral.sh/ruff/rules/ select = [ "F", # pyflakes @@ -192,10 +203,16 @@ select = [ ] ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter + "RUF022", # `__all__` is not sorted + "PGH003", "PGH004", # Use specific rule codes when ignoring type issues + "RUF005", + "PERF203", # Enforced for Python versions prior to 3.11, which introduced "zero-cost" exception handling + "TID252", # Prefer absolute imports over relative imports from parent modules "S101", # Use of `assert` detected "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes "B011", # Do not `assert False` - "SIM108", # Use ternary operator + "SIM105", # Use `contextlib.suppress(GeneratorExit)` instead of `try`-`except`-`pass` # TODO Remove when we drop support for Python <=3.10 "SIM117", # Use a single `with` statement with multiple contexts "PLR0913", # Too many arguments in function definition @@ -209,14 +226,6 @@ ignore = [ "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` ] -pyupgrade.keep-runtime-typing = true - -[tool.ruff.lint.pydocstyle] -convention = "google" - -[tool.ruff.format] -docstring-code-format = true - [tool.pdm] distribution = true diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..75ffe68 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import logging + +import localpost # noqa + +logging.getLogger("localpost").setLevel(logging.DEBUG) diff --git a/tests/consumers/kafka.py b/tests/consumers/kafka.py index 93adb58..69a7e36 100644 --- a/tests/consumers/kafka.py +++ b/tests/consumers/kafka.py @@ -6,10 +6,9 @@ import pytest from confluent_kafka import Producer -from localpost import flow +from localpost import flow, debug from localpost.consumers.kafka import KafkaMessage, KafkaMessages, kafka_consumer from localpost.hosting import Host - from .RedpandaContainer import RedpandaContainer pytestmark = [pytest.mark.anyio, pytest.mark.integration] @@ -32,7 +31,7 @@ async def test_normal_case(local_kafka): # Arrange sent = ["London: cloudy", "Paris: rainy"] - p = Producer(local_kafka, logger=logger) + p = Producer(local_kafka) for message in sent: # Redpanda creates a topic automatically if it doesn't exist p.produce(topic_name, message) p.flush() @@ -45,13 +44,13 @@ async def test_normal_case(local_kafka): "auto.offset.reset": "earliest", } - @kafka_consumer(topic_name, client_config) + @kafka_consumer(topic_name, **client_config) @flow.handler def handle(m: KafkaMessage) -> None: received.append(m.value.decode()) host = Host(handle) - async with host.aserve(): + async with debug, host.aserve(): await anyio.sleep(3) # "App is working" host.shutdown() @@ -67,7 +66,7 @@ async def test_batching(local_kafka): # Arrange sent = ["London: cloudy", "Paris: rainy"] - p = Producer(local_kafka, logger=logger) + p = Producer(local_kafka) for message in sent: p.produce(topic_name, message) p.flush() @@ -80,7 +79,7 @@ async def test_batching(local_kafka): "auto.offset.reset": "earliest", } - @kafka_consumer(topic_name, client_config) + @kafka_consumer(topic_name, **client_config) @flow.batch(10, 1, KafkaMessages) @flow.handler async def handle(messages: KafkaMessages): @@ -88,7 +87,7 @@ async def handle(messages: KafkaMessages): received += [[m.value.decode() for m in messages]] host = Host(handle) - async with host.aserve(): + async with debug, host.aserve(): await anyio.sleep(3) # "App is working" host.shutdown() diff --git a/tests/consumers/sqs.py b/tests/consumers/sqs_aioboto.py similarity index 54% rename from tests/consumers/sqs.py rename to tests/consumers/sqs_aioboto.py index 6afdbb1..58382b5 100644 --- a/tests/consumers/sqs.py +++ b/tests/consumers/sqs_aioboto.py @@ -4,12 +4,12 @@ import anyio import boto3 import pytest -from aiobotocore.session import get_session +from aiobotocore.session import get_session as get_aio_session from testcontainers.localstack import LocalStackContainer from types_boto3_sqs.client import SQSClient -from localpost import flow -from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer +from localpost import flow, debug +from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer, AsyncConsumerClient from localpost.hosting import Host pytestmark = [pytest.mark.anyio, pytest.mark.integration] @@ -46,33 +46,42 @@ async def test_happy_path(local_sqs): sent = ["hello", "world", "!"] sqs_client: SQSClient = boto3.client("sqs", **local_sqs) - sqs_queue = sqs_client.create_queue(QueueName=queue_name) + queue_url = sqs_client.create_queue( + QueueName=queue_name, Attributes={"VisibilityTimeout": "1"} + )["QueueUrl"] for message in sent: - sqs_client.send_message(QueueUrl=sqs_queue["QueueUrl"], MessageBody=message) + sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) # Act received = [] - def create_client(): - return get_session().create_client("sqs", **local_sqs) - - @sqs_queue_consumer(queue_name, create_client) + @sqs_queue_consumer(lambda: AsyncConsumerClient.create( + queue_url, get_aio_session().create_client("sqs", **local_sqs))) @flow.handler async def handle(m: SqsMessage): nonlocal received - received += [m.body] + with m as m_body: + received += [m_body] host = Host(handle) - async with host.aserve(): + async with debug, host.aserve(): await anyio.sleep(3) # "App is working" host.shutdown() # Assert + # TODO Check messages are deleted from the queue + # (https://stackoverflow.com/questions/25351145/get-number-of-messages-in-an-amazon-sqs-queue) assert host.status["exception"] is None assert received == sent + queue_info = sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) + assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 + assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 + async def test_handler_manager(local_sqs): # Test handler lifecycle (via handler manager) @@ -86,26 +95,27 @@ async def test_batching(local_sqs): sent = ["hello", "world", "!"] sqs_client: SQSClient = boto3.client("sqs", **local_sqs) - sqs_queue = sqs_client.create_queue(QueueName=queue_name) + queue_url = sqs_client.create_queue( + QueueName=queue_name, Attributes={"VisibilityTimeout": "2"} # Should be greater than the batch window + )["QueueUrl"] for message in sent: - sqs_client.send_message(QueueUrl=sqs_queue["QueueUrl"], MessageBody=message) + sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) # Act received = [] - def create_client(): - return get_session().create_client("sqs", **local_sqs) - - @sqs_queue_consumer(queue_name, create_client) + @sqs_queue_consumer(lambda: AsyncConsumerClient.create( + queue_url, get_aio_session().create_client("sqs", **local_sqs))) @flow.batch(10, 1, SqsMessages) @flow.handler async def handle(messages: SqsMessages): nonlocal received - received += [[m.body for m in messages]] + with messages: + received += [[m.body for m in messages]] host = Host(handle) - async with host.aserve(): + async with debug, host.aserve(): await anyio.sleep(3) # "App is working" host.shutdown() @@ -113,3 +123,9 @@ async def handle(messages: SqsMessages): assert host.status["exception"] is None assert received == [sent] + + queue_info = sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) + assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 + assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 diff --git a/tests/consumers/sqs_boto.py b/tests/consumers/sqs_boto.py new file mode 100644 index 0000000..425043d --- /dev/null +++ b/tests/consumers/sqs_boto.py @@ -0,0 +1,119 @@ +import random +import string + +import anyio +import boto3 +import pytest +from testcontainers.localstack import LocalStackContainer +from types_boto3_sqs.client import SQSClient + +from localpost import flow, debug +from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer, ConsumerClient +from localpost.hosting import Host + +pytestmark = [pytest.mark.anyio, pytest.mark.integration] + + +@pytest.fixture(scope="module") +def local_sqs(): + with LocalStackContainer("localstack/localstack:4.0").with_services("sqs") as aws: + aws_url = aws.get_url() + + conn_params = { + "endpoint_url": aws_url, + "region_name": aws.region_name, + "aws_access_key_id": "test", + "aws_secret_access_key": "test", + } + + yield conn_params + + +async def test_happy_path(local_sqs): + queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + + # Arrange + + sent = ["hello", "world", "!"] + sqs_client: SQSClient = boto3.client("sqs", **local_sqs) + queue_url = sqs_client.create_queue( + QueueName=queue_name, Attributes={"VisibilityTimeout": "1"} + )["QueueUrl"] + for message in sent: + sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) + + # Act + + received = [] + + @sqs_queue_consumer(lambda: ConsumerClient.create(queue_url, sqs_client)) + @flow.handler + async def handle(m: SqsMessage): + nonlocal received + with m as m_body: + received += [m_body] + + host = Host(handle) + async with debug, host.aserve(): + await anyio.sleep(3) # "App is working" + host.shutdown() + + # Assert + + # TODO Check messages are deleted from the queue + # (https://stackoverflow.com/questions/25351145/get-number-of-messages-in-an-amazon-sqs-queue) + assert host.status["exception"] is None + assert received == sent + + queue_info = sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) + assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 + assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 + + +async def test_handler_manager(local_sqs): + # Test handler lifecycle (via handler manager) + pass + + +async def test_batching(local_sqs): + queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + + # Arrange + + sent = ["hello", "world", "!"] + sqs_client: SQSClient = boto3.client("sqs", **local_sqs) + queue_url = sqs_client.create_queue( + QueueName=queue_name, Attributes={"VisibilityTimeout": "2"} # Should be greater than the batch window + )["QueueUrl"] + for message in sent: + sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) + + # Act + + received = [] + + @sqs_queue_consumer(lambda: ConsumerClient.create(queue_url, sqs_client)) + @flow.batch(10, 1, SqsMessages) + @flow.handler + async def handle(messages: SqsMessages): + nonlocal received + with messages: + received += [[m.body for m in messages]] + + host = Host(handle) + async with debug, host.aserve(): + await anyio.sleep(3) # "App is working" + host.shutdown() + + # Assert + + assert host.status["exception"] is None + assert received == [sent] + + queue_info = sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) + assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 + assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 From 313c042ae66f496001f7361635fa62fd32df5178 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 3 Feb 2026 20:34:50 +0000 Subject: [PATCH 005/286] WIP --- localpost/__init__.py | 2 +- pdm.lock | 3729 +++++++++++++++++--------------- pyproject.toml | 2 +- tests/consumers/kafka.py | 9 +- tests/consumers/sqs_aioboto.py | 17 +- tests/consumers/sqs_boto.py | 17 +- 6 files changed, 2023 insertions(+), 1753 deletions(-) diff --git a/localpost/__init__.py b/localpost/__init__.py index 46a4051..879caa1 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -15,4 +15,4 @@ # Set up logging according to the best practices: # https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library -logging.getLogger("boto3").addHandler(logging.NullHandler()) +logging.getLogger("localpost").addHandler(logging.NullHandler()) diff --git a/pdm.lock b/pdm.lock index 748da86..727f634 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,88 +5,88 @@ groups = ["default", "azure-queue", "azure-servicebus", "cron", "dev", "dev-consumers", "dev-hosting-grpc", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "kafka", "nats", "pubsub", "rabbitmq", "scheduler", "sqs", "tests", "unit-tests"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:440b34dcf15d89513862f0ce2faf2234bf1d10c7671b96e7b1357b919ae3329d" +content_hash = "sha256:fd412350845c89307b3046a3a4d81c3bbab01d82e922e118651ca0c180cee970" [[metadata.targets]] requires_python = ">=3.10" [[package]] name = "aio-pika" -version = "9.5.5" -requires_python = "<4.0,>=3.9" +version = "9.5.8" +requires_python = "<4.0,>=3.10" summary = "Wrapper around the aiormq for asyncio and humans" groups = ["rabbitmq"] dependencies = [ - "aiormq<6.9,>=6.8", - "exceptiongroup<2,>=1", + "aiormq<7.0,>=6.8", + "exceptiongroup<2,>=1; python_version < \"3.11\"", "typing-extensions; python_version < \"3.10\"", "yarl", ] files = [ - {file = "aio_pika-9.5.5-py3-none-any.whl", hash = "sha256:94e0ac3666398d6a28b0c3b530c1febf4c6d4ececb345620727cfd7bfe1c02e0"}, - {file = "aio_pika-9.5.5.tar.gz", hash = "sha256:3d2f25838860fa7e209e21fc95555f558401f9b49a832897419489f1c9e1d6a4"}, + {file = "aio_pika-9.5.8-py3-none-any.whl", hash = "sha256:f4c6cb8a6c5176d00f39fd7431e9702e638449bc6e86d1769ad7548b2a506a8d"}, + {file = "aio_pika-9.5.8.tar.gz", hash = "sha256:7c36874115f522bbe7486c46d8dd711a4dbedd67c4e8a8c47efe593d01862c62"}, ] [[package]] name = "aioboto3" -version = "15.0.0" -requires_python = "<4.0,>=3.9" +version = "15.5.0" +requires_python = ">=3.9" summary = "Async boto3 wrapper" groups = ["dev-consumers"] dependencies = [ - "aiobotocore[boto3]==2.23.0", + "aiobotocore[boto3]==2.25.1", "aiofiles>=23.2.1", ] files = [ - {file = "aioboto3-15.0.0-py3-none-any.whl", hash = "sha256:9cf54b3627c8b34bb82eaf43ab327e7027e37f92b1e10dd5cfe343cd512568d0"}, - {file = "aioboto3-15.0.0.tar.gz", hash = "sha256:dce40b701d1f8e0886dc874d27cd9799b8bf6b32d63743f57e7bef7e4a562756"}, + {file = "aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6"}, + {file = "aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979"}, ] [[package]] name = "aiobotocore" -version = "2.23.0" +version = "2.25.1" requires_python = ">=3.9" summary = "Async client for aws services using botocore and aiohttp" groups = ["dev-consumers"] dependencies = [ "aiohttp<4.0.0,>=3.9.2", "aioitertools<1.0.0,>=0.5.1", - "botocore<1.38.28,>=1.38.23", + "botocore<1.40.62,>=1.40.46", "jmespath<2.0.0,>=0.7.1", "multidict<7.0.0,>=6.0.0", "python-dateutil<3.0.0,>=2.1", "wrapt<2.0.0,>=1.10.10", ] files = [ - {file = "aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:8202cebbf147804a083a02bc282fbfda873bfdd0065fd34b64784acb7757b66e"}, - {file = "aiobotocore-2.23.0.tar.gz", hash = "sha256:0333931365a6c7053aee292fe6ef50c74690c4ae06bb019afdf706cb6f2f5e32"}, + {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, + {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, ] [[package]] name = "aiobotocore" -version = "2.23.0" +version = "2.25.1" extras = ["boto3"] requires_python = ">=3.9" summary = "Async client for aws services using botocore and aiohttp" groups = ["dev-consumers"] dependencies = [ - "aiobotocore==2.23.0", - "boto3<1.38.28,>=1.38.23", + "aiobotocore==2.25.1", + "boto3<1.40.62,>=1.40.46", ] files = [ - {file = "aiobotocore-2.23.0-py3-none-any.whl", hash = "sha256:8202cebbf147804a083a02bc282fbfda873bfdd0065fd34b64784acb7757b66e"}, - {file = "aiobotocore-2.23.0.tar.gz", hash = "sha256:0333931365a6c7053aee292fe6ef50c74690c4ae06bb019afdf706cb6f2f5e32"}, + {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, + {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, ] [[package]] name = "aiofiles" -version = "24.1.0" -requires_python = ">=3.8" +version = "25.1.0" +requires_python = ">=3.9" summary = "File support for asyncio." groups = ["dev-consumers"] files = [ - {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, - {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, ] [[package]] @@ -102,7 +102,7 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.14" +version = "3.13.3" requires_python = ">=3.9" summary = "Async http client/server framework (asyncio)" groups = ["dev-consumers"] @@ -117,105 +117,138 @@ dependencies = [ "yarl<2.0,>=1.17.0", ] files = [ - {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, - {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, - {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, - {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, - {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, - {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, - {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, - {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, - {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, - {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, - {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, - {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, - {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, - {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, - {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, - {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, - {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, - {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, - {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, - {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, - {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, + {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, + {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, + {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, + {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, + {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, + {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, + {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, + {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, + {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, + {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, + {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, ] [[package]] name = "aioitertools" -version = "0.12.0" -requires_python = ">=3.8" +version = "0.13.0" +requires_python = ">=3.9" summary = "itertools and builtins for AsyncIO and mixed iterables" groups = ["dev-consumers"] dependencies = [ "typing-extensions>=4.0; python_version < \"3.10\"", ] files = [ - {file = "aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796"}, - {file = "aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b"}, + {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"}, + {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"}, ] [[package]] name = "aiormq" -version = "6.8.1" -requires_python = "<4.0,>=3.8" +version = "6.9.2" +requires_python = "<4.0,>=3.10" summary = "Pure python AMQP asynchronous client library" groups = ["rabbitmq"] dependencies = [ "pamqp==3.3.0", - "setuptools; python_version < \"3.8\"", "yarl", ] files = [ - {file = "aiormq-6.8.1-py3-none-any.whl", hash = "sha256:5da896c8624193708f9409ffad0b20395010e2747f22aa4150593837f40aa017"}, - {file = "aiormq-6.8.1.tar.gz", hash = "sha256:a964ab09634be1da1f9298ce225b310859763d5cf83ef3a7eae1a6dc6bd1da1a"}, + {file = "aiormq-6.9.2-py3-none-any.whl", hash = "sha256:ab0f4e88e70f874b0ea344b3c41634d2484b5dc8b17cb6ae0ae7892a172ad003"}, + {file = "aiormq-6.9.2.tar.gz", hash = "sha256:d051d46086079934d3a7157f4d8dcb856b77683c2a94aee9faa165efa6a785d3"}, ] [[package]] @@ -233,6 +266,17 @@ files = [ {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +requires_python = ">=3.8" +summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." +groups = ["examples"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -249,71 +293,44 @@ files = [ [[package]] name = "anyio" -version = "4.9.0" +version = "4.12.1" requires_python = ">=3.9" -summary = "High level compatibility layer for multiple asynchronous event loop implementations" +summary = "High-level concurrency and networking framework on top of asyncio or Trio" groups = ["default", "dev-consumers", "examples", "tests"] dependencies = [ "exceptiongroup>=1.0.2; python_version < \"3.11\"", "idna>=2.8", - "sniffio>=1.1", "typing-extensions>=4.5; python_version < \"3.13\"", ] files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [[package]] name = "anyio" -version = "4.9.0" +version = "4.12.1" extras = ["test"] requires_python = ">=3.9" -summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["tests"] -dependencies = [ - "anyio==4.9.0", - "anyio[trio]", - "blockbuster>=1.5.23", - "coverage[toml]>=7", - "exceptiongroup>=1.2.0", - "hypothesis>=4.0", - "psutil>=5.9", - "pytest>=7.0", - "trustme", - "truststore>=0.9.1; python_version >= \"3.10\"", - "uvloop>=0.21; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\"", -] -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[[package]] -name = "anyio" -version = "4.9.0" -extras = ["trio"] -requires_python = ">=3.9" -summary = "High level compatibility layer for multiple asynchronous event loop implementations" +summary = "High-level concurrency and networking framework on top of asyncio or Trio" groups = ["tests"] dependencies = [ - "anyio==4.9.0", - "trio>=0.26.1", + "anyio==4.12.1", ] files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [[package]] name = "asttokens" -version = "3.0.0" +version = "3.0.1" requires_python = ">=3.8" summary = "Annotate AST trees with source code positions" groups = ["dev"] files = [ - {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, - {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, + {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"}, + {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"}, ] [[package]] @@ -330,18 +347,18 @@ files = [ [[package]] name = "attrs" -version = "25.3.0" -requires_python = ">=3.8" +version = "25.4.0" +requires_python = ">=3.9" summary = "Classes Without Boilerplate" -groups = ["dev-consumers", "tests"] +groups = ["dev-consumers"] files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, ] [[package]] name = "authlib" -version = "1.6.0" +version = "1.6.6" requires_python = ">=3.9" summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." groups = ["dev-consumers"] @@ -349,14 +366,14 @@ dependencies = [ "cryptography", ] files = [ - {file = "authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d"}, - {file = "authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210"}, + {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, + {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, ] [[package]] name = "aws-lambda-powertools" -version = "3.16.0" -requires_python = "<4.0.0,>=3.9" +version = "3.24.0" +requires_python = "<4.0.0,>=3.10" summary = "Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverless best practices and increase developer velocity." groups = ["dev-consumers"] dependencies = [ @@ -364,29 +381,28 @@ dependencies = [ "typing-extensions<5.0.0,>=4.11.0", ] files = [ - {file = "aws_lambda_powertools-3.16.0-py3-none-any.whl", hash = "sha256:b37843d4873b88fedd8eed507fdbf3eb7193e48a6b662b1e7b41e741cdcc765b"}, - {file = "aws_lambda_powertools-3.16.0.tar.gz", hash = "sha256:df0e5b6e575bf8fd30101d3c39358deb3ccbed195756343e44af34d05345f128"}, + {file = "aws_lambda_powertools-3.24.0-py3-none-any.whl", hash = "sha256:9c9002856f61b86f49271a9d7efa0dad322ecd22719ddc1c6bb373e57ee0421a"}, + {file = "aws_lambda_powertools-3.24.0.tar.gz", hash = "sha256:9f86959c4aeac9669da799999aae5feac7a3a86e642b52473892eaa4273d3cc3"}, ] [[package]] name = "azure-core" -version = "1.35.0" +version = "1.37.0" requires_python = ">=3.9" summary = "Microsoft Azure Core Library for Python" groups = ["azure-queue", "azure-servicebus"] dependencies = [ "requests>=2.21.0", - "six>=1.11.0", "typing-extensions>=4.6.0", ] files = [ - {file = "azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1"}, - {file = "azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c"}, + {file = "azure_core-1.37.0-py3-none-any.whl", hash = "sha256:b3abe2c59e7d6bb18b38c275a5029ff80f98990e7c90a5e646249a56630fcc19"}, + {file = "azure_core-1.37.0.tar.gz", hash = "sha256:7064f2c11e4b97f340e8e8c6d923b822978be3016e46b7bc4aa4b337cfb48aee"}, ] [[package]] name = "azure-identity" -version = "1.23.0" +version = "1.25.1" requires_python = ">=3.9" summary = "Microsoft Azure Identity Library for Python" groups = ["azure-queue", "azure-servicebus"] @@ -398,14 +414,14 @@ dependencies = [ "typing-extensions>=4.0.0", ] files = [ - {file = "azure_identity-1.23.0-py3-none-any.whl", hash = "sha256:dbbeb64b8e5eaa81c44c565f264b519ff2de7ff0e02271c49f3cb492762a50b0"}, - {file = "azure_identity-1.23.0.tar.gz", hash = "sha256:d9cdcad39adb49d4bb2953a217f62aec1f65bbb3c63c9076da2be2a47e53dde4"}, + {file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"}, + {file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"}, ] [[package]] name = "azure-servicebus" -version = "7.14.2" -requires_python = ">=3.8" +version = "7.14.3" +requires_python = ">=3.9" summary = "Microsoft Azure Service Bus Client Library for Python" groups = ["azure-servicebus"] dependencies = [ @@ -414,14 +430,14 @@ dependencies = [ "typing-extensions>=4.6.0", ] files = [ - {file = "azure_servicebus-7.14.2-py3-none-any.whl", hash = "sha256:154bdde487ae3bd581fd49d207da4892091d9b61ab95f4cd4deab6dd4bb4649a"}, - {file = "azure_servicebus-7.14.2.tar.gz", hash = "sha256:4014b7ac882e0d9ff876a3302818607e1a640b93e9d482073d639f5b04266e5c"}, + {file = "azure_servicebus-7.14.3-py3-none-any.whl", hash = "sha256:386f8d32dae8881661ec8d791c38978eca2bbf7ea9f489d6cff8ad9cc6990234"}, + {file = "azure_servicebus-7.14.3.tar.gz", hash = "sha256:70a63384557aec0bee727740e7b25ded29e9e701b77611764577fd7402389402"}, ] [[package]] name = "azure-storage-queue" -version = "12.12.0" -requires_python = ">=3.8" +version = "12.15.0" +requires_python = ">=3.9" summary = "Microsoft Azure Azure Queue Storage Client Library for Python" groups = ["azure-queue"] dependencies = [ @@ -431,43 +447,29 @@ dependencies = [ "typing-extensions>=4.6.0", ] files = [ - {file = "azure_storage_queue-12.12.0-py3-none-any.whl", hash = "sha256:9305f724e0df6a93e3645bf075b5a7e3fc0a1eb1ee47c85912c7aff6b6fd490d"}, - {file = "azure_storage_queue-12.12.0.tar.gz", hash = "sha256:baf2f1bc82b7d4f5291922c3ea4f23ce2243e942dbe7494fca1782290b37f1e4"}, -] - -[[package]] -name = "blockbuster" -version = "1.5.24" -requires_python = ">=3.8" -summary = "Utility to detect blocking calls in the async event loop" -groups = ["tests"] -dependencies = [ - "forbiddenfruit>=0.1.4; implementation_name == \"cpython\"", -] -files = [ - {file = "blockbuster-1.5.24-py3-none-any.whl", hash = "sha256:e703497b55bc72af09d60d1cd746c2f3ba7ce0c446fa256be6ccda5e7d403520"}, - {file = "blockbuster-1.5.24.tar.gz", hash = "sha256:97645775761a5d425666ec0bc99629b65c7eccdc2f770d2439850682567af4ec"}, + {file = "azure_storage_queue-12.15.0-py3-none-any.whl", hash = "sha256:056cfce0cd60458f0b7653d804f639098b14593f843899c6c0fc65b3ebe61210"}, + {file = "azure_storage_queue-12.15.0.tar.gz", hash = "sha256:4e01dcae5aefd0c463f7bae5c75c8a91f955c893f14ed7590fc0cd447ac4666d"}, ] [[package]] name = "boto3" -version = "1.38.27" +version = "1.40.61" requires_python = ">=3.9" summary = "The AWS SDK for Python" groups = ["dev-consumers", "integration-tests"] dependencies = [ - "botocore<1.39.0,>=1.38.27", + "botocore<1.41.0,>=1.40.61", "jmespath<2.0.0,>=0.7.1", - "s3transfer<0.14.0,>=0.13.0", + "s3transfer<0.15.0,>=0.14.0", ] files = [ - {file = "boto3-1.38.27-py3-none-any.whl", hash = "sha256:95f5fe688795303a8a15e8b7e7f255cadab35eae459d00cc281a4fd77252ea80"}, - {file = "boto3-1.38.27.tar.gz", hash = "sha256:94bd7fdd92d5701b362d4df100d21e28f8307a67ff56b6a8b0398119cf22f859"}, + {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, + {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, ] [[package]] name = "botocore" -version = "1.38.27" +version = "1.40.61" requires_python = ">=3.9" summary = "Low-level, data-driven core of boto 3." groups = ["dev-consumers", "integration-tests", "sqs"] @@ -478,13 +480,13 @@ dependencies = [ "urllib3<1.27,>=1.25.4; python_version < \"3.10\"", ] files = [ - {file = "botocore-1.38.27-py3-none-any.whl", hash = "sha256:a785d5e9a5eda88ad6ab9ed8b87d1f2ac409d0226bba6ff801c55359e94d91a8"}, - {file = "botocore-1.38.27.tar.gz", hash = "sha256:9788f7efe974328a38cbade64cc0b1e67d27944b899f88cb786ae362973133b6"}, + {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, + {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, ] [[package]] name = "botocore-stubs" -version = "1.38.46" +version = "1.42.24" requires_python = ">=3.9" summary = "Type annotations and code completion for botocore" groups = ["dev-types"] @@ -492,158 +494,211 @@ dependencies = [ "types-awscrt", ] files = [ - {file = "botocore_stubs-1.38.46-py3-none-any.whl", hash = "sha256:cc21d9a7dd994bdd90872db4664d817c4719b51cda8004fd507a4bf65b085a75"}, - {file = "botocore_stubs-1.38.46.tar.gz", hash = "sha256:a04e69766ab8bae338911c1897492f88d05cd489cd75f06e6eb4f135f9da8c7b"}, + {file = "botocore_stubs-1.42.24-py3-none-any.whl", hash = "sha256:025999e68f419472cc8dfb7bcc2964fa0a06b447f43e7fc309012ff4c665b3db"}, + {file = "botocore_stubs-1.42.24.tar.gz", hash = "sha256:f5fbe240267b27036b1217a304de34bf2bf993087e049a300d17d6f52d77988b"}, ] [[package]] name = "cachetools" -version = "5.5.2" -requires_python = ">=3.7" +version = "6.2.4" +requires_python = ">=3.9" summary = "Extensible memoizing collections and decorators" -groups = ["dev-consumers", "pubsub"] +groups = ["dev-consumers"] files = [ - {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, - {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, + {file = "cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51"}, + {file = "cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607"}, ] [[package]] name = "certifi" -version = "2025.7.9" +version = "2026.1.4" requires_python = ">=3.7" summary = "Python package for providing Mozilla's CA Bundle." groups = ["azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "integration-tests", "pubsub"] files = [ - {file = "certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39"}, - {file = "certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] name = "cffi" -version = "1.17.1" -requires_python = ">=3.8" +version = "2.0.0" +requires_python = ">=3.9" summary = "Foreign Function Interface for Python calling C code." -groups = ["azure-queue", "azure-servicebus", "dev-consumers", "tests"] -marker = "os_name == \"nt\" and implementation_name != \"pypy\" or platform_python_implementation != \"PyPy\"" -dependencies = [ - "pycparser", -] -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +groups = ["azure-queue", "azure-servicebus", "dev-consumers"] +marker = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\"" +dependencies = [ + "pycparser; implementation_name != \"PyPy\"", +] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] [[package]] name = "charset-normalizer" -version = "3.4.2" +version = "3.4.4" requires_python = ">=3.7" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." groups = ["azure-queue", "azure-servicebus", "dev-otel", "integration-tests", "pubsub"] files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] name = "click" -version = "8.2.1" +version = "8.3.1" requires_python = ">=3.10" summary = "Composable command line interface toolkit" groups = ["dev-hosting-http"] @@ -651,8 +706,8 @@ dependencies = [ "colorama; platform_system == \"Windows\"", ] files = [ - {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, - {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [[package]] @@ -668,215 +723,303 @@ files = [ [[package]] name = "confluent-kafka" -version = "2.11.0" -requires_python = ">=3.7" +version = "2.13.0" +requires_python = ">=3.8" summary = "Confluent's Python client for Apache Kafka" groups = ["dev-consumers", "kafka"] files = [ - {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae672723577775aba560da34e376ffa4a038ff3e07c8513920b81903f8f9c4e8"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc8346a55c4b3f4e4ed938243997e3223e0f00c93c48d52a86343943c0316a6c"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5dccc94019fbca33bcc49d993641b7145c448255206480a38eee452869653b33"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a13398b5b6e025e710b3123c17db8dbc4db78c708916bb602211257fcd7f27c8"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:08e0b0b7fb21ddd83801231f5f32b7e7ba4f7c9a7dee9034a1e36e6b6479143a"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f54f077904cc8541be03e16bee9bf37207c96e6bbd3c5b3d1b5fbd878e40580"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:455a368d35dc4444d4d8f442b5715e3b43310b60bb0f99837556830bd32defdf"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:707d7a39755986605402dd308013e6a142b8a5105d51f863b17f4bc283f4ac85"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:655b5d3a90955dd4ee15e59d89f33b5eaae14e7122e9fecb93b4c03ef426ec97"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:6dc952c45108351b3995b85e23d99c2251a5160e3ed3d9a7768300d8c97eca56"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7ab9b40866d783cd410019b0380b0db815ac36ecf07be2be80a429e41d7caf98"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:70682b25b5ce835de54b6eb803a75e550a953e494fe348960ed8c534cb54ea50"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:80edbdf59d312772a4c5646807cb2a0b5f5a2c7f945df070aac1782a7790f432"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1ac35a551c8df8313695bff3e81dd8ef3d9041b0edc8827883ef8015819bc59c"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:be6273ae93a9076c28ccda39dacae98405af696c97bab02a8a78abd02fe3add4"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:e58b263d8e02c54396e7159d2d6668c9e6f2e7faa06c40026df205ff81b6188e"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a29ac34c7dd3ab51c2e7cc16c37baed13369a487d7f953ff4e3a8fff08642969"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2552147d0929db95ace291e460c652981721e0e437a074445fbafdcee8b6817c"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ed0981e686bdc4ee86034113a750242fe1b405826bf4a553498bf558429188af"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6d6ec6e791b1c97668ff0823dff1dc005ab418b353a430fede7b5cd6c5490497"}, - {file = "confluent_kafka-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:9f44a476241a81e6b3259d3f3f00aa0e94ed64a924131f83cb3b640ecdfcd633"}, - {file = "confluent_kafka-2.11.0.tar.gz", hash = "sha256:d95512838eebd42a4657ce891dfa32bcbc06906e3391b328de24f7731b0c2755"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:264f86956d3bbd26bc14fd2d4060908a0c53d26b14ff33d21fa83369f55d8d3b"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4af5f129acce30110c7a1c9c05c54821c1283850e38c67715bed7c3c8df322c6"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b975664e821d975c85ec9bba806b7c62eb9b23cf2ad3b41813862ee24caf00e"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fc633deedd3eba5c266bf12959d8c4173806d252db45d2c78d3bd9a874dc7ccf"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:2c0b19a83f519de8f2cb170bc7879b1d92ac342a75798722fbfe29965f21d5ad"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:af19d6a2ab49f02cf699bc1dfb6f4bdcc3588e077c7b5319d73335b81fef93fd"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a406800c29e568e61ab687540391ac8eab210b79d24d7eb41b75b6117882e269"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9bc38055f5a26bec15ca81b0465f90cb2663a31c19948d6421dc02090b4cbd4a"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7080a20f64293b1f81747748e6bda0d1e9a9d5d5f94cd1b0c625cfdf819f491f"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:39fdfd4fa6371ebabf9e7be858ae68d4fa9a9fd72fd5fc3d739fdaa99997fd9a"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a69263f22a8c53c7d55067e7795ed49d22c374b9473df91982816a0448e6d242"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0945c7f529e66a18aa19135aa18bfdc239ca6c0f6df6ca9b05a793d9b76c1c4b"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:321037a64c02acb13b5bde193b461c0514dca236a9f5236c847a3240313c297f"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d7a71ca8fd42d3eefa22eb202e7fc8a419e0fd4e3b59862918c21f4d1074f0c5"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:37dddb1b92829b8862bc4fbce07789a79b73aa31eca413f3db187721a09975ff"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0af90b3c566786017a01693da0ec4a876ca14cf37bc6164872652a6cf2702453"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f25d05604dd92e9de72707582dded53aeb4737ef2e2c097a3ca08650200fc446"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:74ddf5ec7fa6058221a619c850f44bdbe8d969d7ed6efe8abdc857d2e233df20"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f8d1d00397f3f32a1bcf4604d4164bf75838bd009e1e28282a7ae25e16814ea4"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1fd035e2c47c4db5fe9b0f59a28fe2f2f1012887290dd0ea7d46741f686e99"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d448537147a33dd8c17656732989ddfe1d4a25a40bcb5f59bc63dc0a5041dd83"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:1325585f9fc283c32c30df4226178dc89cf43d00f5c240e1f77ddedc94573690"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:266bbea18ce99f6e77ce0e9a118f353447c8705792ef5745eabcc5c6db08794a"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7a373a1a3dd8e02dd218946583e951791480921fd777faeaf601c2834e2a6c0d"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:da956b2141d9f425dbfc3cf1c244ef6d0633b83fc5ceada6f496099258e63a68"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:fa354e9fb95e26545decd429477072ea3a98a2e7acac11d09156772e06f14680"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:e256dc3a993bf5fde0fa4a1b6a5b72e3521cedbc9dc4d7a65f159afa4b72e5b4"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eb7988038b7f13ea490af93b9165ed40a3f385fb91e99ce8dacee8890e36e48a"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cfd8f011b0b0a109f8747312dba6cee45b39fb53fc7d53f28813bce84a91228d"}, + {file = "confluent_kafka-2.13.0.tar.gz", hash = "sha256:eff7a4391a9e6d4a33f0c05d0935b200a7463834f1f5d6e6253be318f910babd"}, ] [[package]] name = "confluent-kafka" -version = "2.11.0" +version = "2.13.0" extras = ["protobuf", "schemaregistry"] -requires_python = ">=3.7" +requires_python = ">=3.8" summary = "Confluent's Python client for Apache Kafka" groups = ["dev-consumers"] dependencies = [ - "attrs", - "attrs", + "attrs>=21.2.0", + "attrs>=21.2.0", "authlib>=1.0.0", "authlib>=1.0.0", - "cachetools", - "cachetools", - "confluent-kafka==2.11.0", + "cachetools>=5.5.0", + "cachetools>=5.5.0", + "certifi", + "certifi", + "confluent-kafka==2.13.0", "googleapis-common-protos", "httpx>=0.26", "httpx>=0.26", "protobuf", ] files = [ - {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae672723577775aba560da34e376ffa4a038ff3e07c8513920b81903f8f9c4e8"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc8346a55c4b3f4e4ed938243997e3223e0f00c93c48d52a86343943c0316a6c"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5dccc94019fbca33bcc49d993641b7145c448255206480a38eee452869653b33"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a13398b5b6e025e710b3123c17db8dbc4db78c708916bb602211257fcd7f27c8"}, - {file = "confluent_kafka-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:08e0b0b7fb21ddd83801231f5f32b7e7ba4f7c9a7dee9034a1e36e6b6479143a"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f54f077904cc8541be03e16bee9bf37207c96e6bbd3c5b3d1b5fbd878e40580"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:455a368d35dc4444d4d8f442b5715e3b43310b60bb0f99837556830bd32defdf"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:707d7a39755986605402dd308013e6a142b8a5105d51f863b17f4bc283f4ac85"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:655b5d3a90955dd4ee15e59d89f33b5eaae14e7122e9fecb93b4c03ef426ec97"}, - {file = "confluent_kafka-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:6dc952c45108351b3995b85e23d99c2251a5160e3ed3d9a7768300d8c97eca56"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7ab9b40866d783cd410019b0380b0db815ac36ecf07be2be80a429e41d7caf98"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:70682b25b5ce835de54b6eb803a75e550a953e494fe348960ed8c534cb54ea50"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:80edbdf59d312772a4c5646807cb2a0b5f5a2c7f945df070aac1782a7790f432"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1ac35a551c8df8313695bff3e81dd8ef3d9041b0edc8827883ef8015819bc59c"}, - {file = "confluent_kafka-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:be6273ae93a9076c28ccda39dacae98405af696c97bab02a8a78abd02fe3add4"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:e58b263d8e02c54396e7159d2d6668c9e6f2e7faa06c40026df205ff81b6188e"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a29ac34c7dd3ab51c2e7cc16c37baed13369a487d7f953ff4e3a8fff08642969"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2552147d0929db95ace291e460c652981721e0e437a074445fbafdcee8b6817c"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ed0981e686bdc4ee86034113a750242fe1b405826bf4a553498bf558429188af"}, - {file = "confluent_kafka-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6d6ec6e791b1c97668ff0823dff1dc005ab418b353a430fede7b5cd6c5490497"}, - {file = "confluent_kafka-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:9f44a476241a81e6b3259d3f3f00aa0e94ed64a924131f83cb3b640ecdfcd633"}, - {file = "confluent_kafka-2.11.0.tar.gz", hash = "sha256:d95512838eebd42a4657ce891dfa32bcbc06906e3391b328de24f7731b0c2755"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:264f86956d3bbd26bc14fd2d4060908a0c53d26b14ff33d21fa83369f55d8d3b"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4af5f129acce30110c7a1c9c05c54821c1283850e38c67715bed7c3c8df322c6"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b975664e821d975c85ec9bba806b7c62eb9b23cf2ad3b41813862ee24caf00e"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fc633deedd3eba5c266bf12959d8c4173806d252db45d2c78d3bd9a874dc7ccf"}, + {file = "confluent_kafka-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:2c0b19a83f519de8f2cb170bc7879b1d92ac342a75798722fbfe29965f21d5ad"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:af19d6a2ab49f02cf699bc1dfb6f4bdcc3588e077c7b5319d73335b81fef93fd"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a406800c29e568e61ab687540391ac8eab210b79d24d7eb41b75b6117882e269"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9bc38055f5a26bec15ca81b0465f90cb2663a31c19948d6421dc02090b4cbd4a"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7080a20f64293b1f81747748e6bda0d1e9a9d5d5f94cd1b0c625cfdf819f491f"}, + {file = "confluent_kafka-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:39fdfd4fa6371ebabf9e7be858ae68d4fa9a9fd72fd5fc3d739fdaa99997fd9a"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a69263f22a8c53c7d55067e7795ed49d22c374b9473df91982816a0448e6d242"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0945c7f529e66a18aa19135aa18bfdc239ca6c0f6df6ca9b05a793d9b76c1c4b"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:321037a64c02acb13b5bde193b461c0514dca236a9f5236c847a3240313c297f"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d7a71ca8fd42d3eefa22eb202e7fc8a419e0fd4e3b59862918c21f4d1074f0c5"}, + {file = "confluent_kafka-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:37dddb1b92829b8862bc4fbce07789a79b73aa31eca413f3db187721a09975ff"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0af90b3c566786017a01693da0ec4a876ca14cf37bc6164872652a6cf2702453"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f25d05604dd92e9de72707582dded53aeb4737ef2e2c097a3ca08650200fc446"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:74ddf5ec7fa6058221a619c850f44bdbe8d969d7ed6efe8abdc857d2e233df20"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f8d1d00397f3f32a1bcf4604d4164bf75838bd009e1e28282a7ae25e16814ea4"}, + {file = "confluent_kafka-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1fd035e2c47c4db5fe9b0f59a28fe2f2f1012887290dd0ea7d46741f686e99"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d448537147a33dd8c17656732989ddfe1d4a25a40bcb5f59bc63dc0a5041dd83"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:1325585f9fc283c32c30df4226178dc89cf43d00f5c240e1f77ddedc94573690"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:266bbea18ce99f6e77ce0e9a118f353447c8705792ef5745eabcc5c6db08794a"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7a373a1a3dd8e02dd218946583e951791480921fd777faeaf601c2834e2a6c0d"}, + {file = "confluent_kafka-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:da956b2141d9f425dbfc3cf1c244ef6d0633b83fc5ceada6f496099258e63a68"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:fa354e9fb95e26545decd429477072ea3a98a2e7acac11d09156772e06f14680"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:e256dc3a993bf5fde0fa4a1b6a5b72e3521cedbc9dc4d7a65f159afa4b72e5b4"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eb7988038b7f13ea490af93b9165ed40a3f385fb91e99ce8dacee8890e36e48a"}, + {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cfd8f011b0b0a109f8747312dba6cee45b39fb53fc7d53f28813bce84a91228d"}, + {file = "confluent_kafka-2.13.0.tar.gz", hash = "sha256:eff7a4391a9e6d4a33f0c05d0935b200a7463834f1f5d6e6253be318f910babd"}, ] [[package]] name = "coverage" -version = "7.9.2" -requires_python = ">=3.9" +version = "7.13.1" +requires_python = ">=3.10" summary = "Code coverage measurement for Python" -groups = ["tests", "unit-tests"] +groups = ["unit-tests"] files = [ - {file = "coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912"}, - {file = "coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e"}, - {file = "coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e"}, - {file = "coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c"}, - {file = "coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba"}, - {file = "coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74"}, - {file = "coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6"}, - {file = "coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7"}, - {file = "coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62"}, - {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, - {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, - {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, - {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, - {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, - {file = "coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038"}, - {file = "coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868"}, - {file = "coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a"}, - {file = "coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b"}, - {file = "coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694"}, - {file = "coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5"}, - {file = "coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac"}, - {file = "coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926"}, - {file = "coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd"}, - {file = "coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb"}, - {file = "coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050"}, - {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, - {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, + {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, + {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, + {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, + {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, + {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, + {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, + {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, + {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, + {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, + {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, + {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, + {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, + {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, + {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, + {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, + {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, + {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, + {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, + {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, + {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, + {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, + {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, ] [[package]] name = "coverage" -version = "7.9.2" +version = "7.13.1" extras = ["toml"] -requires_python = ">=3.9" +requires_python = ">=3.10" summary = "Code coverage measurement for Python" -groups = ["tests", "unit-tests"] +groups = ["unit-tests"] dependencies = [ - "coverage==7.9.2", + "coverage==7.13.1", "tomli; python_full_version <= \"3.11.0a6\"", ] files = [ - {file = "coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912"}, - {file = "coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf"}, - {file = "coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2"}, - {file = "coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e"}, - {file = "coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e"}, - {file = "coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c"}, - {file = "coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba"}, - {file = "coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:326802760da234baf9f2f85a39e4a4b5861b94f6c8d95251f699e4f73b1835dc"}, - {file = "coverage-7.9.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19e7be4cfec248df38ce40968c95d3952fbffd57b400d4b9bb580f28179556d2"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b4a4cb73b9f2b891c1788711408ef9707666501ba23684387277ededab1097c"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2c8937fa16c8c9fbbd9f118588756e7bcdc7e16a470766a9aef912dd3f117dbd"}, - {file = "coverage-7.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42da2280c4d30c57a9b578bafd1d4494fa6c056d4c419d9689e66d775539be74"}, - {file = "coverage-7.9.2-cp311-cp311-win32.whl", hash = "sha256:14fa8d3da147f5fdf9d298cacc18791818f3f1a9f542c8958b80c228320e90c6"}, - {file = "coverage-7.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:549cab4892fc82004f9739963163fd3aac7a7b0df430669b75b86d293d2df2a7"}, - {file = "coverage-7.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2667a2b913e307f06aa4e5677f01a9746cd08e4b35e14ebcde6420a9ebb4c62"}, - {file = "coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0"}, - {file = "coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615"}, - {file = "coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f"}, - {file = "coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d"}, - {file = "coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355"}, - {file = "coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0"}, - {file = "coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b"}, - {file = "coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038"}, - {file = "coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14"}, - {file = "coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d"}, - {file = "coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868"}, - {file = "coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a"}, - {file = "coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b"}, - {file = "coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694"}, - {file = "coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5"}, - {file = "coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8"}, - {file = "coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e"}, - {file = "coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac"}, - {file = "coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926"}, - {file = "coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd"}, - {file = "coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb"}, - {file = "coverage-7.9.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:8a1166db2fb62473285bcb092f586e081e92656c7dfa8e9f62b4d39d7e6b5050"}, - {file = "coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4"}, - {file = "coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, + {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, + {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, + {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, + {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, + {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, + {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, + {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, + {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, + {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, + {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, + {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, + {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, + {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, + {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, + {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, + {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, + {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, + {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, + {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, + {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, + {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, + {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, + {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, + {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, + {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, + {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, + {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, + {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, + {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, + {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, + {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, + {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, + {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, + {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, + {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, + {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, + {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, + {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, + {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, + {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, + {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, + {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, + {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, ] [[package]] @@ -896,51 +1039,70 @@ files = [ [[package]] name = "cryptography" -version = "45.0.5" -requires_python = "!=3.9.0,!=3.9.1,>=3.7" +version = "46.0.3" +requires_python = "!=3.9.0,!=3.9.1,>=3.8" summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -groups = ["azure-queue", "azure-servicebus", "dev-consumers", "tests"] -dependencies = [ - "cffi>=1.14; platform_python_implementation != \"PyPy\"", -] -files = [ - {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"}, - {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"}, - {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"}, - {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"}, - {file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"}, - {file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"}, - {file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"}, - {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"}, - {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"}, - {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"}, - {file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"}, - {file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"}, - {file = "cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd"}, - {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097"}, - {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e"}, - {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30"}, - {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e"}, - {file = "cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d"}, - {file = "cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e"}, - {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6"}, - {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18"}, - {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463"}, - {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1"}, - {file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"}, - {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, +groups = ["azure-queue", "azure-servicebus", "dev-consumers"] +dependencies = [ + "cffi>=1.14; python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"", + "cffi>=2.0.0; python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", + "typing-extensions>=4.13.2; python_full_version < \"3.11\"", +] +files = [ + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, ] [[package]] @@ -961,38 +1123,39 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" groups = ["default", "dev-consumers", "dev-hosting-http", "examples", "rabbitmq", "tests", "unit-tests"] +marker = "python_version < \"3.11\"" dependencies = [ "typing-extensions>=4.6.0; python_version < \"3.13\"", ] files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [[package]] name = "execnet" -version = "2.1.1" +version = "2.1.2" requires_python = ">=3.8" summary = "execnet: rapid multi-Python deployment" groups = ["tests"] files = [ - {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, - {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, ] [[package]] name = "executing" -version = "2.2.0" +version = "2.2.1" requires_python = ">=3.8" summary = "Get the currently executing AST node of a frame, and other information" groups = ["dev"] files = [ - {file = "executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa"}, - {file = "executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755"}, + {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, + {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, ] [[package]] @@ -1013,192 +1176,251 @@ files = [ [[package]] name = "fastapi-slim" -version = "0.116.1" -requires_python = ">=3.8" +version = "0.128.0" +requires_python = ">=3.9" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" groups = ["examples"] dependencies = [ - "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", - "starlette<0.48.0,>=0.40.0", + "annotated-doc>=0.0.2", + "pydantic>=2.7.0", + "starlette<0.51.0,>=0.40.0", "typing-extensions>=4.8.0", ] files = [ - {file = "fastapi_slim-0.116.1-py3-none-any.whl", hash = "sha256:37517a302492c30014979cff8d85f42ae5fbdbe4f8b5d0ceed81745bb3b23149"}, - {file = "fastapi_slim-0.116.1.tar.gz", hash = "sha256:5dca6046d3a3e35eb733188649a9f883c9d49474b9b3255ecb8ec52a820fbb9f"}, -] - -[[package]] -name = "forbiddenfruit" -version = "0.1.4" -summary = "Patch python built-in objects" -groups = ["tests"] -marker = "implementation_name == \"cpython\"" -files = [ - {file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"}, + {file = "fastapi_slim-0.128.0-py3-none-any.whl", hash = "sha256:19034e3e48503573fe429d94906d4b5180aa7c754f9148a55ab1981abdb73293"}, + {file = "fastapi_slim-0.128.0.tar.gz", hash = "sha256:7916e28ec3bd897ea4adff9e8d257ff6edba0c1f383640896d54099f78a8afef"}, ] [[package]] name = "frozenlist" -version = "1.7.0" +version = "1.8.0" requires_python = ">=3.9" summary = "A list-like structure which implements collections.abc.MutableSequence" groups = ["dev-consumers"] files = [ - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, - {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, - {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, - {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, - {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, - {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, - {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, - {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, - {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, - {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, - {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, - {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, - {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, - {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, - {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, - {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, - {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, - {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, - {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, - {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, - {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, - {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, - {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, - {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, - {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, - {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, - {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] [[package]] name = "google-api-core" -version = "2.25.1" +version = "2.29.0" requires_python = ">=3.7" summary = "Google API client core library" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "google-auth<3.0.0,>=2.14.1", "googleapis-common-protos<2.0.0,>=1.56.2", + "importlib-metadata>=1.4; python_version < \"3.8\"", "proto-plus<2.0.0,>=1.22.3", "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.19.5", "requests<3.0.0,>=2.18.0", ] files = [ - {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, - {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, + {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, + {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, ] [[package]] name = "google-api-core" -version = "2.25.1" +version = "2.29.0" extras = ["grpc"] requires_python = ">=3.7" summary = "Google API client core library" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ - "google-api-core==2.25.1", + "google-api-core==2.29.0", "grpcio-status<2.0.0,>=1.33.2", "grpcio-status<2.0.0,>=1.49.1; python_version >= \"3.11\"", + "grpcio-status<2.0.0,>=1.75.1; python_version >= \"3.14\"", "grpcio<2.0.0,>=1.33.2", "grpcio<2.0.0,>=1.49.1; python_version >= \"3.11\"", + "grpcio<2.0.0,>=1.75.1; python_version >= \"3.14\"", ] files = [ - {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, - {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, + {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, + {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, ] [[package]] name = "google-auth" -version = "2.40.3" -requires_python = ">=3.7" +version = "2.47.0" +requires_python = ">=3.8" summary = "Google Authentication Library" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ - "cachetools<6.0,>=2.0.0", "pyasn1-modules>=0.2.1", "rsa<5,>=3.1.4", ] files = [ - {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, - {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, + {file = "google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498"}, + {file = "google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da"}, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +requires_python = ">=3.7" +summary = "Google Cloud API client core library" +groups = ["integration-tests"] +dependencies = [ + "google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0,>=1.31.6", + "google-auth<3.0.0,>=1.25.0", + "importlib-metadata>1.0.0; python_version < \"3.8\"", +] +files = [ + {file = "google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc"}, + {file = "google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963"}, +] + +[[package]] +name = "google-cloud-datastore" +version = "2.23.0" +requires_python = ">=3.7" +summary = "Google Cloud Datastore API client library" +groups = ["integration-tests"] +dependencies = [ + "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0", + "google-auth!=2.24.0,!=2.25.0,<3.0.0,>=2.14.1", + "google-cloud-core<3.0.0,>=1.4.0", + "grpcio<2.0.0,>=1.38.0", + "grpcio<2.0.0,>=1.75.1; python_version >= \"3.14\"", + "proto-plus<2.0.0,>=1.22.0", + "proto-plus<2.0.0,>=1.22.2; python_version >= \"3.11\"", + "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", + "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", +] +files = [ + {file = "google_cloud_datastore-2.23.0-py3-none-any.whl", hash = "sha256:24a1b1d29b902148fe41b109699f76fd3aa60591e9d547c0f8b87d7bf9ff213f"}, + {file = "google_cloud_datastore-2.23.0.tar.gz", hash = "sha256:80049883a4ae928fdcc661ba6803ec267665dc0e6f3ce2da91441079a6bb6387"}, ] [[package]] name = "google-cloud-pubsub" -version = "2.31.0" +version = "2.34.0" requires_python = ">=3.7" summary = "Google Cloud Pub/Sub API client library" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0", "google-auth<3.0.0,>=2.14.1", "grpc-google-iam-v1<1.0.0,>=0.12.4", "grpcio-status>=1.33.2", - "grpcio<2.0.0,>=1.51.3", + "grpcio<2.0.0,>=1.51.3; python_version < \"3.14\"", + "grpcio<2.0.0,>=1.75.1; python_version >= \"3.14\"", "opentelemetry-api<=1.22.0; python_version <= \"3.7\"", "opentelemetry-api>=1.27.0; python_version >= \"3.8\"", "opentelemetry-sdk<=1.22.0; python_version <= \"3.7\"", @@ -1209,175 +1431,198 @@ dependencies = [ "protobuf!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", ] files = [ - {file = "google_cloud_pubsub-2.31.0-py3-none-any.whl", hash = "sha256:2dbae4ef079403615856868f8bae20550eb25c0f89396a185ce2c72b029fb060"}, - {file = "google_cloud_pubsub-2.31.0.tar.gz", hash = "sha256:d190cae8f18039d7d7301d3511d3a15cbe1df3d4fc89b871807b86f66349aaea"}, + {file = "google_cloud_pubsub-2.34.0-py3-none-any.whl", hash = "sha256:aa11b2471c6d509058b42a103ed1b3643f01048311a34fd38501a16663267206"}, + {file = "google_cloud_pubsub-2.34.0.tar.gz", hash = "sha256:25f98c3ba16a69871f9ebbad7aece3fe63c8afe7ba392aad2094be730d545976"}, ] [[package]] name = "googleapis-common-protos" -version = "1.70.0" +version = "1.72.0" requires_python = ">=3.7" summary = "Common protobufs used in Google APIs" -groups = ["dev-consumers", "dev-otel", "pubsub"] +groups = ["dev-consumers", "dev-otel", "integration-tests", "pubsub"] dependencies = [ "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", ] files = [ - {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, - {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, + {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, + {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, ] [[package]] name = "googleapis-common-protos" -version = "1.70.0" +version = "1.72.0" extras = ["grpc"] requires_python = ">=3.7" summary = "Common protobufs used in Google APIs" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ - "googleapis-common-protos==1.70.0", + "googleapis-common-protos==1.72.0", "grpcio<2.0.0,>=1.44.0", ] files = [ - {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, - {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, + {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, + {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, ] [[package]] name = "grpc-google-iam-v1" -version = "0.14.2" +version = "0.14.3" requires_python = ">=3.7" summary = "IAM API client library" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "googleapis-common-protos[grpc]<2.0.0,>=1.56.0", "grpcio<2.0.0,>=1.44.0", "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", ] files = [ - {file = "grpc_google_iam_v1-0.14.2-py3-none-any.whl", hash = "sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351"}, - {file = "grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20"}, + {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"}, + {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"}, ] [[package]] name = "grpcio" -version = "1.73.1" +version = "1.76.0" requires_python = ">=3.9" summary = "HTTP/2-based RPC framework" -groups = ["dev-consumers", "dev-hosting-grpc", "dev-otel", "pubsub"] -files = [ - {file = "grpcio-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:2d70f4ddd0a823436c2624640570ed6097e40935c9194482475fe8e3d9754d55"}, - {file = "grpcio-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3841a8a5a66830261ab6a3c2a3dc539ed84e4ab019165f77b3eeb9f0ba621f26"}, - {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:628c30f8e77e0258ab788750ec92059fc3d6628590fb4b7cea8c102503623ed7"}, - {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67a0468256c9db6d5ecb1fde4bf409d016f42cef649323f0a08a72f352d1358b"}, - {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b84d65bbdebd5926eb5c53b0b9ec3b3f83408a30e4c20c373c5337b4219ec5"}, - {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c54796ca22b8349cc594d18b01099e39f2b7ffb586ad83217655781a350ce4da"}, - {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:75fc8e543962ece2f7ecd32ada2d44c0c8570ae73ec92869f9af8b944863116d"}, - {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6a6037891cd2b1dd1406b388660522e1565ed340b1fea2955b0234bdd941a862"}, - {file = "grpcio-1.73.1-cp310-cp310-win32.whl", hash = "sha256:cce7265b9617168c2d08ae570fcc2af4eaf72e84f8c710ca657cc546115263af"}, - {file = "grpcio-1.73.1-cp310-cp310-win_amd64.whl", hash = "sha256:6a2b372e65fad38842050943f42ce8fee00c6f2e8ea4f7754ba7478d26a356ee"}, - {file = "grpcio-1.73.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:ba2cea9f7ae4bc21f42015f0ec98f69ae4179848ad744b210e7685112fa507a1"}, - {file = "grpcio-1.73.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d74c3f4f37b79e746271aa6cdb3a1d7e4432aea38735542b23adcabaaee0c097"}, - {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5b9b1805a7d61c9e90541cbe8dfe0a593dfc8c5c3a43fe623701b6a01b01d710"}, - {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3215f69a0670a8cfa2ab53236d9e8026bfb7ead5d4baabe7d7dc11d30fda967"}, - {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc5eccfd9577a5dc7d5612b2ba90cca4ad14c6d949216c68585fdec9848befb1"}, - {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc7d7fd520614fce2e6455ba89791458020a39716951c7c07694f9dbae28e9c0"}, - {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:105492124828911f85127e4825d1c1234b032cb9d238567876b5515d01151379"}, - {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:610e19b04f452ba6f402ac9aa94eb3d21fbc94553368008af634812c4a85a99e"}, - {file = "grpcio-1.73.1-cp311-cp311-win32.whl", hash = "sha256:d60588ab6ba0ac753761ee0e5b30a29398306401bfbceffe7d68ebb21193f9d4"}, - {file = "grpcio-1.73.1-cp311-cp311-win_amd64.whl", hash = "sha256:6957025a4608bb0a5ff42abd75bfbb2ed99eda29d5992ef31d691ab54b753643"}, - {file = "grpcio-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf"}, - {file = "grpcio-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:277b426a0ed341e8447fbf6c1d6b68c952adddf585ea4685aa563de0f03df887"}, - {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:96c112333309493c10e118d92f04594f9055774757f5d101b39f8150f8c25582"}, - {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f48e862aed925ae987eb7084409a80985de75243389dc9d9c271dd711e589918"}, - {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83a6c2cce218e28f5040429835fa34a29319071079e3169f9543c3fbeff166d2"}, - {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:65b0458a10b100d815a8426b1442bd17001fdb77ea13665b2f7dc9e8587fdc6b"}, - {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0a9f3ea8dce9eae9d7cb36827200133a72b37a63896e0e61a9d5ec7d61a59ab1"}, - {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de18769aea47f18e782bf6819a37c1c528914bfd5683b8782b9da356506190c8"}, - {file = "grpcio-1.73.1-cp312-cp312-win32.whl", hash = "sha256:24e06a5319e33041e322d32c62b1e728f18ab8c9dbc91729a3d9f9e3ed336642"}, - {file = "grpcio-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:303c8135d8ab176f8038c14cc10d698ae1db9c480f2b2823f7a987aa2a4c5646"}, - {file = "grpcio-1.73.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b310824ab5092cf74750ebd8a8a8981c1810cb2b363210e70d06ef37ad80d4f9"}, - {file = "grpcio-1.73.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:8f5a6df3fba31a3485096ac85b2e34b9666ffb0590df0cd044f58694e6a1f6b5"}, - {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:052e28fe9c41357da42250a91926a3e2f74c046575c070b69659467ca5aa976b"}, - {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c0bf15f629b1497436596b1cbddddfa3234273490229ca29561209778ebe182"}, - {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab860d5bfa788c5a021fba264802e2593688cd965d1374d31d2b1a34cacd854"}, - {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ad1d958c31cc91ab050bd8a91355480b8e0683e21176522bacea225ce51163f2"}, - {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f43ffb3bd415c57224c7427bfb9e6c46a0b6e998754bfa0d00f408e1873dcbb5"}, - {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:686231cdd03a8a8055f798b2b54b19428cdf18fa1549bee92249b43607c42668"}, - {file = "grpcio-1.73.1-cp313-cp313-win32.whl", hash = "sha256:89018866a096e2ce21e05eabed1567479713ebe57b1db7cbb0f1e3b896793ba4"}, - {file = "grpcio-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:4a68f8c9966b94dff693670a5cf2b54888a48a5011c5d9ce2295a1a1465ee84f"}, - {file = "grpcio-1.73.1.tar.gz", hash = "sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87"}, +groups = ["dev-consumers", "dev-hosting-grpc", "dev-otel", "integration-tests", "pubsub"] +dependencies = [ + "typing-extensions~=4.12", +] +files = [ + {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, + {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"}, + {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"}, + {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"}, + {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"}, + {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"}, + {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"}, + {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"}, + {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"}, + {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"}, + {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"}, + {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"}, + {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"}, + {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"}, + {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"}, + {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"}, + {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"}, + {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"}, + {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"}, + {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"}, + {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"}, + {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"}, + {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"}, + {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"}, + {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"}, + {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"}, + {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"}, + {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"}, + {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"}, + {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"}, + {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"}, ] [[package]] name = "grpcio-status" -version = "1.73.1" +version = "1.76.0" requires_python = ">=3.9" summary = "Status proto mapping for gRPC" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "googleapis-common-protos>=1.5.5", - "grpcio>=1.73.1", - "protobuf<7.0.0,>=6.30.0", + "grpcio>=1.76.0", + "protobuf<7.0.0,>=6.31.1", ] files = [ - {file = "grpcio_status-1.73.1-py3-none-any.whl", hash = "sha256:538595c32a6c819c32b46a621a51e9ae4ffcd7e7e1bce35f728ef3447e9809b6"}, - {file = "grpcio_status-1.73.1.tar.gz", hash = "sha256:928f49ccf9688db5f20cd9e45c4578a1d01ccca29aeaabf066f2ac76aa886668"}, + {file = "grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18"}, + {file = "grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd"}, ] [[package]] name = "grpcio-tools" -version = "1.73.1" +version = "1.76.0" requires_python = ">=3.9" summary = "Protobuf code generator for gRPC" groups = ["dev-consumers"] dependencies = [ - "grpcio>=1.73.1", - "protobuf<7.0.0,>=6.30.0", + "grpcio>=1.76.0", + "protobuf<7.0.0,>=6.31.1", "setuptools", ] files = [ - {file = "grpcio_tools-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:0731b21a7f3988a9f8c244ffe3940a0579e5b5f2a99d08448459e0b49350d47a"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:15e47b19b70ce6100e9843570e16b0561045c37b5e9d390f1cb54292c99b51b6"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:fbe6cd3e863928b5c127d4956c60e44101f495ddcb69738290db6ef497ce505c"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71c35a6b4d125bec877daefaf7dedb566d37ed4e903a45b74e491683e006afa4"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9486235da85a80accaeaab5829c09e19b70ee96ff100d3f7b342ec9344d96134"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:86dfd562a9ebb74849aca345237cf87c2732067e410752ff809b8bfdf7aa5f49"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bde8101bc7a60de6297916a468bf7900be6e2c0f9965a1a6591aa06bd02e2df3"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b41e2417003b34bf672aa4ec5a48e22d9fc28f7de5f25d9a01fb1e3dcc86af6a"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-win32.whl", hash = "sha256:bb05d02ca7d603260555cc0bbec616b116f741561d5b5c78a65bc3fae5982d5e"}, - {file = "grpcio_tools-1.73.1-cp310-cp310-win_amd64.whl", hash = "sha256:5f0cd287545c9430e3e395181ee11ca9b7bef4c41b1c28afa9174ea5a868dcda"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:2bc4a46177df43853b070a4b6b6106d9829a639bc8b9516a005a879d3e5da0f9"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:448b38ec72ed62c932d48e49e0facbb51e8045065dabf3bb63149810971a51e7"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:249be7616b323b8af72a02bd218bfbba388010e6ccb471c57d42e49b620686f7"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:129dd1e46386da74d78d588c5a7f5c51d8dc2ec40fea95f9a012f655767fd5f3"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d53cd62c30fbd9597c05066a45e5750a11ec566c5fe0d17878e169bd2c66157"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d8f4e2ee735dc6033b6f3379584759cc95259581c9bc58db5dd0e69cc71310f1"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b2795d85da34846bbe6693f0da4c1f9bfa8a77e6cbd85cb83eb1231f1315a2ae"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:40834bf3551a6936151f4ba7eba4a37c8ff66eacb0bb5affa4630afbe78f061d"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-win32.whl", hash = "sha256:0b809d936463fabeda6999eb681b5d4869dd8e5fe4d2fb9dbfee0d2a4c0ce67a"}, - {file = "grpcio_tools-1.73.1-cp311-cp311-win_amd64.whl", hash = "sha256:e0257401088f29315fe0ad7f388ad061cf5b57a89e9abf039f8d9c7a54e9580d"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:23af3de80c89ee803d0143c6293f8e979a851f0414702b5de973e205fca69db2"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fbf17ad6fff6c9002d28bc3632216eafb59631308b05c4cf80e72d21c33f7dc9"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:aa05ecd0b2ca583862d107eea4d9480a2d89987ae46bc02944cc450a122f833b"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664fbec02f93d14bb74c442e06ba20a4448a20236ed3d6ac5d2c4ef82a33a8b4"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60aa528d7576c932f43172723188f53aaa7bdb307e52d22f2e5ab831a3667693"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3893517010bcdb8cb4949715786bc5953fe7df6b575f8b1725531ed492b1080c"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:654083a2d4e83679d4fb6ac46a2748c1b57ecf45be5cbe88d88a1a4aef6b3281"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cd8b1077849acd69923b08a8e5221050e5201eb65907487ee6d69aa06b583b46"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-win32.whl", hash = "sha256:0273f64c8db4a52a3a99fd34c83a1a1723bd3ac6806d3054a93f08044609fa65"}, - {file = "grpcio_tools-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:0626266b0df489d6e8bdd4178b1c78cac9963fde4c5ba6b205b329e46696b334"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:4687200e18d316ef1e28824bf9c62c2c6a3a2aacf4e0d292306258ef05955587"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b86daf2dba207aace486cf5752c9c5d35864cd67f1df1429f7341af3984dadc7"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:44ec622d210740ed6c4300ea51fc43faaa0c58f4e2c4b03e2fe5a452b8e440ce"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ceac9073030012596cb7923643552329e48fb911eea3225624b8ef34bae92e1"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43329b47e982798c9eb18f652fc10fef5d22f9df51001a10f1ece01d9d203c04"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f0afb000ff67f7665f3eedd797c23b403a6bdae829d6e610944aeb6959193698"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:40c5def66b69c8c8f8a6af8d47a5e6b5825055c7ef7cf2b1b58c57ddc86bf3be"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9a0eb7d88c9f1992afb3021e45721971e49d612b03fa5d38cce6e4369509f32c"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-win32.whl", hash = "sha256:34480c6964d16b7fa0012b9fc574efa9e2f71c80f8bc9da0887c300abb7130f3"}, - {file = "grpcio_tools-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:2b005373ad23dc0f25d8d6ec6d219fc3a6831b8d0f487be8f9bb2315307ba1c1"}, - {file = "grpcio_tools-1.73.1.tar.gz", hash = "sha256:6e06adec3b0870f5947953b0ef8dbdf2cebcdff61fb1fe08120cc7483c7978aa"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:9b99086080ca394f1da9894ee20dedf7292dd614e985dcba58209a86a42de602"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d95b5c2394bbbe911cbfc88d15e24c9e174958cb44dad6aa8c46fe367f6cc2a"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d54e9ce2ffc5d01341f0c8898c1471d887ae93d77451884797776e0a505bd503"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c83f39f64c2531336bd8d5c846a2159c9ea6635508b0f8ed3ad0d433e25b53c9"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be480142fae0d986d127d6cb5cbc0357e4124ba22e96bb8b9ece32c48bc2c8ea"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7fefd41fc4ca11fab36f42bdf0f3812252988f8798fca8bec8eae049418deacd"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:63551f371082173e259e7f6ec24b5f1fe7d66040fadd975c966647bca605a2d3"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75a2c34584c99ff47e5bb267866e7dec68d30cd3b2158e1ee495bfd6db5ad4f0"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-win32.whl", hash = "sha256:908758789b0a612102c88e8055b7191eb2c4290d5d6fc50fb9cac737f8011ef1"}, + {file = "grpcio_tools-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:ec6e49e7c4b2a222eb26d1e1726a07a572b6e629b2cf37e6bb784c9687904a52"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:c6480f6af6833850a85cca1c6b435ef4ffd2ac8e88ef683b4065233827950243"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c7c23fe1dc09818e16a48853477806ad77dd628b33996f78c05a293065f8210c"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fcdce7f7770ff052cd4e60161764b0b3498c909bde69138f8bd2e7b24a3ecd8f"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b598fdcebffa931c7da5c9e90b5805fff7e9bc6cf238319358a1b85704c57d33"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6a9818ff884796b12dcf8db32126e40ec1098cacf5697f27af9cfccfca1c1fae"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:105e53435b2eed3961da543db44a2a34479d98d18ea248219856f30a0ca4646b"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:454a1232c7f99410d92fa9923c7851fd4cdaf657ee194eac73ea1fe21b406d6e"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca9ccf667afc0268d45ab202af4556c72e57ea36ebddc93535e1a25cbd4f8aba"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-win32.whl", hash = "sha256:a83c87513b708228b4cad7619311daba65b40937745103cadca3db94a6472d9c"}, + {file = "grpcio_tools-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ce5e87ec71f2e4041dce4351f2a8e3b713e3bca6b54c69c3fbc6c7ad1f4c386"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3dafa34c2626a6691d103877e8a145f54c34cf6530975f695b396ed2fc5c98f8"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30f1d2dda6ece285b3d9084e94f66fa721ebdba14ae76b2bc4c581c8a166535c"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a889af059dc6dbb82d7b417aa581601316e364fe12eb54c1b8d95311ea50916d"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3f2c3c44c56eb5d479ab178f0174595d0a974c37dade442f05bb73dfec02f31"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:479ce02dff684046f909a487d452a83a96b4231f7c70a3b218a075d54e951f56"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-win32.whl", hash = "sha256:9ba4bb539936642a44418b38ee6c3e8823c037699e2cb282bd8a44d76a4be833"}, + {file = "grpcio_tools-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd489016766b05f9ed8a6b6596004b62c57d323f49593eac84add032a6d43f7"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ff48969f81858397ef33a36b326f2dbe2053a48b254593785707845db73c8f44"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa2f030fd0ef17926026ee8e2b700e388d3439155d145c568fa6b32693277613"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bacbf3c54f88c38de8e28f8d9b97c90b76b105fb9ddef05d2c50df01b32b92af"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d4e4afe9a0e3c24fad2f1af45f98cf8700b2bfc4d790795756ba035d2ea7bdc"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbbd4e1fc5af98001ceef5e780e8c10921d94941c3809238081e73818ef707f1"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b05efe5a59883ab8292d596657273a60e0c3e4f5a9723c32feb9fc3a06f2f3ef"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:be483b90e62b7892eb71fa1fc49750bee5b2ee35b5ec99dd2b32bed4bedb5d71"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:630cd7fd3e8a63e20703a7ad816979073c2253e591b5422583c27cae2570de73"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-win32.whl", hash = "sha256:eb2567280f9f6da5444043f0e84d8408c7a10df9ba3201026b30e40ef3814736"}, + {file = "grpcio_tools-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:0071b1c0bd0f5f9d292dca4efab32c92725d418e57f9c60acdc33c0172af8b53"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:c53c5719ef2a435997755abde3826ba4087174bd432aa721d8fac781fcea79e4"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e3db1300d7282264639eeee7243f5de7e6a7c0283f8bf05d66c0315b7b0f0b36"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b018a4b7455a7e8c16d0fdb3655a6ba6c9536da6de6c5d4f11b6bb73378165b"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ec6e4de3866e47cfde56607b1fae83ecc5aa546e06dec53de11f88063f4b5275"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8da4d828883913f1852bdd67383713ae5c11842f6c70f93f31893eab530aead"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c120c2cf4443121800e7f9bcfe2e94519fa25f3bb0b9882359dd3b252c78a7b"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8b7df5591d699cd9076065f1f15049e9c3597e0771bea51c8c97790caf5e4197"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a25048c5f984d33e3f5b6ad7618e98736542461213ade1bd6f2fcfe8ce804e3d"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-win32.whl", hash = "sha256:4b77ce6b6c17869858cfe14681ad09ed3a8a80e960e96035de1fd87f78158740"}, + {file = "grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc"}, + {file = "grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88"}, ] [[package]] @@ -1393,7 +1638,7 @@ files = [ [[package]] name = "h2" -version = "4.2.0" +version = "4.3.0" requires_python = ">=3.9" summary = "Pure-Python HTTP/2 protocol implementation" groups = ["dev-hosting-http"] @@ -1402,8 +1647,8 @@ dependencies = [ "hyperframe<7,>=6.1", ] files = [ - {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, - {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, + {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, + {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, ] [[package]] @@ -1451,25 +1696,25 @@ files = [ [[package]] name = "humanize" -version = "4.12.3" -requires_python = ">=3.9" +version = "4.15.0" +requires_python = ">=3.10" summary = "Python humanize utilities" groups = ["scheduler"] files = [ - {file = "humanize-4.12.3-py3-none-any.whl", hash = "sha256:2cbf6370af06568fa6d2da77c86edb7886f3160ecd19ee1ffef07979efc597f6"}, - {file = "humanize-4.12.3.tar.gz", hash = "sha256:8430be3a615106fdfceb0b2c1b41c4c98c6b0fc5cc59663a5539b111dd325fb0"}, + {file = "humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769"}, + {file = "humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10"}, ] [[package]] name = "hypercorn" -version = "0.17.3" -requires_python = ">=3.8" +version = "0.18.0" +requires_python = ">=3.10" summary = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" groups = ["dev-hosting-http"] dependencies = [ "exceptiongroup>=1.1.0; python_version < \"3.11\"", "h11", - "h2>=3.1.0", + "h2>=4.3.0", "priority", "taskgroup; python_version < \"3.11\"", "tomli; python_version < \"3.11\"", @@ -1477,8 +1722,8 @@ dependencies = [ "wsproto>=0.14.0", ] files = [ - {file = "hypercorn-0.17.3-py3-none-any.whl", hash = "sha256:059215dec34537f9d40a69258d323f56344805efb462959e727152b0aa504547"}, - {file = "hypercorn-0.17.3.tar.gz", hash = "sha256:1b37802ee3ac52d2d85270700d565787ab16cf19e1462ccfa9f089ca17574165"}, + {file = "hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd"}, + {file = "hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da"}, ] [[package]] @@ -1492,26 +1737,10 @@ files = [ {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, ] -[[package]] -name = "hypothesis" -version = "6.135.29" -requires_python = ">=3.9" -summary = "A library for property-based testing" -groups = ["tests"] -dependencies = [ - "attrs>=22.2.0", - "exceptiongroup>=1.0.0; python_version < \"3.11\"", - "sortedcontainers<3.0.0,>=2.1.0", -] -files = [ - {file = "hypothesis-6.135.29-py3-none-any.whl", hash = "sha256:db4e08bcc19235d2cf37801d6ccdaca757f86dc34a64e09c85fced15bb7e836a"}, - {file = "hypothesis-6.135.29.tar.gz", hash = "sha256:871acb38ff61346a420267f81f4ba05ad9a85d08965211edf9b29bc0c1ad9d7b"}, -] - [[package]] name = "icecream" -version = "2.1.5" -summary = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call." +version = "2.1.8" +summary = "Never use print() to debug again: inspect variables, expressions, and program execution with a single, simple function call." groups = ["dev"] dependencies = [ "asttokens>=2.0.1", @@ -1520,45 +1749,44 @@ dependencies = [ "pygments>=2.2.0", ] files = [ - {file = "icecream-2.1.5-py3-none-any.whl", hash = "sha256:c020917c5cb180a528dbba170250ccd8b74ebc75f2a7a8e839819bf959b9f80c"}, - {file = "icecream-2.1.5.tar.gz", hash = "sha256:14d21e3383326a69a8c1a3bcf11f83283459f0d269ece5af83fce2c0d663efec"}, + {file = "icecream-2.1.8-py3-none-any.whl", hash = "sha256:10b1c39dcb54cb28eb487bac56c35dbf9c2b2f406d24340e1a615c3f17274852"}, + {file = "icecream-2.1.8.tar.gz", hash = "sha256:37269bbc62b02f0d85bfaf3a0eb4df272c967fad059f7ddcdaee5303ea2b2a62"}, ] [[package]] name = "idna" -version = "3.10" -requires_python = ">=3.6" +version = "3.11" +requires_python = ">=3.8" summary = "Internationalized Domain Names in Applications (IDNA)" groups = ["default", "azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "examples", "integration-tests", "pubsub", "rabbitmq", "tests"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" requires_python = ">=3.9" summary = "Read metadata from Python packages" -groups = ["dev-otel", "pubsub"] +groups = ["dev-otel", "integration-tests", "pubsub"] dependencies = [ - "typing-extensions>=3.6.4; python_version < \"3.8\"", "zipp>=3.20", ] files = [ - {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, - {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] [[package]] name = "iniconfig" -version = "2.1.0" -requires_python = ">=3.8" +version = "2.3.0" +requires_python = ">=3.10" summary = "brain-dead simple config-ini parsing" groups = ["tests", "unit-tests"] files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] @@ -1585,18 +1813,18 @@ files = [ [[package]] name = "msal" -version = "1.32.3" -requires_python = ">=3.7" +version = "1.34.0" +requires_python = ">=3.8" summary = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." groups = ["azure-queue", "azure-servicebus"] dependencies = [ "PyJWT[crypto]<3,>=1.0.0", - "cryptography<47,>=2.5", + "cryptography<49,>=2.5", "requests<3,>=2.0.0", ] files = [ - {file = "msal-1.32.3-py3-none-any.whl", hash = "sha256:b2798db57760b1961b142f027ffb7c8169536bf77316e99a0df5c4aaebb11569"}, - {file = "msal-1.32.3.tar.gz", hash = "sha256:5eea038689c78a5a70ca8ecbe1245458b55a857bd096efb6989c69ba15985d35"}, + {file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"}, + {file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"}, ] [[package]] @@ -1615,7 +1843,7 @@ files = [ [[package]] name = "multidict" -version = "6.6.3" +version = "6.7.0" requires_python = ">=3.9" summary = "multidict implementation" groups = ["dev-consumers", "rabbitmq"] @@ -1623,157 +1851,193 @@ dependencies = [ "typing-extensions>=4.1.0; python_version < \"3.11\"", ] files = [ - {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, - {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, - {file = "multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8"}, - {file = "multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61"}, - {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b"}, - {file = "multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318"}, - {file = "multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485"}, - {file = "multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5"}, - {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c"}, - {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df"}, - {file = "multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9"}, - {file = "multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56"}, - {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183"}, - {file = "multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5"}, - {file = "multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2"}, - {file = "multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb"}, - {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6"}, - {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f"}, - {file = "multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a"}, - {file = "multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75"}, - {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10"}, - {file = "multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5"}, - {file = "multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17"}, - {file = "multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b"}, - {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55"}, - {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b"}, - {file = "multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca"}, - {file = "multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1"}, - {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6"}, - {file = "multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e"}, - {file = "multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9"}, - {file = "multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600"}, - {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134"}, - {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37"}, - {file = "multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0"}, - {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d"}, - {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c"}, - {file = "multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e"}, - {file = "multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d"}, - {file = "multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb"}, - {file = "multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a"}, - {file = "multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, + {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, + {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, + {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, + {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, + {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, + {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, + {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, + {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, + {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, + {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, + {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, + {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, + {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, + {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, + {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, + {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, + {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, + {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, + {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, + {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, + {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, + {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, + {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, ] [[package]] name = "nats-py" -version = "2.10.0" +version = "2.12.0" requires_python = ">=3.7" summary = "NATS client for Python" -groups = ["nats"] +groups = ["integration-tests", "nats"] files = [ - {file = "nats_py-2.10.0.tar.gz", hash = "sha256:9d44265a097edb30d40e214c1dd1a7405c1451d33480ce714c041fb73bb66a10"}, + {file = "nats_py-2.12.0.tar.gz", hash = "sha256:2981ca4b63b8266c855573fa7871b1be741f1889fd429ee657e5ffc0971a38a1"}, ] [[package]] name = "opentelemetry-api" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Python API" -groups = ["dev-otel", "pubsub"] +groups = ["dev-otel", "integration-tests", "pubsub"] dependencies = [ "importlib-metadata<8.8.0,>=6.0", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06"}, - {file = "opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe"}, + {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, + {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Collector Exporters" groups = ["dev-otel"] dependencies = [ - "opentelemetry-exporter-otlp-proto-grpc==1.35.0", - "opentelemetry-exporter-otlp-proto-http==1.35.0", + "opentelemetry-exporter-otlp-proto-grpc==1.39.1", + "opentelemetry-exporter-otlp-proto-http==1.39.1", ] files = [ - {file = "opentelemetry_exporter_otlp-1.35.0-py3-none-any.whl", hash = "sha256:8e6bb9025f6238db7d69bba7ee37c77e4858d0a1ff22a9e126f7c9e017e83afe"}, - {file = "opentelemetry_exporter_otlp-1.35.0.tar.gz", hash = "sha256:f94feff09b3524df867c7876b79c96cef20068106cb5efe55340e8d08192c8a4"}, + {file = "opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe"}, + {file = "opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c"}, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Protobuf encoding" groups = ["dev-otel"] dependencies = [ - "opentelemetry-proto==1.35.0", + "opentelemetry-proto==1.39.1", ] files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.35.0-py3-none-any.whl", hash = "sha256:863465de697ae81279ede660f3918680b4480ef5f69dcdac04f30722ed7b74cc"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.35.0.tar.gz", hash = "sha256:6f6d8c39f629b9fa5c79ce19a2829dbd93034f8ac51243cdf40ed2196f00d7eb"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464"}, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Collector Protobuf over gRPC Exporter" groups = ["dev-otel"] @@ -1782,84 +2046,84 @@ dependencies = [ "grpcio<2.0.0,>=1.63.2; python_version < \"3.13\"", "grpcio<2.0.0,>=1.66.2; python_version >= \"3.13\"", "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.35.0", - "opentelemetry-proto==1.35.0", - "opentelemetry-sdk~=1.35.0", + "opentelemetry-exporter-otlp-proto-common==1.39.1", + "opentelemetry-proto==1.39.1", + "opentelemetry-sdk~=1.39.1", "typing-extensions>=4.6.0", ] files = [ - {file = "opentelemetry_exporter_otlp_proto_grpc-1.35.0-py3-none-any.whl", hash = "sha256:ee31203eb3e50c7967b8fa71db366cc355099aca4e3726e489b248cdb2fd5a62"}, - {file = "opentelemetry_exporter_otlp_proto_grpc-1.35.0.tar.gz", hash = "sha256:ac4c2c3aa5674642db0df0091ab43ec08bbd91a9be469c8d9b18923eb742b9cc"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18"}, + {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad"}, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Collector Protobuf over HTTP Exporter" groups = ["dev-otel"] dependencies = [ "googleapis-common-protos~=1.52", "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.35.0", - "opentelemetry-proto==1.35.0", - "opentelemetry-sdk~=1.35.0", + "opentelemetry-exporter-otlp-proto-common==1.39.1", + "opentelemetry-proto==1.39.1", + "opentelemetry-sdk~=1.39.1", "requests~=2.7", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.35.0-py3-none-any.whl", hash = "sha256:9a001e3df3c7f160fb31056a28ed7faa2de7df68877ae909516102ae36a54e1d"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.35.0.tar.gz", hash = "sha256:cf940147f91b450ef5f66e9980d40eb187582eed399fa851f4a7a45bb880de79"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb"}, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.56b0" +version = "0.60b1" requires_python = ">=3.9" summary = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" groups = ["dev-otel"] dependencies = [ "opentelemetry-api~=1.4", - "opentelemetry-semantic-conventions==0.56b0", + "opentelemetry-semantic-conventions==0.60b1", "packaging>=18.0", "wrapt<2.0.0,>=1.0.0", ] files = [ - {file = "opentelemetry_instrumentation-0.56b0-py3-none-any.whl", hash = "sha256:948967f7c8f5bdc6e43512ba74c9ae14acb48eb72a35b61afe8db9909f743be3"}, - {file = "opentelemetry_instrumentation-0.56b0.tar.gz", hash = "sha256:d2dbb3021188ca0ec8c5606349ee9a2919239627e8341d4d37f1d21ec3291d11"}, + {file = "opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d"}, + {file = "opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a"}, ] [[package]] name = "opentelemetry-instrumentation-botocore" -version = "0.56b0" +version = "0.60b1" requires_python = ">=3.9" summary = "OpenTelemetry Botocore instrumentation" groups = ["dev-otel"] dependencies = [ - "opentelemetry-api~=1.30", - "opentelemetry-instrumentation==0.56b0", + "opentelemetry-api~=1.37", + "opentelemetry-instrumentation==0.60b1", "opentelemetry-propagator-aws-xray~=1.0", - "opentelemetry-semantic-conventions==0.56b0", + "opentelemetry-semantic-conventions==0.60b1", ] files = [ - {file = "opentelemetry_instrumentation_botocore-0.56b0-py3-none-any.whl", hash = "sha256:7ccacdcb5b858d931b0b526b2e3e857c06f89c67847252fd048002b295862bf8"}, - {file = "opentelemetry_instrumentation_botocore-0.56b0.tar.gz", hash = "sha256:c2dd342eec24acdac7cb6e59c268f523c1e1165b6c7f2ac1f339623a2e1d6118"}, + {file = "opentelemetry_instrumentation_botocore-0.60b1-py3-none-any.whl", hash = "sha256:b5cb1e267545cdd96a81a1bef690b1d00c20497ac4d815ef7c79fb3c572d6fc6"}, + {file = "opentelemetry_instrumentation_botocore-0.60b1.tar.gz", hash = "sha256:198e7d74b78b1b19ea47a5f2191171227557c37bfc4f49076958d129f848a8ed"}, ] [[package]] name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.56b0" +version = "0.60b1" requires_python = ">=3.9" summary = "OpenTelemetry Confluent Kafka instrumentation" groups = ["dev-otel"] dependencies = [ "opentelemetry-api~=1.12", - "opentelemetry-instrumentation==0.56b0", + "opentelemetry-instrumentation==0.60b1", "wrapt<2.0.0,>=1.0.0", ] files = [ - {file = "opentelemetry_instrumentation_confluent_kafka-0.56b0-py3-none-any.whl", hash = "sha256:dc52fba7ddf01d8f13476b81024a8cd8d3dc191f6b8684b6c0a829e6c81d0dd2"}, - {file = "opentelemetry_instrumentation_confluent_kafka-0.56b0.tar.gz", hash = "sha256:f57ae3abef376f37c8d14769a5cfe5c40f2b337f3db0bf63f24b4e827aec87e2"}, + {file = "opentelemetry_instrumentation_confluent_kafka-0.60b1-py3-none-any.whl", hash = "sha256:e25ca438f0a65266e5e580062a53e24df4e8d7e1902634f6c56122db8408e5bc"}, + {file = "opentelemetry_instrumentation_confluent_kafka-0.60b1.tar.gz", hash = "sha256:8272ecfa6ee07fefb21bcddcb1942d05d903e2b1516cb1c463a3e9d7f8dcdad1"}, ] [[package]] @@ -1878,7 +2142,7 @@ files = [ [[package]] name = "opentelemetry-proto" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Python Proto" groups = ["dev-otel"] @@ -1886,53 +2150,39 @@ dependencies = [ "protobuf<7.0,>=5.0", ] files = [ - {file = "opentelemetry_proto-1.35.0-py3-none-any.whl", hash = "sha256:98fffa803164499f562718384e703be8d7dfbe680192279a0429cb150a2f8809"}, - {file = "opentelemetry_proto-1.35.0.tar.gz", hash = "sha256:532497341bd3e1c074def7c5b00172601b28bb83b48afc41a4b779f26eb4ee05"}, + {file = "opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007"}, + {file = "opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8"}, ] [[package]] name = "opentelemetry-sdk" -version = "1.35.0" +version = "1.39.1" requires_python = ">=3.9" summary = "OpenTelemetry Python SDK" -groups = ["dev-otel", "pubsub"] +groups = ["dev-otel", "integration-tests", "pubsub"] dependencies = [ - "opentelemetry-api==1.35.0", - "opentelemetry-semantic-conventions==0.56b0", + "opentelemetry-api==1.39.1", + "opentelemetry-semantic-conventions==0.60b1", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800"}, - {file = "opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954"}, + {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, + {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.56b0" +version = "0.60b1" requires_python = ">=3.9" summary = "OpenTelemetry Semantic Conventions" -groups = ["dev-otel", "pubsub"] +groups = ["dev-otel", "integration-tests", "pubsub"] dependencies = [ - "opentelemetry-api==1.35.0", + "opentelemetry-api==1.39.1", "typing-extensions>=4.5.0", ] files = [ - {file = "opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2"}, - {file = "opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea"}, -] - -[[package]] -name = "outcome" -version = "1.3.0.post0" -requires_python = ">=3.7" -summary = "Capture the outcome of Python function calls." -groups = ["tests"] -dependencies = [ - "attrs>=19.2.0", -] -files = [ - {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, - {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, + {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, + {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, ] [[package]] @@ -1981,241 +2231,260 @@ files = [ [[package]] name = "propcache" -version = "0.3.2" +version = "0.4.1" requires_python = ">=3.9" summary = "Accelerated property cache" groups = ["dev-consumers", "rabbitmq"] files = [ - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, - {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, - {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, - {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, - {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, - {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, - {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, - {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, - {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, - {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, - {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, - {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, - {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, - {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, - {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, - {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, - {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, - {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, - {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, - {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, - {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, - {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, - {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, - {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, - {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, - {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, - {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, - {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, ] [[package]] name = "proto-plus" -version = "1.26.1" +version = "1.27.0" requires_python = ">=3.7" summary = "Beautiful, Pythonic protocol buffers" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "protobuf<7.0.0,>=3.19.0", ] files = [ - {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, - {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, + {file = "proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82"}, + {file = "proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4"}, ] [[package]] name = "protobuf" -version = "6.31.1" +version = "6.33.2" requires_python = ">=3.9" summary = "" -groups = ["dev-consumers", "dev-otel", "pubsub"] +groups = ["dev-consumers", "dev-otel", "integration-tests", "pubsub"] files = [ - {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, - {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, - {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, - {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, - {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, - {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, - {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, -] - -[[package]] -name = "psutil" -version = "7.0.0" -requires_python = ">=3.6" -summary = "Cross-platform lib for process and system monitoring in Python. NOTE: the syntax of this script MUST be kept compatible with Python 2.7." -groups = ["tests"] -files = [ - {file = "psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25"}, - {file = "psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34"}, - {file = "psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993"}, - {file = "psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99"}, - {file = "psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553"}, - {file = "psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456"}, + {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"}, + {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"}, + {file = "protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f"}, + {file = "protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c"}, + {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"}, ] [[package]] name = "psycopg" -version = "3.2.9" -requires_python = ">=3.8" +version = "3.3.2" +requires_python = ">=3.10" summary = "PostgreSQL database adapter for Python" groups = ["examples"] dependencies = [ - "backports-zoneinfo>=0.2.0; python_version < \"3.9\"", "typing-extensions>=4.6; python_version < \"3.13\"", "tzdata; sys_platform == \"win32\"", ] files = [ - {file = "psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6"}, - {file = "psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700"}, + {file = "psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b"}, + {file = "psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7"}, ] [[package]] name = "psycopg-binary" -version = "3.2.9" -requires_python = ">=3.8" +version = "3.3.2" +requires_python = ">=3.10" summary = "PostgreSQL database adapter for Python -- C optimisation distribution" groups = ["examples"] marker = "implementation_name != \"pypy\"" files = [ - {file = "psycopg_binary-3.2.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:528239bbf55728ba0eacbd20632342867590273a9bacedac7538ebff890f1093"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4978c01ca4c208c9d6376bd585e2c0771986b76ff7ea518f6d2b51faece75e8"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ed2bab85b505d13e66a914d0f8cdfa9475c16d3491cf81394e0748b77729af2"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799fa1179ab8a58d1557a95df28b492874c8f4135101b55133ec9c55fc9ae9d7"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb37ac3955d19e4996c3534abfa4f23181333974963826db9e0f00731274b695"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa5c80d8b4cbf23f338db88a7251cef8bb4b68e0f91cf8b6ddfa93884fdbb0c1"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:39a127e0cf9b55bd4734a8008adf3e01d1fd1cb36339c6a9e2b2cbb6007c50ee"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fb7599e436b586e265bea956751453ad32eb98be6a6e694252f4691c31b16edb"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5d2c9fe14fe42b3575a0b4e09b081713e83b762c8dc38a3771dd3265f8f110e7"}, - {file = "psycopg_binary-3.2.9-cp310-cp310-win_amd64.whl", hash = "sha256:7e4660fad2807612bb200de7262c88773c3483e85d981324b3c647176e41fdc8"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2504e9fd94eabe545d20cddcc2ff0da86ee55d76329e1ab92ecfcc6c0a8156c4"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:093a0c079dd6228a7f3c3d82b906b41964eaa062a9a8c19f45ab4984bf4e872b"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:387c87b51d72442708e7a853e7e7642717e704d59571da2f3b29e748be58c78a"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9ac10a2ebe93a102a326415b330fff7512f01a9401406896e78a81d75d6eddc"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72fdbda5b4c2a6a72320857ef503a6589f56d46821592d4377c8c8604810342b"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f34e88940833d46108f949fdc1fcfb74d6b5ae076550cd67ab59ef47555dba95"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a3e0f89fe35cb03ff1646ab663dabf496477bab2a072315192dbaa6928862891"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6afb3e62f2a3456f2180a4eef6b03177788df7ce938036ff7f09b696d418d186"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cc19ed5c7afca3f6b298bfc35a6baa27adb2019670d15c32d0bb8f780f7d560d"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc75f63653ce4ec764c8f8c8b0ad9423e23021e1c34a84eb5f4ecac8538a4a4a"}, - {file = "psycopg_binary-3.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:3db3ba3c470801e94836ad78bf11fd5fab22e71b0c77343a1ee95d693879937a"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be7d650a434921a6b1ebe3fff324dbc2364393eb29d7672e638ce3e21076974e"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a76b4722a529390683c0304501f238b365a46b1e5fb6b7249dbc0ad6fea51a0"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96a551e4683f1c307cfc3d9a05fec62c00a7264f320c9962a67a543e3ce0d8ff"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61d0a6ceed8f08c75a395bc28cb648a81cf8dee75ba4650093ad1a24a51c8724"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad280bbd409bf598683dda82232f5215cfc5f2b1bf0854e409b4d0c44a113b1d"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76eddaf7fef1d0994e3d536ad48aa75034663d3a07f6f7e3e601105ae73aeff6"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52e239cd66c4158e412318fbe028cd94b0ef21b0707f56dcb4bdc250ee58fd40"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1b2cf018168cad87580e67bdde38ff5e51511112f1ce6ce9a8336871f465c19a"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14f64d1ac6942ff089fc7e926440f7a5ced062e2ed0949d7d2d680dc5c00e2d4"}, - {file = "psycopg_binary-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:7a838852e5afb6b4126f93eb409516a8c02a49b788f4df8b6469a40c2157fa21"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:98bbe35b5ad24a782c7bf267596638d78aa0e87abc7837bdac5b2a2ab954179e"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:72691a1615ebb42da8b636c5ca9f2b71f266be9e172f66209a361c175b7842c5"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ab464bfba8c401f5536d5aa95f0ca1dd8257b5202eede04019b4415f491351"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e8aeefebe752f46e3c4b769e53f1d4ad71208fe1150975ef7662c22cca80fab"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7e4e4dd177a8665c9ce86bc9caae2ab3aa9360b7ce7ec01827ea1baea9ff748"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fc2915949e5c1ea27a851f7a472a7da7d0a40d679f0a31e42f1022f3c562e87"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1fa38a4687b14f517f049477178093c39c2a10fdcced21116f47c017516498f"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5be8292d07a3ab828dc95b5ee6b69ca0a5b2e579a577b39671f4f5b47116dfd2"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b"}, - {file = "psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19"}, + {file = "psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4"}, + {file = "psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12"}, + {file = "psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121"}, + {file = "psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e"}, + {file = "psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1"}, ] [[package]] name = "psycopg-pool" -version = "3.2.6" -requires_python = ">=3.8" +version = "3.3.0" +requires_python = ">=3.10" summary = "Connection Pool for Psycopg" groups = ["examples"] dependencies = [ "typing-extensions>=4.6", ] files = [ - {file = "psycopg_pool-3.2.6-py3-none-any.whl", hash = "sha256:5887318a9f6af906d041a0b1dc1c60f8f0dda8340c2572b74e10907b51ed5da7"}, - {file = "psycopg_pool-3.2.6.tar.gz", hash = "sha256:0f92a7817719517212fbfe2fd58b8c35c1850cdd2a80d36b581ba2085d9148e5"}, + {file = "psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063"}, + {file = "psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5"}, ] [[package]] name = "psycopg" -version = "3.2.9" +version = "3.3.2" extras = ["binary", "pool"] -requires_python = ">=3.8" +requires_python = ">=3.10" summary = "PostgreSQL database adapter for Python" groups = ["examples"] dependencies = [ - "psycopg-binary==3.2.9; implementation_name != \"pypy\"", + "psycopg-binary==3.3.2; implementation_name != \"pypy\"", "psycopg-pool", - "psycopg==3.2.9", + "psycopg==3.3.2", ] files = [ - {file = "psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6"}, - {file = "psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700"}, + {file = "psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b"}, + {file = "psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7"}, ] [[package]] @@ -2223,7 +2492,7 @@ name = "pyasn1" version = "0.6.1" requires_python = ">=3.8" summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, @@ -2234,7 +2503,7 @@ name = "pyasn1-modules" version = "0.4.2" requires_python = ">=3.8" summary = "A collection of ASN.1-based protocols modules" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "pyasn1<0.7.0,>=0.6.1", ] @@ -2245,120 +2514,143 @@ files = [ [[package]] name = "pycparser" -version = "2.22" +version = "2.23" requires_python = ">=3.8" summary = "C parser in Python" -groups = ["azure-queue", "azure-servicebus", "dev-consumers", "tests"] -marker = "os_name == \"nt\" and implementation_name != \"pypy\" or platform_python_implementation != \"PyPy\"" +groups = ["azure-queue", "azure-servicebus", "dev-consumers"] +marker = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" requires_python = ">=3.9" summary = "Data validation using Python type hints" groups = ["examples"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.33.2", - "typing-extensions>=4.12.2", - "typing-inspection>=0.4.0", + "pydantic-core==2.41.5", + "typing-extensions>=4.14.1", + "typing-inspection>=0.4.2", ] files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" groups = ["examples"] dependencies = [ - "typing-extensions!=4.7.0,>=4.6.0", -] -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + "typing-extensions>=4.14.1", +] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] [[package]] @@ -2401,7 +2693,7 @@ files = [ [[package]] name = "pytest" -version = "8.4.1" +version = "8.4.2" requires_python = ">=3.9" summary = "pytest: simple powerful testing with Python" groups = ["tests", "unit-tests"] @@ -2415,13 +2707,13 @@ dependencies = [ "tomli>=1; python_version < \"3.11\"", ] files = [ - {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, - {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [[package]] name = "pytest-cov" -version = "6.2.1" +version = "6.3.0" requires_python = ">=3.9" summary = "Pytest plugin for measuring coverage." groups = ["unit-tests"] @@ -2431,22 +2723,22 @@ dependencies = [ "pytest>=6.2.5", ] files = [ - {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, - {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, + {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, + {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, ] [[package]] name = "pytest-mock" -version = "3.14.1" -requires_python = ">=3.8" +version = "3.15.1" +requires_python = ">=3.9" summary = "Thin-wrapper around the mock package for easier use with pytest" groups = ["unit-tests"] dependencies = [ "pytest>=6.2.5", ] files = [ - {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, - {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, ] [[package]] @@ -2480,13 +2772,13 @@ files = [ [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.1" requires_python = ">=3.9" summary = "Read key-value pairs from a .env file and set them as environment variables" groups = ["integration-tests"] files = [ - {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, - {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, ] [[package]] @@ -2512,29 +2804,32 @@ files = [ [[package]] name = "pywin32" -version = "310" +version = "311" summary = "Python for Window Extensions" groups = ["integration-tests"] marker = "sys_platform == \"win32\"" files = [ - {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, - {file = "pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d"}, - {file = "pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213"}, - {file = "pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd"}, - {file = "pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c"}, - {file = "pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582"}, - {file = "pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d"}, - {file = "pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060"}, - {file = "pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966"}, - {file = "pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab"}, - {file = "pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e"}, - {file = "pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33"}, + {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, + {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, + {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, + {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, + {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, + {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, + {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, + {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, + {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, + {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, + {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, + {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, + {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, + {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, + {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, ] [[package]] name = "requests" -version = "2.32.4" -requires_python = ">=3.8" +version = "2.32.5" +requires_python = ">=3.9" summary = "Python HTTP for Humans." groups = ["azure-queue", "azure-servicebus", "dev-otel", "integration-tests", "pubsub"] dependencies = [ @@ -2544,8 +2839,8 @@ dependencies = [ "urllib3<3,>=1.21.1", ] files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [[package]] @@ -2553,7 +2848,7 @@ name = "rsa" version = "4.9.1" requires_python = "<4,>=3.6" summary = "Pure-Python RSA implementation" -groups = ["pubsub"] +groups = ["integration-tests", "pubsub"] dependencies = [ "pyasn1>=0.1.3", ] @@ -2564,7 +2859,7 @@ files = [ [[package]] name = "s3transfer" -version = "0.13.0" +version = "0.14.0" requires_python = ">=3.9" summary = "An Amazon S3 Transfer Manager" groups = ["dev-consumers", "integration-tests"] @@ -2572,82 +2867,94 @@ dependencies = [ "botocore<2.0a.0,>=1.37.4", ] files = [ - {file = "s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be"}, - {file = "s3transfer-0.13.0.tar.gz", hash = "sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177"}, + {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, + {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, ] [[package]] name = "setproctitle" -version = "1.3.6" +version = "1.3.7" requires_python = ">=3.8" summary = "A Python module to customize the process title" groups = ["tests"] files = [ - {file = "setproctitle-1.3.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ebcf34b69df4ca0eabaaaf4a3d890f637f355fed00ba806f7ebdd2d040658c26"}, - {file = "setproctitle-1.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1aa1935aa2195b76f377e5cb018290376b7bf085f0b53f5a95c0c21011b74367"}, - {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13624d9925bb481bc0ccfbc7f533da38bfbfe6e80652314f789abc78c2e513bd"}, - {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97a138fa875c6f281df7720dac742259e85518135cd0e3551aba1c628103d853"}, - {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86e9e82bfab579327dbe9b82c71475165fbc8b2134d24f9a3b2edaf200a5c3d"}, - {file = "setproctitle-1.3.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6af330ddc2ec05a99c3933ab3cba9365357c0b8470a7f2fa054ee4b0984f57d1"}, - {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:109fc07b1cd6cef9c245b2028e3e98e038283342b220def311d0239179810dbe"}, - {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7df5fcc48588f82b6cc8073db069609ddd48a49b1e9734a20d0efb32464753c4"}, - {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:2407955dc359d735a20ac6e797ad160feb33d529a2ac50695c11a1ec680eafab"}, - {file = "setproctitle-1.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:38ca045626af693da042ac35d7332e7b9dbd52e6351d6973b310612e3acee6d6"}, - {file = "setproctitle-1.3.6-cp310-cp310-win32.whl", hash = "sha256:9483aa336687463f5497dd37a070094f3dff55e2c888994f8440fcf426a1a844"}, - {file = "setproctitle-1.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:4efc91b437f6ff2578e89e3f17d010c0a0ff01736606473d082913ecaf7859ba"}, - {file = "setproctitle-1.3.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a1d856b0f4e4a33e31cdab5f50d0a14998f3a2d726a3fd5cb7c4d45a57b28d1b"}, - {file = "setproctitle-1.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50706b9c0eda55f7de18695bfeead5f28b58aa42fd5219b3b1692d554ecbc9ec"}, - {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af188f3305f0a65c3217c30c6d4c06891e79144076a91e8b454f14256acc7279"}, - {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cce0ed8b3f64c71c140f0ec244e5fdf8ecf78ddf8d2e591d4a8b6aa1c1214235"}, - {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70100e2087fe05359f249a0b5f393127b3a1819bf34dec3a3e0d4941138650c9"}, - {file = "setproctitle-1.3.6-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1065ed36bd03a3fd4186d6c6de5f19846650b015789f72e2dea2d77be99bdca1"}, - {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4adf6a0013fe4e0844e3ba7583ec203ca518b9394c6cc0d3354df2bf31d1c034"}, - {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb7452849f6615871eabed6560ffedfe56bc8af31a823b6be4ce1e6ff0ab72c5"}, - {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a094b7ce455ca341b59a0f6ce6be2e11411ba6e2860b9aa3dbb37468f23338f4"}, - {file = "setproctitle-1.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ad1c2c2baaba62823a7f348f469a967ece0062140ca39e7a48e4bbb1f20d54c4"}, - {file = "setproctitle-1.3.6-cp311-cp311-win32.whl", hash = "sha256:8050c01331135f77ec99d99307bfbc6519ea24d2f92964b06f3222a804a3ff1f"}, - {file = "setproctitle-1.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:9b73cf0fe28009a04a35bb2522e4c5b5176cc148919431dcb73fdbdfaab15781"}, - {file = "setproctitle-1.3.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af44bb7a1af163806bbb679eb8432fa7b4fb6d83a5d403b541b675dcd3798638"}, - {file = "setproctitle-1.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cca16fd055316a48f0debfcbfb6af7cea715429fc31515ab3fcac05abd527d8"}, - {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea002088d5554fd75e619742cefc78b84a212ba21632e59931b3501f0cfc8f67"}, - {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb465dd5825356c1191a038a86ee1b8166e3562d6e8add95eec04ab484cfb8a2"}, - {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2c8e20487b3b73c1fa72c56f5c89430617296cd380373e7af3a538a82d4cd6d"}, - {file = "setproctitle-1.3.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d6252098e98129a1decb59b46920d4eca17b0395f3d71b0d327d086fefe77d"}, - {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf355fbf0d4275d86f9f57be705d8e5eaa7f8ddb12b24ced2ea6cbd68fdb14dc"}, - {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e288f8a162d663916060beb5e8165a8551312b08efee9cf68302687471a6545d"}, - {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b2e54f4a2dc6edf0f5ea5b1d0a608d2af3dcb5aa8c8eeab9c8841b23e1b054fe"}, - {file = "setproctitle-1.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b6f4abde9a2946f57e8daaf1160b2351bcf64274ef539e6675c1d945dbd75e2a"}, - {file = "setproctitle-1.3.6-cp312-cp312-win32.whl", hash = "sha256:db608db98ccc21248370d30044a60843b3f0f3d34781ceeea67067c508cd5a28"}, - {file = "setproctitle-1.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:082413db8a96b1f021088e8ec23f0a61fec352e649aba20881895815388b66d3"}, - {file = "setproctitle-1.3.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e2a9e62647dc040a76d55563580bf3bb8fe1f5b6ead08447c2ed0d7786e5e794"}, - {file = "setproctitle-1.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:751ba352ed922e0af60458e961167fa7b732ac31c0ddd1476a2dfd30ab5958c5"}, - {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7890e291bf4708e3b61db9069ea39b3ab0651e42923a5e1f4d78a7b9e4b18301"}, - {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2b17855ed7f994f3f259cf2dfbfad78814538536fa1a91b50253d84d87fd88d"}, - {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e51ec673513465663008ce402171192a053564865c2fc6dc840620871a9bd7c"}, - {file = "setproctitle-1.3.6-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63cc10352dc6cf35a33951656aa660d99f25f574eb78132ce41a85001a638aa7"}, - {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dba8faee2e4a96e934797c9f0f2d093f8239bf210406a99060b3eabe549628e"}, - {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e3e44d08b61de0dd6f205528498f834a51a5c06689f8fb182fe26f3a3ce7dca9"}, - {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:de004939fc3fd0c1200d26ea9264350bfe501ffbf46c8cf5dc7f345f2d87a7f1"}, - {file = "setproctitle-1.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f8194b4d631b003a1176a75d1acd545e04b1f54b821638e098a93e6e62830ef"}, - {file = "setproctitle-1.3.6-cp313-cp313-win32.whl", hash = "sha256:d714e002dd3638170fe7376dc1b686dbac9cb712cde3f7224440af722cc9866a"}, - {file = "setproctitle-1.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:b70c07409d465f3a8b34d52f863871fb8a00755370791d2bd1d4f82b3cdaf3d5"}, - {file = "setproctitle-1.3.6-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:23a57d3b8f1549515c2dbe4a2880ebc1f27780dc126c5e064167563e015817f5"}, - {file = "setproctitle-1.3.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81c443310831e29fabbd07b75ebbfa29d0740b56f5907c6af218482d51260431"}, - {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d88c63bd395c787b0aa81d8bbc22c1809f311032ce3e823a6517b711129818e4"}, - {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73f14b86d0e2858ece6bf5807c9889670e392c001d414b4293d0d9b291942c3"}, - {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3393859eb8f19f5804049a685bf286cb08d447e28ba5c6d8543c7bf5500d5970"}, - {file = "setproctitle-1.3.6-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:785cd210c0311d9be28a70e281a914486d62bfd44ac926fcd70cf0b4d65dff1c"}, - {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c051f46ed1e13ba8214b334cbf21902102807582fbfaf0fef341b9e52f0fafbf"}, - {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:49498ebf68ca3e75321ffe634fcea5cc720502bfaa79bd6b03ded92ce0dc3c24"}, - {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4431629c178193f23c538cb1de3da285a99ccc86b20ee91d81eb5f1a80e0d2ba"}, - {file = "setproctitle-1.3.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d136fbf8ad4321716e44d6d6b3d8dffb4872626010884e07a1db54b7450836cf"}, - {file = "setproctitle-1.3.6-cp313-cp313t-win32.whl", hash = "sha256:d483cc23cc56ab32911ea0baa0d2d9ea7aa065987f47de847a0a93a58bf57905"}, - {file = "setproctitle-1.3.6-cp313-cp313t-win_amd64.whl", hash = "sha256:74973aebea3543ad033b9103db30579ec2b950a466e09f9c2180089e8346e0ec"}, - {file = "setproctitle-1.3.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3cde5b83ec4915cd5e6ae271937fd60d14113c8f7769b4a20d51769fe70d8717"}, - {file = "setproctitle-1.3.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:797f2846b546a8741413c57d9fb930ad5aa939d925c9c0fa6186d77580035af7"}, - {file = "setproctitle-1.3.6-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac3eb04bcf0119aadc6235a2c162bae5ed5f740e3d42273a7228b915722de20"}, - {file = "setproctitle-1.3.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0e6b5633c94c5111f7137f875e8f1ff48f53b991d5d5b90932f27dc8c1fa9ae4"}, - {file = "setproctitle-1.3.6.tar.gz", hash = "sha256:c9f32b96c700bb384f33f7cf07954bb609d35dd82752cef57fb2ee0968409169"}, + {file = "setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b"}, + {file = "setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7"}, + {file = "setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c"}, + {file = "setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3"}, + {file = "setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f"}, + {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64"}, + {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5"}, + {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381"}, + {file = "setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c"}, + {file = "setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95"}, + {file = "setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0"}, + {file = "setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4"}, + {file = "setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f"}, + {file = "setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9"}, + {file = "setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba"}, + {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307"}, + {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee"}, + {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1"}, + {file = "setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d"}, + {file = "setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4"}, + {file = "setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e"}, + {file = "setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798"}, + {file = "setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629"}, + {file = "setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1"}, + {file = "setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6"}, + {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c"}, + {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a"}, + {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739"}, + {file = "setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f"}, + {file = "setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300"}, + {file = "setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922"}, + {file = "setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee"}, + {file = "setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd"}, + {file = "setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0"}, + {file = "setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929"}, + {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f"}, + {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698"}, + {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c"}, + {file = "setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd"}, + {file = "setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f"}, + {file = "setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9"}, + {file = "setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5"}, + {file = "setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29"}, + {file = "setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152"}, + {file = "setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c"}, + {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b"}, + {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18"}, + {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c"}, + {file = "setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29"}, + {file = "setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9"}, + {file = "setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63"}, + {file = "setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e"}, + {file = "setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f"}, + {file = "setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5"}, + {file = "setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17"}, + {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e"}, + {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0"}, + {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8"}, + {file = "setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed"}, + {file = "setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416"}, + {file = "setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3"}, + {file = "setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309"}, + {file = "setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b"}, + {file = "setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45"}, + {file = "setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4"}, + {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1"}, + {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070"}, + {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73"}, + {file = "setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2"}, + {file = "setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a"}, + {file = "setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6"}, + {file = "setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2"}, + {file = "setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78"}, + {file = "setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1"}, + {file = "setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65"}, + {file = "setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a"}, + {file = "setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e"}, ] [[package]] @@ -2666,37 +2973,16 @@ name = "six" version = "1.17.0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Python 2 and 3 compatibility utilities" -groups = ["azure-queue", "azure-servicebus", "cron", "dev-consumers", "integration-tests", "sqs"] +groups = ["cron", "dev-consumers", "integration-tests", "sqs"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] -[[package]] -name = "sniffio" -version = "1.3.1" -requires_python = ">=3.7" -summary = "Sniff out which async library your code is running under" -groups = ["default", "dev-consumers", "examples", "tests"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -summary = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -groups = ["tests"] -files = [ - {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, - {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, -] - [[package]] name = "starlette" -version = "0.47.1" -requires_python = ">=3.9" +version = "0.50.0" +requires_python = ">=3.10" summary = "The little ASGI library that shines." groups = ["examples"] dependencies = [ @@ -2704,13 +2990,13 @@ dependencies = [ "typing-extensions>=4.10.0; python_version < \"3.13\"", ] files = [ - {file = "starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527"}, - {file = "starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b"}, + {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, + {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, ] [[package]] name = "structlog" -version = "25.4.0" +version = "25.5.0" requires_python = ">=3.8" summary = "Structured Logging for Python" groups = ["dev"] @@ -2718,8 +3004,8 @@ dependencies = [ "typing-extensions; python_version < \"3.11\"", ] files = [ - {file = "structlog-25.4.0-py3-none-any.whl", hash = "sha256:fe809ff5c27e557d14e613f45ca441aabda051d119ee5a0102aaba6ce40eed2c"}, - {file = "structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4"}, + {file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"}, + {file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"}, ] [[package]] @@ -2739,8 +3025,8 @@ files = [ [[package]] name = "testcontainers" -version = "4.10.0" -requires_python = "<4.0,>=3.9" +version = "4.14.0" +requires_python = ">=3.10" summary = "Python library for throwaway instances of anything that can run in a Docker container" groups = ["integration-tests"] dependencies = [ @@ -2751,104 +3037,86 @@ dependencies = [ "wrapt", ] files = [ - {file = "testcontainers-4.10.0-py3-none-any.whl", hash = "sha256:31ed1a81238c7e131a2a29df6db8f23717d892b592fa5a1977fd0dcd0c23fc23"}, - {file = "testcontainers-4.10.0.tar.gz", hash = "sha256:03f85c3e505d8b4edeb192c72a961cebbcba0dd94344ae778b4a159cb6dcf8d3"}, + {file = "testcontainers-4.14.0-py3-none-any.whl", hash = "sha256:64e79b6b1e6d2b9b9e125539d35056caab4be739f7b7158c816d717f3596fa59"}, + {file = "testcontainers-4.14.0.tar.gz", hash = "sha256:3b2d4fa487af23024f00fcaa2d1cf4a5c6ad0c22e638a49799813cb49b3176c7"}, ] [[package]] -name = "tomli" -version = "2.2.1" -requires_python = ">=3.8" -summary = "A lil' TOML parser" -groups = ["dev-hosting-http", "tests", "unit-tests"] -marker = "python_version < \"3.11\"" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "trio" -version = "0.30.0" -requires_python = ">=3.9" -summary = "A friendly Python library for async concurrency and I/O" -groups = ["tests"] -dependencies = [ - "attrs>=23.2.0", - "cffi>=1.14; os_name == \"nt\" and implementation_name != \"pypy\"", - "exceptiongroup; python_version < \"3.11\"", - "idna", - "outcome", - "sniffio>=1.3.0", - "sortedcontainers", -] -files = [ - {file = "trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5"}, - {file = "trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df"}, -] - -[[package]] -name = "trustme" -version = "1.2.1" -requires_python = ">=3.9" -summary = "#1 quality TLS certs while you wait, for the discerning tester" -groups = ["tests"] +name = "testcontainers" +version = "4.14.0" +extras = ["google", "localstack", "nats"] +requires_python = ">=3.10" +summary = "Python library for throwaway instances of anything that can run in a Docker container" +groups = ["integration-tests"] dependencies = [ - "cryptography>=3.1", - "idna>=2.0", + "boto3<2,>=1", + "google-cloud-datastore<3,>=2", + "google-cloud-pubsub<3,>=2", + "nats-py<3,>=2", + "testcontainers==4.14.0", ] files = [ - {file = "trustme-1.2.1-py3-none-any.whl", hash = "sha256:d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a"}, - {file = "trustme-1.2.1.tar.gz", hash = "sha256:6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f"}, + {file = "testcontainers-4.14.0-py3-none-any.whl", hash = "sha256:64e79b6b1e6d2b9b9e125539d35056caab4be739f7b7158c816d717f3596fa59"}, + {file = "testcontainers-4.14.0.tar.gz", hash = "sha256:3b2d4fa487af23024f00fcaa2d1cf4a5c6ad0c22e638a49799813cb49b3176c7"}, ] [[package]] -name = "truststore" -version = "0.10.1" -requires_python = ">=3.10" -summary = "Verify certificates using native system trust stores" -groups = ["tests"] -marker = "python_version >= \"3.10\"" +name = "tomli" +version = "2.3.0" +requires_python = ">=3.8" +summary = "A lil' TOML parser" +groups = ["dev-hosting-http", "tests", "unit-tests"] +marker = "python_version < \"3.11\"" files = [ - {file = "truststore-0.10.1-py3-none-any.whl", hash = "sha256:b64e6025a409a43ebdd2807b0c41c8bff49ea7ae6550b5087ac6df6619352d4c"}, - {file = "truststore-0.10.1.tar.gz", hash = "sha256:eda021616b59021812e800fa0a071e51b266721bef3ce092db8a699e21c63539"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] [[package]] name = "types-aioboto3-lite" -version = "15.0.0" +version = "15.5.0" requires_python = ">=3.8" -summary = "Lite type annotations for aioboto3 15.0.0 generated with mypy-boto3-builder 8.11.0" +summary = "Lite type annotations for aioboto3 15.5.0 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ "botocore-stubs", @@ -2857,87 +3125,87 @@ dependencies = [ "typing-extensions>=4.1.0; python_version < \"3.12\"", ] files = [ - {file = "types_aioboto3_lite-15.0.0-py3-none-any.whl", hash = "sha256:90857d96330e5fd8fe7aa1838b7f638a4ed5264bab3ac453a55ae51e9479c208"}, - {file = "types_aioboto3_lite-15.0.0.tar.gz", hash = "sha256:b178dba5dc1ada5402f7afc01ca70edf5d0e76d993d27115a42639ed4d62e061"}, + {file = "types_aioboto3_lite-15.5.0-py3-none-any.whl", hash = "sha256:851430dca47508fbdaca86d421b48dea4dee97d1d085ca9483c2e87b783e6de7"}, + {file = "types_aioboto3_lite-15.5.0.tar.gz", hash = "sha256:a6a7bad0fb36b8e6a3e16767ddcb69637148216319312b5b1e5e537f998aee3c"}, ] [[package]] name = "types-aioboto3-lite" -version = "15.0.0" +version = "15.5.0" extras = ["sqs"] requires_python = ">=3.8" -summary = "Lite type annotations for aioboto3 15.0.0 generated with mypy-boto3-builder 8.11.0" +summary = "Lite type annotations for aioboto3 15.5.0 generated with mypy-boto3-builder 8.11.0" groups = ["dev-types"] dependencies = [ - "types-aioboto3-lite==15.0.0", - "types-aiobotocore-sqs<2.24.0,>=2.23.0", + "types-aioboto3-lite==15.5.0", + "types-aiobotocore-sqs<2.26.0,>=2.25.0", ] files = [ - {file = "types_aioboto3_lite-15.0.0-py3-none-any.whl", hash = "sha256:90857d96330e5fd8fe7aa1838b7f638a4ed5264bab3ac453a55ae51e9479c208"}, - {file = "types_aioboto3_lite-15.0.0.tar.gz", hash = "sha256:b178dba5dc1ada5402f7afc01ca70edf5d0e76d993d27115a42639ed4d62e061"}, + {file = "types_aioboto3_lite-15.5.0-py3-none-any.whl", hash = "sha256:851430dca47508fbdaca86d421b48dea4dee97d1d085ca9483c2e87b783e6de7"}, + {file = "types_aioboto3_lite-15.5.0.tar.gz", hash = "sha256:a6a7bad0fb36b8e6a3e16767ddcb69637148216319312b5b1e5e537f998aee3c"}, ] [[package]] name = "types-aiobotocore-lite" -version = "2.23.0" -requires_python = ">=3.8" -summary = "Lite type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" +version = "2.25.2" +requires_python = ">=3.9" +summary = "Lite type annotations for aiobotocore 2.25.2 generated with mypy-boto3-builder 8.12.0" groups = ["dev-types"] dependencies = [ "botocore-stubs", "typing-extensions>=4.1.0; python_version < \"3.12\"", ] files = [ - {file = "types_aiobotocore_lite-2.23.0-py3-none-any.whl", hash = "sha256:2a04fc8f321ce74d08db99b37514c14fc674a9ecde49ce972f3f69bac0d4d64d"}, - {file = "types_aiobotocore_lite-2.23.0.tar.gz", hash = "sha256:a65746c4e4d2fe070a785e8d7206046215711159b1e5c27f294d9a8820d5f553"}, + {file = "types_aiobotocore_lite-2.25.2-py3-none-any.whl", hash = "sha256:a4592006240dc2d338bfd4aca9a84235bd2636d115227ab274ec1b6e214fac77"}, + {file = "types_aiobotocore_lite-2.25.2.tar.gz", hash = "sha256:43596309690898f21c5a71f21da1d58af77180db9118c549b9226eba3aab8ecc"}, ] [[package]] name = "types-aiobotocore-lite" -version = "2.23.0" +version = "2.25.2" extras = ["sqs"] -requires_python = ">=3.8" -summary = "Lite type annotations for aiobotocore 2.23.0 generated with mypy-boto3-builder 8.11.0" +requires_python = ">=3.9" +summary = "Lite type annotations for aiobotocore 2.25.2 generated with mypy-boto3-builder 8.12.0" groups = ["dev-types"] dependencies = [ - "types-aiobotocore-lite==2.23.0", - "types-aiobotocore-sqs<2.24.0,>=2.23.0", + "types-aiobotocore-lite==2.25.2", + "types-aiobotocore-sqs<2.26.0,>=2.25.0", ] files = [ - {file = "types_aiobotocore_lite-2.23.0-py3-none-any.whl", hash = "sha256:2a04fc8f321ce74d08db99b37514c14fc674a9ecde49ce972f3f69bac0d4d64d"}, - {file = "types_aiobotocore_lite-2.23.0.tar.gz", hash = "sha256:a65746c4e4d2fe070a785e8d7206046215711159b1e5c27f294d9a8820d5f553"}, + {file = "types_aiobotocore_lite-2.25.2-py3-none-any.whl", hash = "sha256:a4592006240dc2d338bfd4aca9a84235bd2636d115227ab274ec1b6e214fac77"}, + {file = "types_aiobotocore_lite-2.25.2.tar.gz", hash = "sha256:43596309690898f21c5a71f21da1d58af77180db9118c549b9226eba3aab8ecc"}, ] [[package]] name = "types-aiobotocore-sqs" -version = "2.23.0" -requires_python = ">=3.8" -summary = "Type annotations for aiobotocore SQS 2.23.0 service generated with mypy-boto3-builder 8.11.0" +version = "2.25.2" +requires_python = ">=3.9" +summary = "Type annotations for aiobotocore SQS 2.25.2 service generated with mypy-boto3-builder 8.12.0" groups = ["dev-types"] dependencies = [ "typing-extensions; python_version < \"3.12\"", ] files = [ - {file = "types_aiobotocore_sqs-2.23.0-py3-none-any.whl", hash = "sha256:88f1bd8b0767d40ef8b607c1158f4e80309bf99e9678657cc1fd671efc09caea"}, - {file = "types_aiobotocore_sqs-2.23.0.tar.gz", hash = "sha256:1701584bb8e6b89cbe7297f56205f0d26b42d7e0241ce9aa1779c0433cdbb29c"}, + {file = "types_aiobotocore_sqs-2.25.2-py3-none-any.whl", hash = "sha256:04c5f20bc8da8f02d6db20d2369fedf8012596829a61aa40c68ff83d3970bcb3"}, + {file = "types_aiobotocore_sqs-2.25.2.tar.gz", hash = "sha256:60b38f4ede6d83cc6bd82db2b0721a983cd5010f1cfa78edaa8e5b380917b077"}, ] [[package]] name = "types-awscrt" -version = "0.27.4" +version = "0.30.0" requires_python = ">=3.8" summary = "Type annotations and code completion for awscrt" groups = ["dev-types"] files = [ - {file = "types_awscrt-0.27.4-py3-none-any.whl", hash = "sha256:a8c4b9d9ae66d616755c322aba75ab9bd793c6fef448917e6de2e8b8cdf66fb4"}, - {file = "types_awscrt-0.27.4.tar.gz", hash = "sha256:c019ba91a097e8a31d6948f6176ede1312963f41cdcacf82482ac877cbbcf390"}, + {file = "types_awscrt-0.30.0-py3-none-any.whl", hash = "sha256:8204126e01a00eaa4a746e7a0076538ca0e4e3f52408adec0ab9b471bb0bb64b"}, + {file = "types_awscrt-0.30.0.tar.gz", hash = "sha256:362fd8f5eaebcfcd922cb9fd8274fb375df550319f78031ee3779eac0b9ecc79"}, ] [[package]] name = "types-boto3-lite" -version = "1.39.4" -requires_python = ">=3.8" -summary = "Lite type annotations for boto3 1.39.4 generated with mypy-boto3-builder 8.11.0" +version = "1.42.24" +requires_python = ">=3.9" +summary = "Lite type annotations for boto3 1.42.24 generated with mypy-boto3-builder 8.12.0" groups = ["dev-types"] dependencies = [ "botocore-stubs", @@ -2945,98 +3213,98 @@ dependencies = [ "typing-extensions>=4.1.0; python_version < \"3.12\"", ] files = [ - {file = "types_boto3_lite-1.39.4-py3-none-any.whl", hash = "sha256:c705dcadfc75645e12b6ce04810f079d216f8284c141e33f62695cdf6340824f"}, - {file = "types_boto3_lite-1.39.4.tar.gz", hash = "sha256:9bfd8e136da1c0f391a57c390e2856ea8ead6e5644cd6ebd3864b8256e0cebef"}, + {file = "types_boto3_lite-1.42.24-py3-none-any.whl", hash = "sha256:c31c4be0262ccf9ca91bc2a38aee357f3a4e9530bf125543bc002d185dc31135"}, + {file = "types_boto3_lite-1.42.24.tar.gz", hash = "sha256:be0e425bc02759949963eca86cb405f12922377052caa5dd01cbe5fd16dff9fd"}, ] [[package]] name = "types-boto3-lite" -version = "1.39.4" +version = "1.42.24" extras = ["sqs"] -requires_python = ">=3.8" -summary = "Lite type annotations for boto3 1.39.4 generated with mypy-boto3-builder 8.11.0" +requires_python = ">=3.9" +summary = "Lite type annotations for boto3 1.42.24 generated with mypy-boto3-builder 8.12.0" groups = ["dev-types"] dependencies = [ - "types-boto3-lite==1.39.4", - "types-boto3-sqs<1.40.0,>=1.39.0", + "types-boto3-lite==1.42.24", + "types-boto3-sqs<1.43.0,>=1.42.0", ] files = [ - {file = "types_boto3_lite-1.39.4-py3-none-any.whl", hash = "sha256:c705dcadfc75645e12b6ce04810f079d216f8284c141e33f62695cdf6340824f"}, - {file = "types_boto3_lite-1.39.4.tar.gz", hash = "sha256:9bfd8e136da1c0f391a57c390e2856ea8ead6e5644cd6ebd3864b8256e0cebef"}, + {file = "types_boto3_lite-1.42.24-py3-none-any.whl", hash = "sha256:c31c4be0262ccf9ca91bc2a38aee357f3a4e9530bf125543bc002d185dc31135"}, + {file = "types_boto3_lite-1.42.24.tar.gz", hash = "sha256:be0e425bc02759949963eca86cb405f12922377052caa5dd01cbe5fd16dff9fd"}, ] [[package]] name = "types-boto3-sqs" -version = "1.39.0" -requires_python = ">=3.8" -summary = "Type annotations for boto3 SQS 1.39.0 service generated with mypy-boto3-builder 8.11.0" +version = "1.42.3" +requires_python = ">=3.9" +summary = "Type annotations for boto3 SQS 1.42.3 service generated with mypy-boto3-builder 8.12.0" groups = ["dev-types"] dependencies = [ "typing-extensions; python_version < \"3.12\"", ] files = [ - {file = "types_boto3_sqs-1.39.0-py3-none-any.whl", hash = "sha256:bbe1d206443d29660ae2630e4558f90e04f89fe99e7010d0f6be1a921734ced4"}, - {file = "types_boto3_sqs-1.39.0.tar.gz", hash = "sha256:aa39971515da52047db7a944bb2ded0de509f9d5395995821d71966569e32cba"}, + {file = "types_boto3_sqs-1.42.3-py3-none-any.whl", hash = "sha256:9290509e99f22464d39cba39feb8034b295ca312a84e43f8c7ad9b511c488e40"}, + {file = "types_boto3_sqs-1.42.3.tar.gz", hash = "sha256:b7df81d6f1cc94ac9d59ee8ddafb21b1c4e9c1140960156c55e19b1cdc3358e3"}, ] [[package]] name = "types-croniter" -version = "6.0.0.20250626" +version = "6.0.0.20250809" requires_python = ">=3.9" summary = "Typing stubs for croniter" groups = ["dev-types"] files = [ - {file = "types_croniter-6.0.0.20250626-py3-none-any.whl", hash = "sha256:3e8c37b54b541f323b2c487f3a1c9dcb27a7092333a2d5c09fff0c4d41c68380"}, - {file = "types_croniter-6.0.0.20250626.tar.gz", hash = "sha256:c32243b16d4dfa7c9989a5eadc6762459d093dded023f3c363fdee6b96578a77"}, + {file = "types_croniter-6.0.0.20250809-py3-none-any.whl", hash = "sha256:d9f53f3e837eb6af509e2090fd2f5bb29b38425dd78f77d7b3bf37ccd2b2bf93"}, + {file = "types_croniter-6.0.0.20250809.tar.gz", hash = "sha256:c829295d4d65eaddcfafec905b0fbab59e72c3c91ee934a4d504dcafad79ff95"}, ] [[package]] name = "types-grpcio" -version = "1.0.0.20250703" +version = "1.0.0.20251009" requires_python = ">=3.9" summary = "Typing stubs for grpcio" groups = ["dev-types"] files = [ - {file = "types_grpcio-1.0.0.20250703-py3-none-any.whl", hash = "sha256:78d1bfc33b58a56697ef99e666e34be4c6887631341c75fdd28d58587aef5d9f"}, - {file = "types_grpcio-1.0.0.20250703.tar.gz", hash = "sha256:baf100184e5353cb60f045fb4fd47f37a360bedf0f19581535e4c3a3a1f7912b"}, + {file = "types_grpcio-1.0.0.20251009-py3-none-any.whl", hash = "sha256:112ac4312a5b0a273a4c414f7f2c7668f342990d9c6ab0f647391c36331f95ed"}, + {file = "types_grpcio-1.0.0.20251009.tar.gz", hash = "sha256:a8f615ea7a47b31f10da028ab5258d4f1611fbd70719ca450fc0ab3fb9c62b63"}, ] [[package]] name = "types-protobuf" -version = "6.30.2.20250703" +version = "6.32.1.20251210" requires_python = ">=3.9" summary = "Typing stubs for protobuf" groups = ["dev-types"] files = [ - {file = "types_protobuf-6.30.2.20250703-py3-none-any.whl", hash = "sha256:fa5aff9036e9ef432d703abbdd801b436a249b6802e4df5ef74513e272434e57"}, - {file = "types_protobuf-6.30.2.20250703.tar.gz", hash = "sha256:609a974754bbb71fa178fc641f51050395e8e1849f49d0420a6281ed8d1ddf46"}, + {file = "types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140"}, + {file = "types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763"}, ] [[package]] name = "types-s3transfer" -version = "0.13.0" -requires_python = ">=3.8" +version = "0.16.0" +requires_python = ">=3.9" summary = "Type annotations and code completion for s3transfer" groups = ["dev-types"] files = [ - {file = "types_s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:79c8375cbf48a64bff7654c02df1ec4b20d74f8c5672fc13e382f593ca5565b3"}, - {file = "types_s3transfer-0.13.0.tar.gz", hash = "sha256:203dadcb9865c2f68fb44bc0440e1dc05b79197ba4a641c0976c26c9af75ef52"}, + {file = "types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef"}, + {file = "types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443"}, ] [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "azure-queue", "azure-servicebus", "dev", "dev-consumers", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "pubsub", "rabbitmq", "tests", "unit-tests"] +groups = ["default", "azure-queue", "azure-servicebus", "dev", "dev-consumers", "dev-hosting-grpc", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "pubsub", "rabbitmq", "tests", "unit-tests"] files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" requires_python = ">=3.9" summary = "Runtime typing introspection tools" groups = ["examples"] @@ -3044,37 +3312,37 @@ dependencies = [ "typing-extensions>=4.12.0", ] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" requires_python = ">=2" summary = "Provider of IANA time zone data" groups = ["examples"] marker = "sys_platform == \"win32\"" files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" requires_python = ">=3.9" summary = "HTTP library with thread-safe connection pooling, file post, and more." groups = ["azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "integration-tests", "pubsub", "sqs"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [[package]] name = "uvicorn" -version = "0.35.0" -requires_python = ">=3.9" +version = "0.40.0" +requires_python = ">=3.10" summary = "The lightning-fast ASGI server." groups = ["dev-hosting-http"] dependencies = [ @@ -3083,128 +3351,98 @@ dependencies = [ "typing-extensions>=4.0; python_version < \"3.11\"", ] files = [ - {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, - {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, -] - -[[package]] -name = "uvloop" -version = "0.21.0" -requires_python = ">=3.8.0" -summary = "Fast implementation of asyncio event loop on top of libuv" -groups = ["tests"] -marker = "platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\"" -files = [ - {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, - {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, - {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"}, - {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"}, - {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"}, - {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"}, - {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, - {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, - {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, - {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, - {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, - {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, - {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, - {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, - {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, - {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, - {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, - {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, - {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"}, - {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"}, - {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"}, - {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"}, - {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"}, - {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"}, - {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, + {file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"}, + {file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"}, ] [[package]] name = "wrapt" -version = "1.17.2" +version = "1.17.3" requires_python = ">=3.8" summary = "Module for decorators, wrappers and monkey patching." groups = ["dev-consumers", "dev-otel", "integration-tests"] files = [ - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, - {file = "wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72"}, - {file = "wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c"}, - {file = "wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62"}, - {file = "wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563"}, - {file = "wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda"}, - {file = "wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000"}, - {file = "wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662"}, - {file = "wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72"}, - {file = "wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317"}, - {file = "wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392"}, - {file = "wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b"}, - {file = "wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae"}, - {file = "wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9"}, - {file = "wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9"}, - {file = "wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998"}, - {file = "wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6"}, - {file = "wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b"}, - {file = "wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504"}, - {file = "wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a"}, - {file = "wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b"}, - {file = "wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb"}, - {file = "wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6"}, - {file = "wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f"}, - {file = "wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555"}, - {file = "wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c"}, - {file = "wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8"}, - {file = "wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, + {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, + {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, + {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, + {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, + {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, + {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, + {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, + {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, + {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, + {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, + {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, + {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, + {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, + {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, + {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, + {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, + {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, + {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, + {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, + {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, + {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, + {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, + {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, + {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, + {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, + {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, + {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, + {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, + {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, + {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, + {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, + {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, + {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, + {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, + {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, + {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, + {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, ] [[package]] name = "wsproto" -version = "1.2.0" -requires_python = ">=3.7.0" -summary = "WebSockets state-machine based protocol implementation" +version = "1.3.2" +requires_python = ">=3.10" +summary = "Pure-Python WebSocket protocol implementation" groups = ["dev-hosting-http"] dependencies = [ - "h11<1,>=0.9.0", + "h11<1,>=0.16.0", ] files = [ - {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, - {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, + {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, + {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, ] [[package]] name = "yarl" -version = "1.20.1" +version = "1.22.0" requires_python = ">=3.9" summary = "Yet another URL library" groups = ["dev-consumers", "rabbitmq"] @@ -3214,93 +3452,120 @@ dependencies = [ "propcache>=0.2.1", ] files = [ - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, - {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, - {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, - {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, - {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, - {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, - {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, - {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, - {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, - {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, - {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, - {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, - {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, - {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, - {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, - {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, - {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, - {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, - {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, - {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, - {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, - {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, - {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, - {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, - {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, - {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, - {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, - {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, ] [[package]] @@ -3308,7 +3573,7 @@ name = "zipp" version = "3.23.0" requires_python = ">=3.9" summary = "Backport of pathlib-compatible object wrapper for zip files" -groups = ["dev-otel", "pubsub"] +groups = ["dev-otel", "integration-tests", "pubsub"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, diff --git a/pyproject.toml b/pyproject.toml index 384fa84..7195d6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,7 +139,7 @@ unit-tests = [ ] integration-tests = [ "boto3 ~=1.38", - "testcontainers ~=4.10", + "testcontainers[google,localstack,nats] ~=4.10", ] [tool.coverage.run] diff --git a/tests/consumers/kafka.py b/tests/consumers/kafka.py index 69a7e36..1e46b7a 100644 --- a/tests/consumers/kafka.py +++ b/tests/consumers/kafka.py @@ -25,9 +25,12 @@ def local_kafka(): yield conn_config -async def test_normal_case(local_kafka): - topic_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) +@pytest.fixture() +def topic_name(): + return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + +async def test_happy_path(local_kafka, topic_name): # Arrange sent = ["London: cloudy", "Paris: rainy"] @@ -60,7 +63,7 @@ def handle(m: KafkaMessage) -> None: assert received == sent -async def test_batching(local_kafka): +async def test_batching(local_kafka, topic_name): topic_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) # Arrange diff --git a/tests/consumers/sqs_aioboto.py b/tests/consumers/sqs_aioboto.py index 58382b5..cee3cc8 100644 --- a/tests/consumers/sqs_aioboto.py +++ b/tests/consumers/sqs_aioboto.py @@ -39,9 +39,12 @@ def local_sqs(): yield conn_params -async def test_happy_path(local_sqs): - queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) +@pytest.fixture() +def queue_name(): + return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + +async def test_happy_path(local_sqs, queue_name): # Arrange sent = ["hello", "world", "!"] @@ -71,11 +74,10 @@ async def handle(m: SqsMessage): # Assert - # TODO Check messages are deleted from the queue - # (https://stackoverflow.com/questions/25351145/get-number-of-messages-in-an-amazon-sqs-queue) assert host.status["exception"] is None assert received == sent + # Make sure that the messages were deleted from the queue queue_info = sqs_client.get_queue_attributes( QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) @@ -83,14 +85,12 @@ async def handle(m: SqsMessage): assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 -async def test_handler_manager(local_sqs): +async def test_handler_manager(): # Test handler lifecycle (via handler manager) pass -async def test_batching(local_sqs): - queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - +async def test_batching(local_sqs, queue_name): # Arrange sent = ["hello", "world", "!"] @@ -124,6 +124,7 @@ async def handle(messages: SqsMessages): assert host.status["exception"] is None assert received == [sent] + # Make sure that the messages were deleted from the queue queue_info = sqs_client.get_queue_attributes( QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) diff --git a/tests/consumers/sqs_boto.py b/tests/consumers/sqs_boto.py index 425043d..406f5d6 100644 --- a/tests/consumers/sqs_boto.py +++ b/tests/consumers/sqs_boto.py @@ -29,9 +29,12 @@ def local_sqs(): yield conn_params -async def test_happy_path(local_sqs): - queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) +@pytest.fixture() +def queue_name(): + return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + +async def test_happy_path(local_sqs, queue_name): # Arrange sent = ["hello", "world", "!"] @@ -60,11 +63,10 @@ async def handle(m: SqsMessage): # Assert - # TODO Check messages are deleted from the queue - # (https://stackoverflow.com/questions/25351145/get-number-of-messages-in-an-amazon-sqs-queue) assert host.status["exception"] is None assert received == sent + # Make sure that the messages were deleted from the queue queue_info = sqs_client.get_queue_attributes( QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) @@ -72,14 +74,12 @@ async def handle(m: SqsMessage): assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 -async def test_handler_manager(local_sqs): +async def test_handler_manager(): # Test handler lifecycle (via handler manager) pass -async def test_batching(local_sqs): - queue_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - +async def test_batching(local_sqs, queue_name): # Arrange sent = ["hello", "world", "!"] @@ -112,6 +112,7 @@ async def handle(messages: SqsMessages): assert host.status["exception"] is None assert received == [sent] + # Make sure that the messages were deleted from the queue queue_info = sqs_client.get_queue_attributes( QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) From 0dc66aafa18e635a385622b2cf9e1e37f3d7eea3 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 3 Feb 2026 20:37:39 +0000 Subject: [PATCH 006/286] WIP (everything else) --- examples/consumers/sqs/app.py | 59 ++++ examples/consumers/sqs/app_lambda.py | 27 ++ examples/lifespan.py | 1 + localpost/consumers/async_queue.py | 114 ++++++++ localpost/consumers/azure_queue.py | 8 + localpost/consumers/azure_servicebus.py | 6 + localpost/consumers/nats.py | 0 localpost/consumers/pubsub.py | 354 ++++++++++++++++++++++++ localpost/consumers/queue.py | 86 ++++++ localpost/flow/_queue.py | 95 +++++++ tests/consumers/pubsub.py | 102 +++++++ tests/consumers/pubsub_async.py | 1 + tests/hosting/app_host.py | 11 + tests/hosting/service_middlewares.py | 18 ++ tests/scheduler/scheduler.py | 60 ++++ 15 files changed, 942 insertions(+) create mode 100755 examples/consumers/sqs/app.py create mode 100755 examples/consumers/sqs/app_lambda.py create mode 100644 examples/lifespan.py create mode 100644 localpost/consumers/async_queue.py create mode 100644 localpost/consumers/azure_queue.py create mode 100644 localpost/consumers/azure_servicebus.py create mode 100644 localpost/consumers/nats.py create mode 100644 localpost/consumers/pubsub.py create mode 100644 localpost/consumers/queue.py create mode 100644 localpost/flow/_queue.py create mode 100644 tests/consumers/pubsub.py create mode 100644 tests/consumers/pubsub_async.py create mode 100644 tests/hosting/app_host.py create mode 100644 tests/hosting/service_middlewares.py create mode 100644 tests/scheduler/scheduler.py diff --git a/examples/consumers/sqs/app.py b/examples/consumers/sqs/app.py new file mode 100755 index 0000000..2c9a688 --- /dev/null +++ b/examples/consumers/sqs/app.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python + +import os + +from localpost import flow +from localpost.consumers import sqs_otel +from localpost.consumers.sqs import SqsMessage, sqs_queue_consumer + + +@sqs_queue_consumer("weather-forecasts") +@sqs_otel.trace() +@flow.handler +async def handle_message(message: SqsMessage): + import random + + import anyio + + with message: + print(f"Message received: {message.body}") + await anyio.sleep(random.uniform(0.01, 1.5)) # Simulate some work + + +def configure_otel(): + from opentelemetry import metrics, trace + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + trace_provider = TracerProvider() + processor = BatchSpanProcessor(OTLPSpanExporter()) + trace_provider.add_span_processor(processor) + trace.set_tracer_provider(trace_provider) + + reader = PeriodicExportingMetricReader(OTLPMetricExporter()) + meter_provider = MeterProvider(metric_readers=[reader]) + metrics.set_meter_provider(meter_provider) + + +if __name__ == "__main__": + import logging + + import localpost + + os.environ["AWS_ENDPOINT_URL"] = "http://127.0.0.1:4566" # Localstack + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + os.environ["AWS_ACCESS_KEY_ID"] = "test" + os.environ["AWS_SECRET_ACCESS_KEY"] = "test" + os.environ["OTEL_SERVICE_NAME"] = "sample_sqs_consumer" + os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:18889" # Aspire Dashboard + os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "grpc" + + logging.basicConfig() + logging.getLogger().setLevel(logging.INFO) + logging.getLogger("localpost").setLevel(logging.DEBUG) + + exit(localpost.run(handle_message)) diff --git a/examples/consumers/sqs/app_lambda.py b/examples/consumers/sqs/app_lambda.py new file mode 100755 index 0000000..a0ab795 --- /dev/null +++ b/examples/consumers/sqs/app_lambda.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python + +from localpost.consumers.sqs import lambda_handler, sqs_queue_consumer + + +@sqs_queue_consumer("weather-forecasts") +@lambda_handler +async def handle_message(event, context): + import random + + import anyio + + messages = event["Records"] + print(f"Messages received: {messages}") + await anyio.sleep(random.uniform(0.01, 1.5)) # Simulate some work + + +if __name__ == "__main__": + import logging + + import localpost + + logging.basicConfig() + logging.getLogger().setLevel(logging.INFO) + logging.getLogger("localpost").setLevel(logging.DEBUG) + + exit(localpost.run(handle_message)) diff --git a/examples/lifespan.py b/examples/lifespan.py new file mode 100644 index 0000000..b1dd741 --- /dev/null +++ b/examples/lifespan.py @@ -0,0 +1 @@ +# TODO Implement diff --git a/localpost/consumers/async_queue.py b/localpost/consumers/async_queue.py new file mode 100644 index 0000000..fa26fb2 --- /dev/null +++ b/localpost/consumers/async_queue.py @@ -0,0 +1,114 @@ +import asyncio +import dataclasses as dc +import logging +import math +import sys +from collections.abc import Callable +from typing import Generic, TypeVar, cast + +from anyio import ClosedResourceError, EndOfStream +from anyio.abc import ObjectReceiveStream + +from localpost._utils import NOT_SET, Event, wait_any +from localpost.flow import AnyHandlerManager, ensure_async_handler +from localpost.flow._stream import create_stream_consumer +from localpost.hosting import ServiceLifetimeManager + +if sys.version_info >= (3, 13): + from asyncio import QueueShutDown +else: + + class QueueShutDown(Exception): + pass + + +T = TypeVar("T") + +__all__ = ["async_queue_consumer"] + +logger = logging.getLogger(__name__) + + +@dc.dataclass(frozen=True, slots=True, eq=False) +class _AsyncQueueAdapter(ObjectReceiveStream[T]): + source: asyncio.Queue[T] + closed: Event = dc.field(default_factory=Event) + + async def receive(self) -> T: + if self.closed: + raise ClosedResourceError() + source = self.source + item = cast(T, NOT_SET) + + async def get_item() -> None: + nonlocal item + item = (await source.get()) if source.empty() else source.get_nowait() + + try: + await wait_any(get_item, self.closed) + if item is NOT_SET: + raise ClosedResourceError() + return item + except QueueShutDown: + raise EndOfStream() from None + + async def aclose(self) -> None: + # Because it's not possible to close just the receiver end for asyncio.Queue + self.closed.set() + + +class AsyncQueueConsumerService(Generic[T]): + def __init__( + self, + target: asyncio.Queue[T], + handler_m: AnyHandlerManager[T], + /, + *, + concurrency: int | float, + process_leftovers: bool, + task_tracking: bool, + ): + if not (math.isinf(concurrency) or (isinstance(concurrency, int) and concurrency >= 1)): + raise ValueError("Number of consumers must be at least 1") + + self.target = target + self.handler_m = handler_m + self.concurrency = concurrency + self.process_leftovers = process_leftovers + self.task_tracking = task_tracking + + async def __call__(self, service_lifetime: ServiceLifetimeManager): + queue = self.target + stream = _AsyncQueueAdapter(queue) + async with self.handler_m as handler: + handle_async = ensure_async_handler(handler, max_threads=self.concurrency) + + async def handle_and_ack(item: T) -> None: + await handle_async(item) + queue.task_done() + + async with create_stream_consumer( + stream, + handle_and_ack if self.task_tracking else handle_async, + concurrency=self.concurrency, + ) as consumer_state: + service_lifetime.set_started() + # TODO Timeout, if the queue is not closed at all + if not self.process_leftovers: + await wait_any(service_lifetime.shutting_down, consumer_state.closed) + await stream.aclose() + # Otherwise just wait for the stream to be exhausted and closed + + +def async_queue_consumer( + target: asyncio.Queue[T], + /, + *, + concurrency: int = 1, + process_leftovers: bool = True, + task_tracking: bool = True, +) -> Callable[[AnyHandlerManager[T]], AsyncQueueConsumerService[T]]: + """Decorator to create an asyncio.Queue consumer hosted service.""" + return lambda handler_m: AsyncQueueConsumerService( + target, handler_m, concurrency=concurrency, process_leftovers=process_leftovers, task_tracking=task_tracking + ) diff --git a/localpost/consumers/azure_queue.py b/localpost/consumers/azure_queue.py new file mode 100644 index 0000000..94c2a89 --- /dev/null +++ b/localpost/consumers/azure_queue.py @@ -0,0 +1,8 @@ +""" +Azure Queue Storage consumer. +""" + +# See: +# - https://pypi.org/project/azure-storage-queue/ +# - https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/CLIENT_LIBRARY_DEVELOPER.md#transport +# - https://learn.microsoft.com/en-us/azure/storage/queues/storage-quickstart-queues-python diff --git a/localpost/consumers/azure_servicebus.py b/localpost/consumers/azure_servicebus.py new file mode 100644 index 0000000..0102427 --- /dev/null +++ b/localpost/consumers/azure_servicebus.py @@ -0,0 +1,6 @@ +""" +Azure ServiceBus consumer. +""" + +# See: +# - https://learn.microsoft.com/en-us/python/api/overview/azure/servicebus-readme#receive-messages-from-a-queue diff --git a/localpost/consumers/nats.py b/localpost/consumers/nats.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/consumers/pubsub.py b/localpost/consumers/pubsub.py new file mode 100644 index 0000000..2fded06 --- /dev/null +++ b/localpost/consumers/pubsub.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import dataclasses as dc +import logging +import math +from collections.abc import Callable, Iterable, Sequence +from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager +from functools import partial +from typing import Final, TypeAlias, final, overload + +from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread +from google.api_core import retry +from google.api_core.exceptions import AlreadyExists +from google.api_core.gapic_v1.method import DEFAULT +import google.pubsub_v1 as pubsub +from google.pubsub_v1 import SubscriberAsyncClient, SubscriberClient + +from localpost._utils import MemoryStream +from localpost.flow import ( + AnyHandlerManager, + AsyncHandler, + SyncHandler, + ensure_async_handler, + ensure_sync_handler, +) +from localpost.flow._stream import BatchReceiver, create_stream_consumer +from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager + +__all__ = [ + "PubSubMessage", + "PubSubMessages", + "ConsumerClient", + "AsyncConsumerClient", + "client_factory", + "async_client_factory", + "pubsub_consumer", +] + +from localpost.hosting.utils import ThreadSafeMemorySendStream, ThreadSafeSendStream + +logger = logging.getLogger(__name__) + +# Timeout for a sync pull request, to check the application state (and exit gracefully on shutdown) +CHECK_INTERVAL: Final = 3.0 # seconds + +_EMPTY_RECEIVE: Final[Sequence[pubsub.ReceivedMessage]] = () # Empty response from pull() + + +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class PubSubMessage(Sequence["PubSubMessage"], AbstractContextManager[bytes, None]): + payload: pubsub.ReceivedMessage + client: ConsumerClient | AsyncConsumerClient + ack_queue: ThreadSafeSendStream[PubSubMessage] + + def __repr__(self): + return f"{self.__class__.__name__}(subscription_path={self.client.subscription_path!r})" + + def __len__(self): + return 1 + + def __getitem__(self, i): + if i == 0: + return self + raise IndexError() + + def __enter__(self) -> bytes: + return self.data + + def __exit__(self, exc_type, _, __) -> None: + if exc_type is None: + self.ack() + + def ack(self): + self.ack_queue.send_nowait(self) + + @property + def data(self) -> bytes: + return self.payload.message.data + + +@final +@dc.dataclass(frozen=True, slots=True) +class PubSubMessages(Sequence[PubSubMessage], AbstractContextManager[Sequence[bytes], None]): + """Non-empty batch of PubSub messages.""" + + source: Sequence[PubSubMessage] + + def __init__(self, source: Sequence[PubSubMessage]) -> None: + if isinstance(source, PubSubMessages): + source = source.source + if len(source) == 0: + raise ValueError(f"{self.__class__.__name__} must not be empty") + object.__setattr__(self, "source", source) + + def __repr__(self): + subscription_path = self.source[0].client.subscription_path + return f"{self.__class__.__name__}(len={len(self)}, subscription_path={subscription_path!r})" + + def __len__(self): + return len(self.source) + + def __getitem__(self, i) -> PubSubMessage: + return self.source[i] # type: ignore[return-value] + + def __enter__(self) -> Sequence[bytes]: + return self.data + + def __exit__(self, exc_type, _, __) -> None: + if exc_type is None: + for message in self.source: + message.ack() + + @property + def payload(self) -> Sequence[pubsub.ReceivedMessage]: + return [msg.payload for msg in self.source] + + @property + def data(self) -> Sequence[bytes]: + return [msg.data for msg in self.source] + + +@final +class ConsumerClient: + @classmethod + @asynccontextmanager + async def create( + cls, + subscription_path: str, # projects/{project}/subscriptions/{subscription} + topic_path: str, # projects/{project}/topics/{topic} + client: SubscriberClient | None = None, + /, + *, + max_messages: int = 100, + retry_policy: retry.Retry | object = DEFAULT, + ): + client = client or SubscriberClient() + try: + # In case the subscription needs to be customized, it can always be created in advance (so this call will + # end up with ALREADY_EXISTS response), see also: + # https://cloud.google.com/pubsub/docs/reference/error-codes + try: + logger.info("Creating subscription for pulling messages") + await to_thread.run_sync( + client.create_subscription, {"name": subscription_path, "topic": topic_path}) + except AlreadyExists: + logger.info("Subscription already exists, using it for pulling messages") + yield cls(client, subscription_path, topic_path, max_messages, retry_policy) + finally: + await to_thread.run_sync(client.transport.close) + + def __init__( + self, + client: SubscriberClient, + subscription_path: str, # projects/{project}/subscriptions/{subscription} + topic_path: str, # projects/{project}/topics/{topic} + max_messages: int, + retry_policy: retry.Retry | object = DEFAULT, + ): + self.client = client + self.subscription_path = subscription_path + self.subscription_name = topic_path + self.max_messages = max_messages + self.retry_policy = retry_policy + + def acknowledge(self, target: Iterable[PubSubMessage]) -> None: + request = {"subscription": self.subscription_path, "ack_ids": [msg.payload.ack_id for msg in target]} + self.client.acknowledge(request) + + def pull(self): + # The actual number of messages pulled may be smaller than max_messages + response = self.client.pull( + {"subscription": self.subscription_path, "max_messages": self.max_messages}, + retry=self.retry_policy or DEFAULT, + timeout=CHECK_INTERVAL, + ) + return response.received_messages + + +@final +class AsyncConsumerClient: + @classmethod + @asynccontextmanager + async def create( + cls, + subscription_path: str, + client: SubscriberAsyncClient | None = None, + /, + *, + max_messages: int = 100, + retry_policy: retry.AsyncRetry | object = DEFAULT, + ): + client = client or SubscriberAsyncClient() + try: + yield cls(client, subscription_path, max_messages, retry_policy) + finally: + await client.transport.close() + + def __init__( + self, + client: SubscriberAsyncClient, + subscription_path: str, + max_messages: int, + retry_policy: retry.AsyncRetry | object = DEFAULT, + ): + self.client = client + self.subscription_path = subscription_path + self.max_messages = max_messages + self.retry_policy = retry_policy + + async def acknowledge(self, target: Iterable[PubSubMessage]) -> None: + request = {"subscription": self.subscription_path, "ack_ids": [msg.payload.ack_id for msg in target]} + await self.client.acknowledge(request) + + async def pull(self): + # The actual number of messages pulled may be smaller than max_messages + response = await self.client.pull( + {"subscription": self.subscription_path, "max_messages": self.max_messages}, + retry=self.retry_policy or DEFAULT, + ) + return response.received_messages + + +AnyClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient | AsyncConsumerClient]] + + +# https://cloud.google.com/pubsub/docs/samples/pubsub-subscriber-sync-pull#pubsub_subscriber_sync_pull-python +async def _consume_sync( + client: ConsumerClient, + message_handler: SyncHandler[PubSubMessage], + ack_queue: ThreadSafeSendStream[PubSubMessage], + shutdown_scope: CancelScope, +): + def consume(): + while True: + from_thread.check_cancelled() + messages = client.pull() + if not messages: + continue # No messages received (empty queue or shutdown) + for m in messages: + message_handler(PubSubMessage(m, client, ack_queue)) + + # In Trio shutdown_scope.cancel_called can only be checked in async context + with shutdown_scope: + await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) + + +async def _consume_async( + client: AsyncConsumerClient, + message_handler: AsyncHandler[PubSubMessage], + ack_queue: ThreadSafeSendStream[PubSubMessage], + shutdown_scope: CancelScope, +): + while not shutdown_scope.cancel_called: + messages = _EMPTY_RECEIVE + with shutdown_scope: + messages = await client.pull() + if not messages: + continue # No messages received (empty queue or shutdown) + for m in messages: + await message_handler(PubSubMessage(m, client, ack_queue)) + + +def client_factory(subscription_path: str) -> Callable[[], AbstractAsyncContextManager[ConsumerClient]]: + """Default PubSub client factory.""" + return partial(ConsumerClient.create, subscription_path) + + +def async_client_factory(subscription_path: str) -> Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]]: + """Default PubSub async client factory.""" + return partial(AsyncConsumerClient.create, subscription_path) + + +@final +class PubSubConsumerService(ExposedServiceBase): + def __init__( + self, + cf: AnyClientFactory, + handler_m: AnyHandlerManager[PubSubMessage], + /, + *, + consumers: int, + ): + super().__init__() + + if consumers < 1: + raise ValueError("Number of consumers must be at least 1") + + self.client_factory = cf + self.handler_m = handler_m + self.num_consumers = consumers + + async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: + assert self.num_consumers > 0 + self._lifetime = service_lifetime # Expose service lifetime events + + ack_queue_writer, ack_queue_reader = MemoryStream.create(math.inf) + batched_ack_queue_reader = BatchReceiver[PubSubMessage, list[PubSubMessage]]( + ack_queue_reader, batch_size=100, batch_window=1.0 + ) + robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) + + async def acknowledge_messages(messages: Iterable[PubSubMessage]) -> None: + if isinstance(client, ConsumerClient): + await to_thread.run_sync(client.acknowledge, messages) + else: + await client.acknowledge(messages) + + # Graceful shutdown scopes, one per consumer task (thread) + consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] + + async with ( + self.client_factory() as client, + create_stream_consumer(batched_ack_queue_reader, acknowledge_messages, concurrency=math.inf), + ack_queue_writer, + self.handler_m as handler, + create_task_group() as tg, + ): + service_lifetime.set_started() + # Start pulling messages only after the whole app is started + await service_lifetime.host.started + if isinstance(client, ConsumerClient): + sync_m_handler = ensure_sync_handler(handler) + for cs in consumer_scopes: + tg.start_soon(_consume_sync, client, sync_m_handler, robust_ack_queue, cs) + else: + async_m_handler = ensure_async_handler(handler) + for cs in consumer_scopes: + tg.start_soon(_consume_async, client, async_m_handler, robust_ack_queue, cs) + await service_lifetime.shutting_down + for cs in consumer_scopes: + cs.cancel() + + +@overload +def pubsub_consumer( + subscription_path: str, /, *, num_consumers: int = 1 +) -> Callable[[AnyHandlerManager[PubSubMessage]], PubSubConsumerService]: + """Decorator to create a PubSub consumer hosted service.""" + + +@overload +def pubsub_consumer( + cf: AnyClientFactory, /, *, num_consumers: int = 1 +) -> Callable[[AnyHandlerManager[PubSubMessage]], PubSubConsumerService]: + """Decorator to create a PubSub consumer hosted service.""" + + +# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function +def pubsub_consumer( + cf_or_sp: AnyClientFactory | str, /, *, num_consumers: int = 1 +) -> Callable[[AnyHandlerManager[PubSubMessage]], PubSubConsumerService]: + cf = client_factory(cf_or_sp) if isinstance(cf_or_sp, str) else cf_or_sp + return lambda handler_m: PubSubConsumerService(cf, handler_m, consumers=num_consumers) diff --git a/localpost/consumers/queue.py b/localpost/consumers/queue.py new file mode 100644 index 0000000..aedcc9b --- /dev/null +++ b/localpost/consumers/queue.py @@ -0,0 +1,86 @@ +import logging +from collections.abc import Awaitable, Callable +from queue import Queue, SimpleQueue +from typing import Generic + +from anyio import CapacityLimiter, ClosedResourceError, create_task_group, to_thread +from typing_extensions import TypeVar + +from localpost._utils import Event, wait_any +from localpost.flow import AnyHandlerManager, ensure_sync_handler +from localpost.flow._queue import Receiver, ShutDown +from localpost.hosting import ServiceLifetimeManager + +T = TypeVar("T") + +__all__ = ["queue_consumer"] + +logger = logging.getLogger(__name__) + + +class QueueConsumerService(Generic[T]): + def __init__( + self, + target: Queue[T] | SimpleQueue[T], + handler_m: AnyHandlerManager[T], + /, + *, + consumers: int, + process_leftovers: bool, + task_tracking: bool | None, + ): + if not isinstance(consumers, int) or consumers < 1: + raise ValueError("Number of consumers must be at least 1") + + self.target = target + self.handler_m = handler_m + self.num_consumers = consumers + self.process_leftovers = process_leftovers + self.task_tracking = hasattr(target, "task_done") if task_tracking is None else task_tracking + + async def __call__(self, service_lifetime: ServiceLifetimeManager): + def source_items(): + try: + while True: + yield stream.receive() + except ShutDown: + logger.debug("Source queue has been completed, no more items to process") + except ClosedResourceError: + logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") + + def consume() -> None: + for item in source_items(): + handle(item) + if self.task_tracking: + self.target.task_done() # type: ignore + + def consumer_thread() -> Awaitable[None]: + return to_thread.run_sync(consume, limiter=threads_limiter) + + closed = Event() + stream = Receiver(self.target) + threads_limiter = CapacityLimiter(self.num_consumers) + async with self.handler_m as handler, create_task_group() as consumers_tg: + handle = ensure_sync_handler(handler) + for _ in range(self.num_consumers): + consumers_tg.start_soon(consumer_thread) + service_lifetime.set_started() + # TODO Timeout, if the queue is not closed at all + if not self.process_leftovers: + await wait_any(service_lifetime.shutting_down, closed) + stream.close() + # Otherwise just wait for the stream to be exhausted and closed + + +def queue_consumer( + target: Queue[T] | SimpleQueue[T], + /, + *, + consumers: int = 1, + process_leftovers: bool = True, + task_tracking: bool | None = None, +) -> Callable[[AnyHandlerManager[T]], QueueConsumerService[T]]: + """Decorator to create a Queue/SimpleQueue consumer hosted service.""" + return lambda handler_m: QueueConsumerService( + target, handler_m, consumers=consumers, process_leftovers=process_leftovers, task_tracking=task_tracking + ) diff --git a/localpost/flow/_queue.py b/localpost/flow/_queue.py new file mode 100644 index 0000000..8e3741a --- /dev/null +++ b/localpost/flow/_queue.py @@ -0,0 +1,95 @@ +import dataclasses as dc +import queue +import sys +import time +from collections.abc import Callable, Collection +from queue import Queue, SimpleQueue +from typing import Generic, Protocol, TypeVar + +from anyio import ClosedResourceError, from_thread + +if sys.version_info >= (3, 13): + from queue import ShutDown +else: + + class ShutDown(Exception): + pass + + +T = TypeVar("T", covariant=True) +TC = TypeVar("TC", bound=Collection[object]) + +CHECK_INTERVAL = 0.1 # How often to check for cancellation or shutdown, seconds + + +class ReceiveStream(Protocol[T]): + def receive(self) -> T: ... + + def close(self): ... + + +@dc.dataclass(eq=False, slots=True) +class Receiver(Generic[T]): + queue: Queue[T] | SimpleQueue[T] + closed: bool = False + + def receive(self) -> T: + def checkpoint(): + from_thread.check_cancelled() + if self.closed: + raise ClosedResourceError() + + while True: + checkpoint() + try: + return self.queue.get(timeout=CHECK_INTERVAL) + except queue.Empty: + continue + + def close(self): + self.closed = True + + +@dc.dataclass(eq=False, slots=True) +class BatchReceiver(Generic[T, TC]): + queue: Queue[T] | SimpleQueue[T] + batch_size: int + batch_window: float + items_f: Callable[[list[T]], TC] + closed: bool = False + + def receive(self) -> TC: + def checkpoint(): + from_thread.check_cancelled() + if self.closed: + raise ClosedResourceError() + + def read_batch() -> list[T]: + batch: list[T] = [] + batch_started_at = time.monotonic() + batch_age = 0.0 + while (len(batch) < self.batch_size) and (batch_age < self.batch_window): + checkpoint() + batch_age = time.monotonic() - batch_started_at + try: + message = self.queue.get(timeout=CHECK_INTERVAL) + batch.append(message) + except queue.Empty: + continue + return batch + + empty_batch: list[T] = [] + while True: + items = empty_batch + try: + items = read_batch() + if not items: + continue + return self.items_f(items) + except ShutDown: + if items: + return self.items_f(items) # Return the last batch first + raise + + def close(self): + self.closed = True diff --git a/tests/consumers/pubsub.py b/tests/consumers/pubsub.py new file mode 100644 index 0000000..b375d8d --- /dev/null +++ b/tests/consumers/pubsub.py @@ -0,0 +1,102 @@ +import random +import string + +import anyio +import pytest +from testcontainers.google import PubSubContainer + +from localpost import flow, debug +from localpost.consumers.pubsub import pubsub_consumer, ConsumerClient, PubSubMessage, PubSubMessages +from localpost.hosting import Host + +pytestmark = [pytest.mark.anyio, pytest.mark.integration] + + +@pytest.fixture(scope="module") +def local_pubsub(): + with PubSubContainer() as pubsub: + yield pubsub + + +@pytest.fixture() +def topic_name(): + return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + + +async def test_happy_path(local_pubsub, topic_name): + # Arrange + + sent = ["hello", "world", "!"] + publisher = local_pubsub.get_publisher_client() + topic_path = publisher.topic_path(local_pubsub.project, topic_name) + # TODO VisibilityTimeout=1 + publisher.create_topic(name=topic_path) + for message in sent: + publisher.publish(topic_path, message.encode("utf-8")) + + # Act + + received = [] + + s_client = local_pubsub.get_subscriber_client() + s_client.subscription_path() + + @pubsub_consumer(lambda: ConsumerClient.create(topic_path, local_pubsub.get_subscriber_client())) + @flow.handler + async def handle(m: PubSubMessage): + nonlocal received + with m as m_body: + received += [m_body.decode()] + + host = Host(handle) + async with debug, host.aserve(): + await anyio.sleep(3) # "App is working" + host.shutdown() + + # Assert + + assert host.status["exception"] is None + assert received == sent + + # TODO Make sure that the messages were deleted from the queue + + +async def test_handler_manager(): + # Test handler lifecycle (via handler manager) + pass + + +async def test_batching(local_pubsub, topic_name): + # Arrange + + sent = ["hello", "world", "!"] + publisher = local_pubsub.get_publisher_client() + topic_path = publisher.topic_path(local_pubsub.project, topic_name) + # TODO Attributes={"VisibilityTimeout": "2"} # Should be greater than the batch window + publisher.create_topic(name=topic_path) + for message in sent: + publisher.publish(topic_path, message.encode("utf-8")) + + # Act + + received = [] + + @pubsub_consumer(lambda: ConsumerClient.create(topic_path, local_pubsub.get_subscriber_client())) + @flow.batch(10, 1, PubSubMessages) + @flow.handler + async def handle(messages: PubSubMessages): + nonlocal received + with messages: + received += [[m.data.decode() for m in messages]] + + host = Host(handle) + async with debug, host.aserve(): + await anyio.sleep(3) # "App is working" + host.shutdown() + + # Assert + + assert host.status["exception"] is None + assert received == [sent] + + # TODO Make sure that the messages were deleted from the queue diff --git a/tests/consumers/pubsub_async.py b/tests/consumers/pubsub_async.py new file mode 100644 index 0000000..dd10ec7 --- /dev/null +++ b/tests/consumers/pubsub_async.py @@ -0,0 +1 @@ +# FIXME Implement diff --git a/tests/hosting/app_host.py b/tests/hosting/app_host.py new file mode 100644 index 0000000..e4c35c4 --- /dev/null +++ b/tests/hosting/app_host.py @@ -0,0 +1,11 @@ +import pytest + +pytestmark = pytest.mark.anyio + + +def test_service_decorator_does_not_change_target(): + pass # TODO Implement + + +def test_multiple_middlewares(): + pass # TODO Implement diff --git a/tests/hosting/service_middlewares.py b/tests/hosting/service_middlewares.py new file mode 100644 index 0000000..248f79f --- /dev/null +++ b/tests/hosting/service_middlewares.py @@ -0,0 +1,18 @@ +import pytest + +pytestmark = pytest.mark.anyio + + +async def test_shutdown_timeout(): + # HostedService(service_func).use(shutdown_timeout(5)) + pass # TODO Implement + + +async def test_start_timeout(): + # HostedService(service_func).use(start_timeout(5)) + pass # TODO Implement + + +async def test_lifespan(): + # HostedService(service_func).use(lifespan(...)) + pass # TODO Implement diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py new file mode 100644 index 0000000..4b4156e --- /dev/null +++ b/tests/scheduler/scheduler.py @@ -0,0 +1,60 @@ +import random +from datetime import timedelta + +import anyio +import pytest +from anyio import fail_after + +from localpost.hosting import Host +from localpost.scheduler import Scheduler, every, scheduled_task + +pytestmark = [pytest.mark.anyio, pytest.mark.integration] + + +async def test_task_decorator(): + results = [] + + @scheduled_task(every(timedelta(seconds=1))) + def sample_task(): + results.append(random.randint(0, 10)) + + assert callable(sample_task) + + host = Host(sample_task) + async with host.aserve(): + await anyio.sleep(0.5) # "App is working" + host.shutdown() + + assert len(results) == 1 # Only one initial run + + +async def test_scheduler_tasks(): + results = [] + scheduler = Scheduler() + + @scheduler.task(every(timedelta(seconds=1))) + def sample_task1(): + results.append("task1 result") + + @scheduler.task(every(timedelta(seconds=1))) + def sample_task2(): + results.append("task2 result") + + assert callable(sample_task1) + + async with scheduler.aserve(): + await anyio.sleep(0.5) # "App is working" + scheduler.shutdown() + + assert len(results) == 2 # Only one initial run for each task + assert "task1 result" in results + assert "task2 result" in results + + +# An empty scheduler (without any tasks) should complete immediately without errors +async def test_empty_scheduler(): + scheduler = Scheduler() + + with fail_after(1): + async with scheduler.aserve(): + pass From 46681ee17e88caf7b05cd9a8ed9c14dd5d9d6c89 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 4 Feb 2026 21:59:22 +0000 Subject: [PATCH 007/286] WIP --- .aider.conf.yml | 2 - .python-version | 1 - examples/lifespan.py | 1 - justfile | 8 +- localpost/__init__.py | 5 +- localpost/consumers/_sqs_types.py | 24 +- localpost/consumers/async_queue.py | 114 - localpost/consumers/azure_queue.py | 8 - localpost/consumers/azure_servicebus.py | 6 - localpost/consumers/pubsub.py | 70 - localpost/consumers/queue.py | 86 - localpost/consumers/sqs.py | 114 +- localpost/hosting/__init__.py | 35 - localpost/hosting/_app_host.py | 76 - localpost/hosting/_host.py | 901 +---- localpost/hosting/middlewares.py | 94 +- localpost/hosting/utils.py | 27 - localpost/http/README.md | 3 + .../{consumers/nats.py => http/__init__.py} | 0 localpost/http/config.py | 43 + localpost/http/main.py | 0 localpost/http/server.py | 332 ++ localpost/http/server_manager.py | 84 + pdm.lock | 3580 ----------------- pyproject.toml | 63 +- uv.lock | 2058 ++++++++++ 26 files changed, 2625 insertions(+), 5110 deletions(-) delete mode 100644 .aider.conf.yml delete mode 100644 .python-version delete mode 100644 examples/lifespan.py delete mode 100644 localpost/consumers/async_queue.py delete mode 100644 localpost/consumers/azure_queue.py delete mode 100644 localpost/consumers/azure_servicebus.py delete mode 100644 localpost/consumers/queue.py delete mode 100644 localpost/hosting/_app_host.py delete mode 100644 localpost/hosting/utils.py create mode 100644 localpost/http/README.md rename localpost/{consumers/nats.py => http/__init__.py} (100%) create mode 100644 localpost/http/config.py create mode 100644 localpost/http/main.py create mode 100644 localpost/http/server.py create mode 100644 localpost/http/server_manager.py delete mode 100644 pdm.lock create mode 100644 uv.lock diff --git a/.aider.conf.yml b/.aider.conf.yml deleted file mode 100644 index 686ae81..0000000 --- a/.aider.conf.yml +++ /dev/null @@ -1,2 +0,0 @@ -# See https://aider.chat/docs/usage/conventions.html -read: CONVENTIONS.md diff --git a/.python-version b/.python-version deleted file mode 100644 index c8cfe39..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/examples/lifespan.py b/examples/lifespan.py deleted file mode 100644 index b1dd741..0000000 --- a/examples/lifespan.py +++ /dev/null @@ -1 +0,0 @@ -# TODO Implement diff --git a/justfile b/justfile index a45c990..09af4b6 100755 --- a/justfile +++ b/justfile @@ -4,13 +4,11 @@ default: just --list deps: - pdm install --group :all -# uv sync --all-groups --all-extras + uv sync --all-groups --all-extras deps-upgrade: - pdm lock --group :all -v - pdm sync --group :all --clean -# uv sync --all-groups --all-extras --upgrade + uv lock --upgrade + uv sync --all-groups --all-extras [doc("Check types (using both PyRight and MyPy)")] types: diff --git a/localpost/__init__.py b/localpost/__init__.py index 879caa1..fdeca75 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -1,12 +1,13 @@ import logging +from importlib.metadata import version from ._debug import debug from ._run import arun, run from ._utils import Result try: - from .__meta__ import version as __version__ -except ImportError: + __version__ = version("localpost") +except Exception: __version__ = "dev" diff --git a/localpost/consumers/_sqs_types.py b/localpost/consumers/_sqs_types.py index e1fced0..3cb25c9 100644 --- a/localpost/consumers/_sqs_types.py +++ b/localpost/consumers/_sqs_types.py @@ -10,12 +10,6 @@ # Same trick as in types_boto3_sqs stubs from typing import Any as BotoSqsClient # type: ignore[assignment] # noqa -try: - from types_aiobotocore_sqs.client import SQSClient as AioBotoSqsClient -except ImportError: - # Same trick as in types_boto3_sqs stubs - from typing import Any as AioBotoSqsClient # type: ignore[assignment] # noqa - try: from types_boto3_sqs.literals import MessageSystemAttributeNameType from types_boto3_sqs.type_defs import ( @@ -24,19 +18,11 @@ ReceiveMessageRequestTypeDef, ) except ImportError: - try: - from types_aiobotocore_sqs.literals import MessageSystemAttributeNameType - from types_aiobotocore_sqs.type_defs import ( - MessageAttributeValueOutputTypeDef, - MessageTypeDef, - ReceiveMessageRequestTypeDef, - ) - except ImportError: - # Same trick as in types_boto3_sqs stubs - from typing import Any as MessageSystemAttributeNameType # type: ignore[assignment] # noqa - from typing import Any as MessageAttributeValueOutputTypeDef # type: ignore[assignment] # noqa - from typing import Any as MessageTypeDef # type: ignore[assignment] # noqa - from typing import Any as ReceiveMessageRequestTypeDef # type: ignore[assignment] # noqa + # Same trick as in types_boto3_sqs stubs + from typing import Any as MessageSystemAttributeNameType # type: ignore[assignment] # noqa + from typing import Any as MessageAttributeValueOutputTypeDef # type: ignore[assignment] # noqa + from typing import Any as MessageTypeDef # type: ignore[assignment] # noqa + from typing import Any as ReceiveMessageRequestTypeDef # type: ignore[assignment] # noqa class LambdaEventRecordMessageAttributeValue(TypedDict): diff --git a/localpost/consumers/async_queue.py b/localpost/consumers/async_queue.py deleted file mode 100644 index fa26fb2..0000000 --- a/localpost/consumers/async_queue.py +++ /dev/null @@ -1,114 +0,0 @@ -import asyncio -import dataclasses as dc -import logging -import math -import sys -from collections.abc import Callable -from typing import Generic, TypeVar, cast - -from anyio import ClosedResourceError, EndOfStream -from anyio.abc import ObjectReceiveStream - -from localpost._utils import NOT_SET, Event, wait_any -from localpost.flow import AnyHandlerManager, ensure_async_handler -from localpost.flow._stream import create_stream_consumer -from localpost.hosting import ServiceLifetimeManager - -if sys.version_info >= (3, 13): - from asyncio import QueueShutDown -else: - - class QueueShutDown(Exception): - pass - - -T = TypeVar("T") - -__all__ = ["async_queue_consumer"] - -logger = logging.getLogger(__name__) - - -@dc.dataclass(frozen=True, slots=True, eq=False) -class _AsyncQueueAdapter(ObjectReceiveStream[T]): - source: asyncio.Queue[T] - closed: Event = dc.field(default_factory=Event) - - async def receive(self) -> T: - if self.closed: - raise ClosedResourceError() - source = self.source - item = cast(T, NOT_SET) - - async def get_item() -> None: - nonlocal item - item = (await source.get()) if source.empty() else source.get_nowait() - - try: - await wait_any(get_item, self.closed) - if item is NOT_SET: - raise ClosedResourceError() - return item - except QueueShutDown: - raise EndOfStream() from None - - async def aclose(self) -> None: - # Because it's not possible to close just the receiver end for asyncio.Queue - self.closed.set() - - -class AsyncQueueConsumerService(Generic[T]): - def __init__( - self, - target: asyncio.Queue[T], - handler_m: AnyHandlerManager[T], - /, - *, - concurrency: int | float, - process_leftovers: bool, - task_tracking: bool, - ): - if not (math.isinf(concurrency) or (isinstance(concurrency, int) and concurrency >= 1)): - raise ValueError("Number of consumers must be at least 1") - - self.target = target - self.handler_m = handler_m - self.concurrency = concurrency - self.process_leftovers = process_leftovers - self.task_tracking = task_tracking - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - queue = self.target - stream = _AsyncQueueAdapter(queue) - async with self.handler_m as handler: - handle_async = ensure_async_handler(handler, max_threads=self.concurrency) - - async def handle_and_ack(item: T) -> None: - await handle_async(item) - queue.task_done() - - async with create_stream_consumer( - stream, - handle_and_ack if self.task_tracking else handle_async, - concurrency=self.concurrency, - ) as consumer_state: - service_lifetime.set_started() - # TODO Timeout, if the queue is not closed at all - if not self.process_leftovers: - await wait_any(service_lifetime.shutting_down, consumer_state.closed) - await stream.aclose() - # Otherwise just wait for the stream to be exhausted and closed - - -def async_queue_consumer( - target: asyncio.Queue[T], - /, - *, - concurrency: int = 1, - process_leftovers: bool = True, - task_tracking: bool = True, -) -> Callable[[AnyHandlerManager[T]], AsyncQueueConsumerService[T]]: - """Decorator to create an asyncio.Queue consumer hosted service.""" - return lambda handler_m: AsyncQueueConsumerService( - target, handler_m, concurrency=concurrency, process_leftovers=process_leftovers, task_tracking=task_tracking - ) diff --git a/localpost/consumers/azure_queue.py b/localpost/consumers/azure_queue.py deleted file mode 100644 index 94c2a89..0000000 --- a/localpost/consumers/azure_queue.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Azure Queue Storage consumer. -""" - -# See: -# - https://pypi.org/project/azure-storage-queue/ -# - https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/CLIENT_LIBRARY_DEVELOPER.md#transport -# - https://learn.microsoft.com/en-us/azure/storage/queues/storage-quickstart-queues-python diff --git a/localpost/consumers/azure_servicebus.py b/localpost/consumers/azure_servicebus.py deleted file mode 100644 index 0102427..0000000 --- a/localpost/consumers/azure_servicebus.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Azure ServiceBus consumer. -""" - -# See: -# - https://learn.microsoft.com/en-us/python/api/overview/azure/servicebus-readme#receive-messages-from-a-queue diff --git a/localpost/consumers/pubsub.py b/localpost/consumers/pubsub.py index 2fded06..1051993 100644 --- a/localpost/consumers/pubsub.py +++ b/localpost/consumers/pubsub.py @@ -30,9 +30,7 @@ "PubSubMessage", "PubSubMessages", "ConsumerClient", - "AsyncConsumerClient", "client_factory", - "async_client_factory", "pubsub_consumer", ] @@ -177,53 +175,6 @@ def pull(self): return response.received_messages -@final -class AsyncConsumerClient: - @classmethod - @asynccontextmanager - async def create( - cls, - subscription_path: str, - client: SubscriberAsyncClient | None = None, - /, - *, - max_messages: int = 100, - retry_policy: retry.AsyncRetry | object = DEFAULT, - ): - client = client or SubscriberAsyncClient() - try: - yield cls(client, subscription_path, max_messages, retry_policy) - finally: - await client.transport.close() - - def __init__( - self, - client: SubscriberAsyncClient, - subscription_path: str, - max_messages: int, - retry_policy: retry.AsyncRetry | object = DEFAULT, - ): - self.client = client - self.subscription_path = subscription_path - self.max_messages = max_messages - self.retry_policy = retry_policy - - async def acknowledge(self, target: Iterable[PubSubMessage]) -> None: - request = {"subscription": self.subscription_path, "ack_ids": [msg.payload.ack_id for msg in target]} - await self.client.acknowledge(request) - - async def pull(self): - # The actual number of messages pulled may be smaller than max_messages - response = await self.client.pull( - {"subscription": self.subscription_path, "max_messages": self.max_messages}, - retry=self.retry_policy or DEFAULT, - ) - return response.received_messages - - -AnyClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient | AsyncConsumerClient]] - - # https://cloud.google.com/pubsub/docs/samples/pubsub-subscriber-sync-pull#pubsub_subscriber_sync_pull-python async def _consume_sync( client: ConsumerClient, @@ -245,32 +196,11 @@ def consume(): await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) -async def _consume_async( - client: AsyncConsumerClient, - message_handler: AsyncHandler[PubSubMessage], - ack_queue: ThreadSafeSendStream[PubSubMessage], - shutdown_scope: CancelScope, -): - while not shutdown_scope.cancel_called: - messages = _EMPTY_RECEIVE - with shutdown_scope: - messages = await client.pull() - if not messages: - continue # No messages received (empty queue or shutdown) - for m in messages: - await message_handler(PubSubMessage(m, client, ack_queue)) - - def client_factory(subscription_path: str) -> Callable[[], AbstractAsyncContextManager[ConsumerClient]]: """Default PubSub client factory.""" return partial(ConsumerClient.create, subscription_path) -def async_client_factory(subscription_path: str) -> Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]]: - """Default PubSub async client factory.""" - return partial(AsyncConsumerClient.create, subscription_path) - - @final class PubSubConsumerService(ExposedServiceBase): def __init__( diff --git a/localpost/consumers/queue.py b/localpost/consumers/queue.py deleted file mode 100644 index aedcc9b..0000000 --- a/localpost/consumers/queue.py +++ /dev/null @@ -1,86 +0,0 @@ -import logging -from collections.abc import Awaitable, Callable -from queue import Queue, SimpleQueue -from typing import Generic - -from anyio import CapacityLimiter, ClosedResourceError, create_task_group, to_thread -from typing_extensions import TypeVar - -from localpost._utils import Event, wait_any -from localpost.flow import AnyHandlerManager, ensure_sync_handler -from localpost.flow._queue import Receiver, ShutDown -from localpost.hosting import ServiceLifetimeManager - -T = TypeVar("T") - -__all__ = ["queue_consumer"] - -logger = logging.getLogger(__name__) - - -class QueueConsumerService(Generic[T]): - def __init__( - self, - target: Queue[T] | SimpleQueue[T], - handler_m: AnyHandlerManager[T], - /, - *, - consumers: int, - process_leftovers: bool, - task_tracking: bool | None, - ): - if not isinstance(consumers, int) or consumers < 1: - raise ValueError("Number of consumers must be at least 1") - - self.target = target - self.handler_m = handler_m - self.num_consumers = consumers - self.process_leftovers = process_leftovers - self.task_tracking = hasattr(target, "task_done") if task_tracking is None else task_tracking - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - def source_items(): - try: - while True: - yield stream.receive() - except ShutDown: - logger.debug("Source queue has been completed, no more items to process") - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - - def consume() -> None: - for item in source_items(): - handle(item) - if self.task_tracking: - self.target.task_done() # type: ignore - - def consumer_thread() -> Awaitable[None]: - return to_thread.run_sync(consume, limiter=threads_limiter) - - closed = Event() - stream = Receiver(self.target) - threads_limiter = CapacityLimiter(self.num_consumers) - async with self.handler_m as handler, create_task_group() as consumers_tg: - handle = ensure_sync_handler(handler) - for _ in range(self.num_consumers): - consumers_tg.start_soon(consumer_thread) - service_lifetime.set_started() - # TODO Timeout, if the queue is not closed at all - if not self.process_leftovers: - await wait_any(service_lifetime.shutting_down, closed) - stream.close() - # Otherwise just wait for the stream to be exhausted and closed - - -def queue_consumer( - target: Queue[T] | SimpleQueue[T], - /, - *, - consumers: int = 1, - process_leftovers: bool = True, - task_tracking: bool | None = None, -) -> Callable[[AnyHandlerManager[T]], QueueConsumerService[T]]: - """Decorator to create a Queue/SimpleQueue consumer hosted service.""" - return lambda handler_m: QueueConsumerService( - target, handler_m, consumers=consumers, process_leftovers=process_leftovers, task_tracking=task_tracking - ) diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py index 550e167..c985b50 100644 --- a/localpost/consumers/sqs.py +++ b/localpost/consumers/sqs.py @@ -6,27 +6,14 @@ from collections.abc import Callable, Collection, Iterable, Sequence from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager from functools import partial -from typing import Final, TypeAlias, cast, final, overload +from typing import Final, cast, final, overload from urllib.parse import urlparse from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread from localpost import flow from localpost._utils import MemoryStream, is_async_callable -from localpost.flow import ( - AnyHandlerManager, - AsyncHandler, - FlowHandlerManager, - SyncHandler, - ensure_async_handler, - ensure_sync_handler, -) -from localpost.flow._stream import BatchReceiver, create_stream_consumer -from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager -from localpost.hosting.utils import ThreadSafeMemorySendStream, ThreadSafeSendStream - from ._sqs_types import ( - AioBotoSqsClient, BotoSqsClient, LambdaEvent, LambdaEventRecord, @@ -39,8 +26,6 @@ "SqsMessage", "SqsMessages", "ConsumerClient", - "AsyncConsumerClient", - "async_client_factory", "lambda_handler", "sqs_queue_consumer", ] @@ -133,86 +118,6 @@ def delete(self, workload: Iterable[SqsMessage], /) -> None: ) -@final -class AsyncConsumerClient: - @classmethod - @asynccontextmanager - async def create( - cls, - queue_name_or_url: str, - client: AbstractAsyncContextManager[AioBotoSqsClient] | None = None, - /, - *, - req_template: ReceiveMessageRequestTypeDef | None = None, - ): - def get_client() -> AbstractAsyncContextManager[AioBotoSqsClient]: - if client is None: - try: - import aioboto3 - - return aioboto3.Session().client("sqs") - except ImportError: - from aiobotocore.session import get_session - - return get_session().create_client("sqs") - return client - - async with get_client() as transport: - if "/" in queue_name_or_url: - queue_url = queue_name_or_url - queue_name = _queue_name_from_url(queue_url) - else: - queue_name = queue_name_or_url - queue_url = (await transport.get_queue_url(QueueName=queue_name))["QueueUrl"] - yield cls( - transport, - queue_name, - queue_url, - req_template - or { - "QueueUrl": queue_name, - "MessageAttributeNames": ["All"], - "MaxNumberOfMessages": 10, - "WaitTimeSeconds": 20, - }, - ) - - def __init__( - self, - client: AioBotoSqsClient, - queue_name: str, - queue_url: str, - receive_req: ReceiveMessageRequestTypeDef, - /, - ) -> None: - self._client = client - self.queue_name = queue_name - self.queue_url = queue_url - self.receive_req: ReceiveMessageRequestTypeDef = receive_req | {"QueueUrl": queue_url} - - async def receive(self) -> Sequence[MessageTypeDef]: - # TODO Check HTTP status and retry on errors (exponential backoff) - pull_resp = await self._client.receive_message(**self.receive_req) - return pull_resp.get("Messages", _EMPTY_RECEIVE) - - async def delete(self, workload: Iterable[SqsMessage], /) -> None: - if isinstance(workload, SqsMessage): - await self._client.delete_message( - QueueUrl=self.queue_url, - ReceiptHandle=workload.receipt_handle, - ) - else: - await self._client.delete_message_batch( - QueueUrl=self.queue_url, - Entries=[ # type: ignore - {"Id": str(i), "ReceiptHandle": message.receipt_handle} for i, message in enumerate(workload) - ], - ) - - -AnyClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient | AsyncConsumerClient]] - - @final @dc.dataclass(frozen=True, slots=True) class SqsMessage(Sequence["SqsMessage"], AbstractContextManager[str, None]): @@ -354,23 +259,6 @@ def consume(): await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) -# See also https://github.com/aio-libs/aiobotocore/blob/master/examples/sqs_queue_consumer.py -async def _consume_async( - client: AsyncConsumerClient, - message_handler: AsyncHandler[SqsMessage], - ack_queue: ThreadSafeSendStream[SqsMessage], - shutdown_scope: CancelScope, -): - while not shutdown_scope.cancel_called: - messages = _EMPTY_RECEIVE - with shutdown_scope: - messages = await client.receive() - if not messages: - continue # No messages received (empty queue or shutdown) - for m in messages: - await message_handler(SqsMessage(m, client, ack_queue)) - - @final class SqsConsumerService(ExposedServiceBase): def __init__( diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index cedb4e5..e69de29 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -1,35 +0,0 @@ -from ._app_host import AppHost -from ._host import ( - AbstractHost, - ExposedService, - ExposedServiceBase, - Host, - HostedService, - HostedServiceDecorator, - HostedServiceFunc, - HostedServiceSet, - ServiceFunc, - ServiceLifetime, - ServiceLifetimeManager, - ServiceState, - ServiceStatus, - hosted_service, -) - -__all__ = [ - "AbstractHost", - "AppHost", - "ExposedService", - "ExposedServiceBase", - "Host", - "HostedService", - "HostedServiceDecorator", - "HostedServiceFunc", - "HostedServiceSet", - "ServiceFunc", - "ServiceLifetime", - "ServiceLifetimeManager", - "ServiceState", - "ServiceStatus", - "hosted_service", -] diff --git a/localpost/hosting/_app_host.py b/localpost/hosting/_app_host.py deleted file mode 100644 index d8abd65..0000000 --- a/localpost/hosting/_app_host.py +++ /dev/null @@ -1,76 +0,0 @@ -from collections.abc import Callable -from typing import Any, TypeVar, cast, final, overload - -from localpost.hosting._host import ( - EMPTY_SERVICE, - AbstractHost, - HostedService, - HostedServiceDecorator, - HostedServiceFunc, -) - -T = TypeVar("T", bound=Callable[..., Any]) - - -@final -class AppHost(AbstractHost): - def __init__(self, name: str | None = "app", /): - super().__init__() - self._name = name - self._middlewares: tuple[HostedServiceDecorator, ...] = () - self._root_service = EMPTY_SERVICE - - def __repr__(self): - return f"{self.__class__.__name__}(root_service={self.name!r})" - - def _prepare_for_run(self) -> HostedService: - return self._root_service.use(*self._middlewares).named(self.name) - - @property - def name(self) -> str: - return self._name or self._root_service.name - - @name.setter - def name(self, value: str | None): - self._assert_not_started() - self._name = value - - @property - def root_service(self) -> HostedService: - return self._root_service - - @root_service.setter - def root_service(self, value: HostedServiceFunc): - self._assert_not_started() - self._root_service = HostedService.ensure(value) - - def use(self, *middlewares: HostedServiceDecorator) -> None: - """ - Register middlewares for the root service. - """ - self._assert_not_started() - self._middlewares += middlewares - - def _add_service(self, target: T, name: str | None = None) -> T: - self._assert_not_started() - hs = HostedService.create(target).named(name) - self.root_service += hs - return target - - @overload - def service(self, target: T, /) -> T: ... - - @overload - def service(self, name: str | None, /) -> Callable[[T], T]: ... - - def service(self, target: T | str | None) -> T | Callable[[T], T]: - """ - Register a service in the host. - - Equivalent to: `host.root_service += hosted_service(target).named(name)` - """ - - def _decorator(func: T) -> T: - return self._add_service(func, cast(str | None, target)) - - return self._add_service(target) if callable(target) else _decorator diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index f8d5f2a..b82e667 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -1,115 +1,71 @@ from __future__ import annotations -import abc import dataclasses as dc -import inspect -import itertools import logging -import math import threading -from abc import abstractmethod -from collections.abc import AsyncGenerator, Awaitable, Callable, Collection, Generator, Iterable, Iterator, Mapping -from contextlib import asynccontextmanager, contextmanager -from functools import wraps +from _contextvars import ContextVar +from collections.abc import Callable from typing import ( - TYPE_CHECKING, Any, ClassVar, - Concatenate, - Final, Literal, - ParamSpec, Protocol, - TypeAlias, - TypedDict, - TypeVar, - cast, final, - overload, ) from anyio import ( CancelScope, - CapacityLimiter, - create_task_group, - get_cancelled_exc_class, - to_thread, ) -from anyio.abc import TaskGroup, TaskStatus -from anyio.from_thread import BlockingPortal, start_blocking_portal -from typing_extensions import Self +from anyio.from_thread import BlockingPortal -from localpost._debug import DebugState, debug from localpost._utils import ( - NO_OP_TS, - Event, EventView, - EventViewProxy, - choose_anyio_backend, - def_full_name, - is_async_callable, - start_task_soon, - unwrap_exc, - wait_all, ) -T = TypeVar("T") -P = ParamSpec("P") - logger = logging.getLogger("localpost.hosting") -sync_services_limiter = CapacityLimiter(math.inf) # Separate thread pool for sync services (long-running tasks) - - -@final -@dc.dataclass(frozen=True, slots=True) -class Created: - name: ClassVar[Literal["created"]] = "created" - - -@final -@dc.dataclass(frozen=True, slots=True) -class Starting: - name: ClassVar[Literal["starting"]] = "starting" - # timeout: float - - -@final -@dc.dataclass(frozen=True, slots=True) -class Running: - name: ClassVar[Literal["running"]] = "running" - value: Any = None - # graceful_shutdown_scope: CancelScope | None = None - - -@final -@dc.dataclass(frozen=True, slots=True) -class ShuttingDown: - name: ClassVar[Literal["shutting_down"]] = "shutting_down" - reason: BaseException | str | None = None - # timeout: float - -@final -@dc.dataclass(frozen=True, slots=True) -class Stopped: - name: ClassVar[Literal["stopped"]] = "stopped" - shutdown_reason: BaseException | str | None = None - exception: BaseException | None = None - - -ServiceState = Starting | Running | ShuttingDown | Stopped +# @final +# @dc.dataclass(frozen=True, slots=True) +# class Created: +# name: ClassVar[Literal["created"]] = "created" +# +# +# @final +# @dc.dataclass(frozen=True, slots=True) +# class Starting: +# name: ClassVar[Literal["starting"]] = "starting" +# # timeout: float +# +# +# @final +# @dc.dataclass(frozen=True, slots=True) +# class Running: +# name: ClassVar[Literal["running"]] = "running" +# value: Any = None +# # graceful_shutdown_scope: CancelScope | None = None +# +# +# @final +# @dc.dataclass(frozen=True, slots=True) +# class ShuttingDown: +# name: ClassVar[Literal["shutting_down"]] = "shutting_down" +# reason: BaseException | str | None = None +# # timeout: float +# +# +# @final +# @dc.dataclass(frozen=True, slots=True) +# class Stopped: +# name: ClassVar[Literal["stopped"]] = "stopped" +# shutdown_reason: BaseException | str | None = None +# exception: BaseException | None = None + + +# ServiceState = Starting | Running | ShuttingDown | Stopped HostState = Created | Starting | Running | ShuttingDown | Stopped -# Just a dict, ready for (JSON) serialization -class ServiceStatus(TypedDict): - name: str - state: Literal["created", "starting", "running", "shutting_down", "stopped"] - services: Collection[ServiceStatus] - shutdown_reason: str | None - exception: str | None - class HostLifetime(Protocol): exit_code: int = 0 @@ -123,12 +79,6 @@ def state(self) -> HostState: ... @property def status(self) -> ServiceStatus: ... - # @property - # def same_thread(self) -> bool: ... - - # @property - # def portal(self) -> BlockingPortal: ... - @property def started(self) -> EventView: ... @@ -141,650 +91,22 @@ def stopped(self) -> EventView: ... def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... -class ServiceLifetime(Protocol): - @property - def name(self) -> str: ... - - @property - def state(self) -> ServiceState: ... - - @property - def status(self) -> ServiceStatus: ... - - # @property - # def child_services(self) -> Sequence[ServiceLifetimeView]: ... - - @property - def started(self) -> EventView: ... - - @property - def shutting_down(self) -> EventView: ... - - @property - def stopped(self) -> EventView: ... - - @property - def value(self) -> Any: ... - - @property - def exception(self) -> BaseException | None: ... - - @property - def shutdown_reason(self) -> BaseException | str | None: ... - - async def wait_started(self) -> Any: ... - - def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... - - -class ServiceLifetimeManager(Protocol): - @property - def name(self) -> str: ... - - @property - def state(self) -> ServiceState: ... - - @property - def status(self) -> ServiceStatus: ... - - # @property - # def child_services(self) -> Sequence[ServiceLifetimeView]: ... - - @property - def started(self) -> EventView: ... - - @property - def shutting_down(self) -> EventView: ... - - @property - def stopped(self) -> EventView: ... - - @property - def host(self) -> AbstractHost: ... - - # @property - # def tg(self) -> TaskGroup: - # """ Task group for the service itself and its child services. """ - # ... - - def set_started(self, value=None, /, *, graceful_shutdown_scope: CancelScope | None = None) -> None: ... - - def set_shutting_down(self, *, reason: BaseException | str | None = None) -> None: ... - - # 1. PyCharm (at least 2024.03) has a bug when calling a TypeVarTuple-parameterized function with 0 arguments (see - # https://youtrack.jetbrains.com/issue/PY-63820), - # 2. mypy (at least 1.15.0) does not like overloads ("error: Incompatible return value type ... [return-value]"), - # So just skip complex typing here, for now - def start_child_service( # type: ignore[misc] - self, - func: ServiceFunc, - /, - *func_args, - name: str | None = None, - ) -> ServiceLifetime: ... - - -# Everything that can be used as a hosted service, see HostedService.create() -ServiceFunc: TypeAlias = Callable[..., Awaitable[None]] | Callable[..., None] - -if TYPE_CHECKING: - HostedServiceFunc: TypeAlias = Callable[Concatenate[ServiceLifetimeManager, ...], Awaitable[None]] -else: - # Python 3.10 does not support ... (ellipsis) for Concatenate - HostedServiceFunc: TypeAlias = Callable[..., Awaitable[None]] -# HostedServiceFunc: TypeAlias = Callable[[ServiceLifetimeManager, ...], Awaitable[None]] -# class HostedServiceFunc(Protocol): -# def __call__(self, service_lifetime: ServiceLifetimeManager, *args) -> Awaitable[None]: ... - -HostedServiceDecorator: TypeAlias = Callable[ - [Callable[P, Awaitable[None]]], # [HostedServiceFunc] - Callable[P, Awaitable[None]], # HostedServiceFunc -] - - -def async_service(func: Callable[..., Awaitable[None]]) -> HostedServiceFunc: - """ - Decorator to create a service from an async function. - """ - if len(inspect.signature(func).parameters) >= 1: - return cast(HostedServiceFunc, func) - - @wraps(func) - async def _simple_service(lifetime: ServiceLifetimeManager): - with CancelScope() as run_scope: - lifetime.set_started(graceful_shutdown_scope=run_scope) - await func() - - return _simple_service - - -def sync_service(func: Callable[..., Any]) -> HostedServiceFunc: - """ - Decorator to create a service from a target sync function (by running it in a separate thread). - - The target can be lifetime-aware (by accepting `ServiceLifetime` as the first argument). - - Important: the target must call `from_thread.check_cancelled()` periodically, to check for cancellation. - """ - - @wraps(func) - async def _service(lifetime: ServiceLifetimeManager): - await to_thread.run_sync(func, lifetime, limiter=sync_services_limiter) - - @wraps(func) - async def _simple_service(lifetime: ServiceLifetimeManager): - with CancelScope() as run_scope: - lifetime.set_started(graceful_shutdown_scope=run_scope) - await to_thread.run_sync(func, limiter=sync_services_limiter) - - lifetime_aware = len(inspect.signature(func).parameters) >= 1 - return _service if lifetime_aware else _simple_service - - -@overload -def hosted_service(target: ServiceFunc, /) -> HostedService: ... - - -@overload -def hosted_service(name: str | None, /) -> Callable[[ServiceFunc], HostedService]: ... - - -def hosted_service(target: ServiceFunc | str | None) -> HostedService | Callable[[ServiceFunc], HostedService]: - """ - Create a hosted service from an arbitrary callable. - """ +_current_host: ContextVar[Host] = ContextVar("localpost.current_host") - def _decorator(func: ServiceFunc) -> HostedService: - return HostedService.create(func).named(cast(str | None, target)) - return HostedService.create(target) if callable(target) else _decorator +def current_host() -> Host: + if _current_host.get(None) is None: + raise RuntimeError("Not in localpost hosting context") + return _current_host.get() -class _ServiceLifetime: - def __init__(self, name: str, host: AbstractHost, parent_tg: TaskGroup): - self.name = name - self.host = host - self.parent_tg = parent_tg - self.tg = create_task_group() - self.child_services: list[ServiceLifetime] = [] - - self.started = Event() - self.value: Any = None - - self.graceful_shutdown_scope: CancelScope | None = None - self.shutting_down = Event() - self.shutdown_reason: BaseException | str | None = None - - self.stopped = Event() - self.exception: BaseException | None = None # If crashed or cancelled - - @property - def as_view(self) -> ServiceLifetime: - return self - - @property - def state(self) -> ServiceState: - if self.stopped: - return Stopped(self.shutdown_reason, self.exception) - if self.shutting_down: - return ShuttingDown(self.shutdown_reason) - if self.started: - return Running(self.value) - return Starting() - - @property - def status(self) -> ServiceStatus: - return { - "name": self.name, - "state": self.state.name, - "services": [cs.status for cs in self.child_services], - "shutdown_reason": str(self.shutdown_reason) if self.shutdown_reason else None, - # "exception": traceback.format_exception(self.exception) if self.exception else None, - "exception": str(self.exception) if self.exception else None, - } - - async def wait_started(self) -> Any: - await self.started - return self.value - - def set_started(self, value=None, /, *, graceful_shutdown_scope: CancelScope | None = None): - if self.stopped or self.shutting_down or self.started: - return - - def _do(): - self.started.set() - self.value = value - self.graceful_shutdown_scope = graceful_shutdown_scope - logger.debug(f"{self.name} started") - - in_host_thread(self.host, _do) - - def set_shutting_down(self, *, reason: BaseException | str | None = None): - self.shutdown(reason=reason) - - def shutdown(self, *, reason: BaseException | str | None = None): - if self.stopped or self.shutting_down: - return - - def _do(): - self.shutdown_reason = reason - self.shutting_down.set() - logger.debug(f"{self.name} shutting down") - if graceful_shutdown_scope := self.graceful_shutdown_scope: - graceful_shutdown_scope.cancel() - - in_host_thread(self.host, _do) - - def start_child_service( - self, - func, - /, - *target_args, - name: str | None = None, - raise_on_error: bool = True, - ) -> _ServiceLifetime: - if self.stopped: - raise RuntimeError("Cannot start a child service for a stopped service") - - def start_service(): - svc = HostedService.create(func).named(name) - svc_lifetime = _ServiceLifetime(svc.name, self.host, self.tg) - self.child_services.append(svc_lifetime) - self.tg.start_soon(_run_service, svc_lifetime, svc, target_args, False, name=svc.name) - return svc_lifetime - - return in_host_thread(self.host, start_service) - - -async def _run_service( - svc_lifetime: _ServiceLifetime, svc_func, svc_func_args: Iterable[Any], raise_on_error: bool | DebugState -) -> None: - svc_name = svc_lifetime.name - logger.debug(f"Starting {svc_name}...") - try: - async with svc_lifetime.tg: # Used for the service itself and its child services - await svc_func(svc_lifetime, *svc_func_args) - svc_lifetime.set_shutting_down() - for child in svc_lifetime.child_services: - child.shutdown() - logger.debug(f"{svc_name} stopped") - except get_cancelled_exc_class() as c_exc: # Cancellation exception inherits directly from BaseException - svc_lifetime.exception = c_exc - logger.error(f"{svc_name} got cancelled") - raise # Always propagate the cancellation - except Exception as exc: - source_exc = unwrap_exc(exc) - svc_lifetime.exception = source_exc - logger.exception(f"{svc_name} crashed", exc_info=source_exc) - if raise_on_error: - raise # Re-raise the original exception for debugging - finally: - svc_lifetime.stopped.set() - - -@dc.dataclass(frozen=True, eq=False, slots=True) -class _HostExecContext: - root_service_lifetime: _ServiceLifetime +@final +@dc.dataclass(slots=True) +class Host: # Actually a Host run portal: BlockingPortal thread_id: int run_scope: CancelScope - - -def _hs_name(func: object) -> str: - match func: - case HostedService(s, attrs): - return attrs.get("name") or _hs_name(s) - case HostedServiceSet(services): - return "(" + " + ".join(_hs_name(s) for s in services) + ")" - case WrappedHostedService(w, t): - return _hs_name(w) + " >> " + _hs_name(t) - case _: - return getattr(func, "name", def_full_name(func)) - - -@final -@dc.dataclass(frozen=True) -class HostedService: # Also a HostedServiceFunc, see __call__() below - """ - Named hosted service callable, immutable. - """ - - source: HostedServiceFunc - _attrs: dict[str, Any] = dc.field(compare=False, hash=False) - - @staticmethod - def wraps(wrapped: HostedServiceFunc, attrs: dict[str, Any]) -> Callable[[HostedServiceFunc], HostedService]: - def decorator(func: HostedServiceFunc) -> HostedService: - wrapped_hs = HostedService.ensure(wrapped) - return HostedService(func, **({"name": wrapped_hs.name} | attrs)) - - return decorator - - @staticmethod - def decorator(dec: Callable[..., HostedServiceFunc]) -> HostedServiceDecorator: - def decorate(func: HostedServiceFunc) -> HostedService: - svc_func = func - attrs = {} - if isinstance(func, HostedService): - svc_func = func.source - attrs = func._attrs - return HostedService.ensure(dec(svc_func, **attrs)) - - return decorate - - @staticmethod - def _unwrap(func: HostedServiceFunc, attrs: dict[str, Any]) -> tuple[HostedServiceFunc, dict[str, Any]]: - if isinstance(func, HostedService) and not func._attrs: - return HostedService._unwrap(func.source, attrs) - if isinstance(func, HostedService) and not attrs: - return func.source, func._attrs - if isinstance(func, HostedServiceSet) and len(func) == 1: - return HostedService._unwrap(next(iter(func)), attrs) - return func, attrs - - @classmethod - def create(cls, target: ServiceFunc, /) -> Self: - if isinstance(target, cls): - return target - return cls(async_service(target) if is_async_callable(target) else sync_service(target)) - - @classmethod - def ensure(cls, target: HostedServiceFunc, /) -> Self: - if isinstance(target, cls): - return target - return cls(target) - - def __init__(self, s: HostedServiceFunc, /, **attrs): - if "name" in attrs and not attrs["name"]: - del attrs["name"] - source, attrs = self._unwrap(s, attrs) - if not callable(source): - raise TypeError(f"Invalid service source: {source}") - object.__setattr__(self, "source", source) - object.__setattr__(self, "_attrs", attrs) - - @property - def _services(self) -> HostedServiceSet: - if isinstance(self.source, HostedServiceSet) and not self._attrs: - return self.source - return HostedServiceSet(self) - - @property - def name(self) -> str: - return _hs_name(self) - - def named(self, name: str | None) -> HostedService: - """ - Override the service name. - """ - if name == self._attrs.get("name"): - return self - attrs = self._attrs | {"name": name} - return HostedService(self.source, **attrs) - - @property - def attrs(self) -> Mapping[str, Any]: - return self._attrs - - def annotated(self, **attrs: Any) -> HostedService: - """ - Annotate the service with a set of attributes (kwargs). - """ - if self._attrs == attrs: - return self - attrs = self._attrs | attrs - return HostedService(self.source, **attrs) - - def __call__(self, service_lifetime: ServiceLifetimeManager, *args) -> Awaitable[None]: - return self.source(service_lifetime, *args) - - def __add__(self, other: HostedServiceFunc) -> HostedService: - """ - Combine two services to run in parallel. - """ - if self is EMPTY_SERVICE: - return HostedService.ensure(other) - if other is EMPTY_SERVICE: - return self - if isinstance(other, HostedService): - other = other._services - return HostedService(self._services.add(other)) - - def __iadd__(self, other: HostedServiceFunc) -> HostedService: - return self.__add__(other) - - def wrap(self, target: HostedServiceFunc | Iterable[HostedServiceFunc]) -> HostedService: - """ - Run `target` service(s) inside this service, like in a context manager. - """ - if not callable(target): # Assume multiple services - target = HostedServiceSet(*target) - return HostedService(WrappedHostedService(self, HostedService.ensure(target))) - - # s1 = s1 >> s2 - def __rshift__(self, target: HostedServiceFunc | Iterable[HostedServiceFunc]) -> HostedService: - return self.wrap(target) - - # s1 >>= s2 - def __irshift__(self, target: HostedServiceFunc | Iterable[HostedServiceFunc]) -> HostedService: - return self.wrap(target) - - def use(self, *middlewares: HostedServiceDecorator) -> HostedService: - """ - Apply a middleware to the hosted service (decorate the source callable). - """ - - def _ensure(s: HostedServiceFunc) -> HostedService: - return s if isinstance(s, HostedService) else HostedService(s, **self._attrs) - - source = self - for middleware in middlewares: - source = _ensure(middleware(source)) - return source - - # s1 = s1 // m1 - def __floordiv__(self, middleware: HostedServiceDecorator) -> HostedService: - return self.use(middleware) - - # s1 //= m1 - def __ifloordiv__(self, middleware: HostedServiceDecorator) -> HostedService: - return self.use(middleware) - - -@final -@dc.dataclass(frozen=True, slots=True) -class HostedServiceSet(Collection[HostedService]): - services: frozenset[HostedService] - - def __init__(self, *services: HostedServiceFunc): - def unwrap(): - for s in services: - if isinstance(s, HostedServiceSet): # Flatten if needed - yield from s.services - else: - yield HostedService.ensure(s) - - object.__setattr__(self, "services", frozenset(unwrap())) - - def __repr__(self): - return f"{self.__class__.__name__}(len={len(self)})" - - def __iter__(self) -> Iterator[HostedService]: - return iter(self.services) - - def __len__(self) -> int: - return len(self.services) - - def __contains__(self, x, /) -> bool: - if not callable(x): - return False - other = HostedService.ensure(cast(HostedServiceFunc, x)) - return other in self.services - - def add(self, *services: HostedServiceFunc) -> HostedServiceSet: - return HostedServiceSet(*itertools.chain(services, self.services)) # type: ignore[arg-type] - - async def __call__(self, lifetime: ServiceLifetimeManager) -> None: - async def when_all_started(): - await wait_all(lt.started for lt in svc_lifetimes) - lifetime.set_started() - - async def when_all_done(): - await wait_all(lt.stopped for lt in svc_lifetimes) - lifetime.set_shutting_down() - - async def when_done(svc: ServiceLifetime): - await svc.stopped - if svc.exception: - lifetime.set_shutting_down(reason="Child service crashed") - - svc_lifetimes = [lifetime.start_child_service(s) for s in self.services] - if not svc_lifetimes: - lifetime.set_started() - return - async with create_task_group() as observers_tg: - start_task_soon(observers_tg, when_all_started) - start_task_soon(observers_tg, when_all_done) - for s in svc_lifetimes: - observers_tg.start_soon(when_done, s) - await lifetime.shutting_down - observers_tg.cancel_scope.cancel() - - -@final -@dc.dataclass(frozen=True, slots=True) -class WrappedHostedService: - wrapper: HostedService - target: HostedService - - async def __call__(self, lifetime: ServiceLifetimeManager) -> None: - async def when_target_started(): - await target.started - lifetime.set_started() - - async def when_target_done(): - await target.stopped - lifetime.set_shutting_down() - - async def when_wrapper_done(): - await wrapper.stopped - lifetime.set_shutting_down(reason="Wrapper service stopped") - - wrapper = lifetime.start_child_service(self.wrapper) - await wrapper.started - target = lifetime.start_child_service(self.target) - async with create_task_group() as observers_tg: - start_task_soon(observers_tg, when_target_started) - start_task_soon(observers_tg, when_target_done) - start_task_soon(observers_tg, when_wrapper_done) - await lifetime.shutting_down - observers_tg.cancel_scope.cancel() - target.shutdown() - await target.stopped - wrapper.shutdown() - - -async def _empty_service(lifetime: ServiceLifetimeManager) -> None: - lifetime.set_started() - - -EMPTY_SERVICE: Final = HostedService(_empty_service, name="empty_service") - - -class ExposedService(Protocol): # Also a HostedServiceFunc - def __call__(self, service_lifetime: ServiceLifetimeManager) -> Awaitable[None]: ... - - @property - def started(self) -> EventView: ... - - @property - def shutting_down(self) -> EventView: ... - - @property - def stopped(self) -> EventView: ... - - def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... - - -class ExposedServiceBase(abc.ABC): - def __init__(self) -> None: - self._started = EventViewProxy() - self._shutting_down = EventViewProxy() - self._stopped = EventViewProxy() - self._service_lifetime: ServiceLifetimeManager | None = None - - @abstractmethod - def __call__(self, service_lifetime: ServiceLifetimeManager) -> Awaitable[None]: ... - - def _assert_not_started(self): - if self._service_lifetime: - raise RuntimeError("Service has already started") - - @property - def _lifetime(self) -> ServiceLifetimeManager: - if self._service_lifetime: - return self._service_lifetime - raise RuntimeError("Service has not started yet") - - @_lifetime.setter - def _lifetime(self, value: ServiceLifetimeManager): - self._assert_not_started() - self._service_lifetime = value - self._started.resolve(value.started) - self._shutting_down.resolve(value.shutting_down) - self._stopped.resolve(value.stopped) - - @property - def started(self) -> EventView: - return self._started - - @property - def shutting_down(self) -> EventView: - return self._shutting_down - - @property - def stopped(self) -> EventView: - return self._stopped - - def shutdown(self, *, reason: BaseException | str | None = None) -> None: - self._lifetime.set_shutting_down(reason=reason) - - -@final -class _ExposedService(ExposedServiceBase): - def __init__(self, source: HostedServiceFunc): - super().__init__() - self._source = source - - def __repr__(self): - return f"ExposedService(source={self._source!r})" - - def __call__(self, service_lifetime: ServiceLifetimeManager) -> Awaitable[None]: - self._lifetime = service_lifetime - return self._source(service_lifetime) - - -# # What is a better name: exposed() or observable()?.. -# def exposed(func: HostedServiceFunc) -> ExposedService: -# return _ExposedService(func) - - -class AbstractHost(ExposedServiceBase, abc.ABC): - def __init__(self) -> None: - super().__init__() - self._exec_context: _HostExecContext | None = None - self._exit_code: int | None = None - - def _assert_not_started(self): - if self._exec_context: - raise RuntimeError("Host has already started") - - @abstractmethod - def _prepare_for_run(self) -> HostedService: ... - - @property - @abstractmethod - def name(self) -> str: ... + _exit_code: int | None = None @property def exit_code(self) -> int: @@ -800,127 +122,36 @@ def exit_code(self, value: int): raise ValueError("Exit code must be in [0,255] range") self._exit_code = value - @property - def _exec(self) -> _HostExecContext: - if self._exec_context: - return self._exec_context - raise RuntimeError("Host has not started yet") - - @property - def portal(self) -> BlockingPortal: - return self._exec.portal - @property def state(self) -> HostState: if self._exec_context: return self._exec_context.root_service_lifetime.state return Created() - @property - def status(self) -> ServiceStatus: - if self._exec_context: - return self._exec_context.root_service_lifetime.status - return { - "name": self.name, - "state": "created", - "services": [], - "shutdown_reason": None, - "exception": None, - } + # @property + # def status(self) -> ServiceStatus: + # if self._exec_context: + # return self._exec_context.root_service_lifetime.status + # return { + # "name": self.name, + # "state": "created", + # "services": [], + # "shutdown_reason": None, + # "exception": None, + # } @property def same_thread(self) -> bool: - return threading.get_ident() == self._exec.thread_id + return threading.get_ident() == self.thread_id + + def stop(self) -> None: - in_host_thread(self, self._exec.run_scope.cancel) - - def __call__(self, sl: ServiceLifetimeManager) -> Awaitable[None]: - """Run the host as a service (in another host).""" - assert isinstance(sl, _ServiceLifetime) - self._lifetime = sl - self._exec_context = _HostExecContext(sl, sl.host.portal, threading.get_ident(), sl.tg.cancel_scope) - return self._prepare_for_run()(sl) - - @asynccontextmanager - async def _aserve_in(self, portal: BlockingPortal, exec_tg: TaskGroup | None = None): - # A premature optimization, to save one task group nesting level - tg: TaskGroup = exec_tg if exec_tg else portal._task_group - self._lifetime = sl = _ServiceLifetime(self.name, self, tg) - self._exec_context = _HostExecContext(sl, portal, threading.get_ident(), sl.tg.cancel_scope) - tg.start_soon(_run_service, sl, self._prepare_for_run(), (), debug) - try: - yield cast(HostLifetime, self) - finally: - if not self.stopped: - # Wait till all services are stopped (act like a task group, not like a portal) - await self.stopped - - @asynccontextmanager - async def aserve(self, portal: BlockingPortal | None = None) -> AsyncGenerator[HostLifetime, Any]: - """ - Start the host in the current event loop. - - :param portal: An optional portal for the current event loop (thread), if already created. - :return: A context manager that returns the host instance. - """ - if portal is None: - async with BlockingPortal() as portal, self._aserve_in(portal) as lifetime: - yield lifetime - else: - async with create_task_group() as exec_tg, self._aserve_in(portal, exec_tg) as lifetime: - yield lifetime - - async def aexecute( - self, portal: BlockingPortal | None = None, *, task_status: TaskStatus[HostLifetime] = NO_OP_TS - ) -> None: - async with self.aserve(portal) as lifetime: - task_status.started(lifetime) - - @contextmanager - def serve(self) -> Generator[HostLifetime, Any, None]: - """ - Start the host in a separate thread, on a separate event loop. - - Intended mainly for integration with legacy apps. Like when you have an old (not async) app and want to run some - hosted services around it. - - In general, do prefer :meth:`aserve` instead. - """ - logger.debug(f"Starting a separate thread for {self.name}...") - with start_blocking_portal(**choose_anyio_backend()) as thread: - with thread.wrap_async_context_manager(self._aserve_in(thread)) as lifetime: - yield lifetime + in_host_thread(self, self.run_scope.cancel) # def in_host_thread(h: HostLifetime, func: Callable[..., T]) -> T: -def in_host_thread(h: AbstractHost, func: Callable[..., T]) -> T: +def in_host_thread[T](h: Host, func: Callable[..., T]) -> T: if h.same_thread: return func() return h.portal.start_task_soon(func).result() - - -@final -class Host(AbstractHost): - def __init__(self, root_service: HostedServiceFunc, /): - super().__init__() - self._root_service = HostedService.ensure(root_service) - - def __repr__(self): - return f"{self.__class__.__name__}(root_service={self.name!r})" - - def _prepare_for_run(self) -> HostedService: - return self._root_service - - @property - def name(self) -> str: - return self._root_service.name - - @property - def root_service(self) -> HostedService: - return self._root_service - - @root_service.setter - def root_service(self, value: HostedServiceFunc): - self._assert_not_started() - self._root_service = HostedService.ensure(value) diff --git a/localpost/hosting/middlewares.py b/localpost/hosting/middlewares.py index da7fae1..f001253 100644 --- a/localpost/hosting/middlewares.py +++ b/localpost/hosting/middlewares.py @@ -1,99 +1,7 @@ -import math -from contextlib import AbstractAsyncContextManager -from typing import Any - -from anyio import fail_after - -from localpost._utils import wait_all, wait_any -from localpost.hosting._host import HostedService, HostedServiceDecorator, HostedServiceFunc, _ServiceLifetime, logger - __all__ = [ - "lifespan", "shutdown_timeout", "start_timeout", ] -def lifespan(cm: AbstractAsyncContextManager[Any], /) -> HostedServiceDecorator: - def decorator(svc_func: HostedServiceFunc, **attrs) -> HostedService: - @HostedService.wraps(svc_func, attrs) - async def run(sl, *args): - assert isinstance(sl, _ServiceLifetime) - async with cm: - await svc_func(sl, *args) - if child_services := sl.child_services: - await sl.shutting_down - for child in child_services: - child.shutdown() - await wait_all(child.stopped for child in child_services) - - return run - - return decorator - - -def start_timeout(timeout: float, /) -> HostedServiceDecorator: - def decorator(svc_func: HostedServiceFunc, **attrs) -> HostedService: - if timeout > attrs.get("start_timeout", math.inf): - raise ValueError("Timeout must be less than the existing one") - attrs["start_timeout"] = timeout - - @HostedService.wraps(svc_func, attrs) - def run(sl, *args): - assert isinstance(sl, _ServiceLifetime) - sl.parent_tg.start_soon(_observe_service_start, sl, timeout) - return svc_func(sl, *args) - - return run - - return HostedService.decorator(decorator) - - -def shutdown_timeout(timeout: float, /) -> HostedServiceDecorator: - def decorator(svc_func: HostedServiceFunc, **attrs) -> HostedService: - if timeout > attrs.get("shutdown_timeout", math.inf): - raise ValueError("Timeout must be less than the existing one") - attrs["shutdown_timeout"] = timeout - - @HostedService.wraps(svc_func, attrs) - def run(sl, *args): - assert isinstance(sl, _ServiceLifetime) - sl.parent_tg.start_soon(_observe_service_shutdown, sl, timeout) - return svc_func(sl, *args) - - return run - - return HostedService.decorator(decorator) - - -async def _observe_service_shutdown(svc: _ServiceLifetime, timeout: float): - if math.isinf(timeout): - return - assert timeout >= 0 - await wait_any(svc.shutting_down, svc.stopped) - if svc.stopped: - return - service_scope = svc.tg.cancel_scope - if timeout == 0: - service_scope.cancel() - return - try: - with fail_after(timeout): - await svc.stopped - except TimeoutError as exc: - svc.exception = exc - logger.error(f"{svc.name} shutdown timeout") - service_scope.cancel() - - -async def _observe_service_start(svc: _ServiceLifetime, timeout: float): - if math.isinf(timeout): - return - assert timeout > 0 - try: - with fail_after(timeout): - await wait_any(svc.started, svc.stopped) - except TimeoutError as exc: - svc.exception = exc - logger.error(f"{svc.name} startup timeout") - svc.tg.cancel_scope.cancel() +# lifespan — simply an async context manager (that can be used as a decorator) diff --git a/localpost/hosting/utils.py b/localpost/hosting/utils.py deleted file mode 100644 index daa7767..0000000 --- a/localpost/hosting/utils.py +++ /dev/null @@ -1,27 +0,0 @@ -import dataclasses as dc -from typing import Protocol, TypeVar, final - -from anyio.streams.memory import MemoryObjectSendStream - -from localpost.hosting import AbstractHost - -T = TypeVar("T", contravariant=True) - -__all__ = ["ThreadSafeMemorySendStream", "ThreadSafeSendStream"] - - -class ThreadSafeSendStream(Protocol[T]): - def send_nowait(self, item: T) -> None: ... - - -@final -@dc.dataclass(frozen=True, slots=True) -class ThreadSafeMemorySendStream(ThreadSafeSendStream[T]): - source: MemoryObjectSendStream[T] - _host: AbstractHost - - def send_nowait(self, item: T) -> None: - if self._host.same_thread: - self.source.send_nowait(item) - else: - self._host.portal.start_task_soon(self.source.send_nowait, item).result() diff --git a/localpost/http/README.md b/localpost/http/README.md new file mode 100644 index 0000000..72d3e62 --- /dev/null +++ b/localpost/http/README.md @@ -0,0 +1,3 @@ +# Custom HTTP/WSGI server + +A combination of a simple HTTP/WSGI server per thread, each thread is managed from the main thread (AnyIO event loop). diff --git a/localpost/consumers/nats.py b/localpost/http/__init__.py similarity index 100% rename from localpost/consumers/nats.py rename to localpost/http/__init__.py diff --git a/localpost/http/config.py b/localpost/http/config.py new file mode 100644 index 0000000..4103c7a --- /dev/null +++ b/localpost/http/config.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import final + +__all__ = [ + "LOGGER_NAME", + "WorkerConfig", + "ServerConfig", +] + +LOGGER_NAME = "localpost.http" + + +@final +@dataclass(frozen=True, slots=True) +class ServerConfig: + host: str = "0.0.0.0" + port: int = 8000 + backlog: int = 16 + """Maximum number of queued connections.""" + read_timeout: float = 5.0 + """Timeout (seconds) for read operations (keep-alive timeout too).""" + + +@final +@dataclass(frozen=True, slots=True) +class WorkerConfig: + server: ServerConfig = field(default_factory=ServerConfig) + max_concurrent_requests: int = 10 + """Max concurrent requests (connections).""" + + +@final +@dataclass(frozen=True, slots=True) +class ProcessWorkerConfig: + pass # For the future + + +@final +@dataclass(frozen=True, slots=True) +class InterpreterWorkerConfig: + pass # For the future diff --git a/localpost/http/main.py b/localpost/http/main.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/http/server.py b/localpost/http/server.py new file mode 100644 index 0000000..8740847 --- /dev/null +++ b/localpost/http/server.py @@ -0,0 +1,332 @@ +""" +Simple WSGI server implementation using h11 for HTTP protocol handling. + +Notes: +- ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. +- The server supports keep-alive connections and graceful shutdown. +""" + +from __future__ import annotations + +import logging +import socket +import sys +from collections.abc import Iterator, Callable +from contextlib import contextmanager, closing, suppress +from io import BufferedReader, DEFAULT_BUFFER_SIZE, RawIOBase +from typing import Any, final +from wsgiref.types import WSGIApplication + +import h11 + +from .config import ServerConfig, LOGGER_NAME + +__all__ = ['Server', 'ClientConn', 'start_http_server'] + +CHECK_TIMEOUT = 0.5 # Seconds + +try: + from anyio import from_thread, NoEventLoopError # noqa + + + def _checkpoint() -> None: + try: + from_thread.check_cancelled() + except NoEventLoopError: + pass +except ImportError: + def _checkpoint() -> None: + pass + + +def _sock_op[**P, T](op: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: + """Perform a socket operation with automatic retry on timeout.""" + while True: + try: + _checkpoint() + return op(*args, **kwargs) + except socket.timeout: + continue + + +@final +class RequestBodyStream(RawIOBase): + def __init__(self, conn: h11.Connection, sock: socket.socket) -> None: + self._conn = conn + self._sock = sock + self._finished = False + + def writable(self): + return False + + def seekable(self): + return False + + def readable(self) -> bool: + return True + + def readall(self): + chunks = bytearray() + with suppress(EOFError): + while True: + chunks.extend(self._receive(DEFAULT_BUFFER_SIZE)) + return chunks + + def readinto(self, b: bytearray, /) -> int: + try: + data = self._receive(len(b)) + size = len(data) + b[:size] = data + return size + except EOFError: + return 0 + + def _receive(self, size: int, /) -> bytes: + """Receive next chunk of body data from the socket via h11.""" + if self._finished: + raise EOFError() + conn, sock = self._conn, self._sock + while True: + event = conn.next_event() + if event is h11.NEED_DATA: + data = _sock_op(sock.recv, size) + if not data: + raise ConnectionAbortedError("Client closed connection unexpectedly") + conn.receive_data(data) + elif isinstance(event, h11.Data): + return event.data + elif isinstance(event, h11.EndOfMessage): + self._finished = True + raise EOFError() + else: + raise RuntimeError(f"Unexpected h11 event: {event!r}") + + def drain(self) -> None: + """Consume any remaining body data. Required before starting next request cycle.""" + with suppress(EOFError): + while not self._finished: + self._receive(DEFAULT_BUFFER_SIZE) + + +@contextmanager +def start_http_server(app: WSGIApplication, config: ServerConfig) -> Iterator[Server]: + _socket = socket.create_server( + (config.host, config.port), + backlog=config.backlog, + reuse_port=True, + ) + _socket.settimeout(CHECK_TIMEOUT) + + logger = logging.getLogger(LOGGER_NAME) + + server = Server(_socket, app, config, logger) + logger.info(f"Serving on {config.host}:{server.port}") + with _socket: + yield server + + +@final +class Server: + def __init__( + self, + server_sock: socket.socket, + app: WSGIApplication, + config: ServerConfig, + logger: logging.Logger, + ) -> None: + self._socket = server_sock + self.app = app + self.config = config + self.port = server_sock.getsockname()[1] + """ + Actual port the server is listening on. + + Can be useful when port 0 is specified to auto-assign a free port. + """ + self._logger = logger + self._closed = False + + def shutdown(self) -> None: + """Graceful shutdown (stop accepting connections).""" + if self._closed: + return + self._closed = True + + def __iter__(self) -> Iterator[ClientConn]: + while not self._closed: + client_sock, client_addr = _sock_op(self._socket.accept) + yield ClientConn(self, client_sock, self._logger) + + +@final +class ClientConn: + def __init__( + self, + server: Server, + client_sock: socket.socket, + logger: logging.Logger, + ) -> None: + self.server = server + self.config = server.config + self._socket = client_sock + self._conn = h11.Connection(h11.SERVER) + self._logger = logger + + def __call__(self) -> None: + # TODO Assert it's not called concurrently + with self._socket: + self._socket.settimeout(self.config.read_timeout) + while True: + keep_alive = self._handle_request() + if not keep_alive: + return + # Prepare for next request on this client's connection + self._conn.start_next_cycle() + + def _handle_request(self) -> bool: + conn, sock = self._conn, self._socket + request: h11.Request | None = None + + # Only read until we get the Request headers - body is read lazily + while request is None: + event = conn.next_event() + if event is h11.NEED_DATA: + try: + data = _sock_op(sock.recv, DEFAULT_BUFFER_SIZE) + except socket.timeout: + return False # Idle timeout, close connection + if not data: + return False # Client closed connection + conn.receive_data(data) + elif isinstance(event, h11.Request): + request = event + + # Create lazy body that will read from socket on demand + body = RequestBodyStream(conn, sock) + environ = self._build_environ(request, body) + keep_alive: bool = False + + def start_response(status: str, headers: list[tuple[str, str]], /) -> None: + nonlocal keep_alive + keep_alive = _should_keep_alive(request, headers) + + # Add Connection header if not present + if not any(name.lower() == 'connection' for name, _ in headers): + headers.append(('Connection', 'keep-alive' if keep_alive else 'close')) + + # Send response headers immediately + status_code = int(status.split(' ', 1)[0]) + response = h11.Response( + status_code=status_code, + headers=[(name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) + for name, value in headers]) + sock.sendall(conn.send(response)) + + response_body = self.server.app(environ, start_response) # type: ignore + try: + for chunk in response_body: + # TODO Check from_thread.check_cancelled() + if chunk: + sock.sendall(conn.send(h11.Data(data=chunk))) + finally: + if hasattr(response_body, 'close'): + response_body.close() # Generator cleanup + + sock.sendall(conn.send(h11.EndOfMessage())) + + # Drain any unread body data before next request cycle + if keep_alive: + body.drain() + + return keep_alive + + def _build_environ(self, request: h11.Request, body: RequestBodyStream) -> dict[str, Any]: + # Decode path and parse query string + path = request.target.decode('ISO-8859-1') + if '?' in path: + path, query_string = path.split('?', 1) + else: + query_string = '' + + headers_dict = {} + for name, value in request.headers: + headers_dict[name.decode('ISO-8859-1').lower()] = value.decode('ISO-8859-1') + + # See https://wsgi.readthedocs.io/en/latest/definitions.html + environ = { + 'REQUEST_METHOD': request.method.decode('ascii'), + 'PATH_INFO': path, + 'QUERY_STRING': query_string, + 'CONTENT_TYPE': headers_dict.get('content-type', ''), + 'CONTENT_LENGTH': headers_dict.get('content-length', ''), + 'SERVER_NAME': self.config.host, # TODO Actual name + 'SERVER_PORT': str(self.server.port), + 'SERVER_PROTOCOL': f'HTTP/{request.http_version.decode("ascii")}', + 'wsgi.version': (1, 0), + 'wsgi.url_scheme': 'http', + 'wsgi.input': BufferedReader(body), + 'wsgi.errors': sys.stderr, # Handle it later with the logger + 'wsgi.multithread': True, + 'wsgi.multiprocess': False, + 'wsgi.run_once': False, + } + + # Add HTTP headers + for name, value in request.headers: + name_str = name.decode('ISO-8859-1').upper().replace('-', '_') + if name_str not in ('CONTENT_TYPE', 'CONTENT_LENGTH'): + name_str = f'HTTP_{name_str}' + environ[name_str] = value.decode('ISO-8859-1') + + return environ + + +def _should_keep_alive(request: h11.Request, response_headers: list[tuple[str, str]]) -> bool: + """Determine if the connection should be kept alive.""" + # Check if response explicitly sets Connection header + for name, value in response_headers: + if name.lower() == 'connection': + return value.lower() == 'keep-alive' + + # Check request's Connection header + for name, value in request.headers: + if name.lower() == b'connection': + return value.lower() == b'keep-alive' + + # HTTP/1.1 defaults to keep-alive + return request.http_version == b'1.1' + + +def _main(): + logging.basicConfig(level=logging.DEBUG) + + def simple_app(_, start_response): + start_response('200 OK', [('Content-Type', 'text/plain')]) + yield b'Hello, World!\n' + # return [b'Hello, World!\n'] + + with start_http_server(simple_app, ServerConfig()) as server: + for client_conn in server: + client_conn() + + +def _main_flask(): + logging.basicConfig(level=logging.DEBUG) + + from flask import Flask, request + + app = Flask(__name__) + + @app.route('/hello/') + def hello(name): + user_agent = request.headers.get('User-Agent', 'Unknown') + return f'Hello, {name}! Your User-Agent is: {user_agent}\n' + + with start_http_server(app, ServerConfig()) as server: + for client_conn in server: + client_conn() + + +if __name__ == '__main__': + # _main() + _main_flask() diff --git a/localpost/http/server_manager.py b/localpost/http/server_manager.py new file mode 100644 index 0000000..ef312ff --- /dev/null +++ b/localpost/http/server_manager.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +import signal +import threading +from collections.abc import Awaitable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import final +from wsgiref.types import WSGIApplication + +import anyio +from anyio import to_thread, CancelScope, from_thread, create_task_group + +from .config import WorkerConfig +from .server import start_http_server, ClientConn, Server + +__all__ = ['Worker', 'serve'] + + +@asynccontextmanager +async def serve(app: WSGIApplication, config: WorkerConfig, /): + """Run multiple servers (workers).""" + threads_limiter = anyio.CapacityLimiter(config.max_concurrent_requests) + conn_sem = threading.BoundedSemaphore(config.max_concurrent_requests) + + def handle_client(c: ClientConn) -> None: + try: + c() + finally: + conn_sem.release() + + def handle_client_thread(c: ClientConn) -> Awaitable[None]: + return to_thread.run_sync(handle_client, c, limiter=threads_limiter) + + def handle_clients_thread() -> Awaitable[None]: + return to_thread.run_sync(handle_clients, limiter=anyio.CapacityLimiter(1)) + + def handle_clients() -> None: + for client_conn in server: + conn_sem.acquire() # TODO Check from_thread.check_cancelled() periodically + # Process the client in a separate thread, to support multiple concurrent connections + from_thread.run_sync(tg.start_soon, handle_client_thread, client_conn) + + with start_http_server(app, config.server) as server: + async with create_task_group() as tg: + tg.start_soon(handle_clients_thread) + sm = Worker(server, config, tg.cancel_scope) + yield sm + sm.shutdown() + + +@final +@dataclass(frozen=True, slots=True) +class Worker: + server: Server + config: WorkerConfig + _cancel_scope: CancelScope + + def shutdown(self) -> None: + """Graceful shutdown (stop handling new connections, wait for in-flight requests).""" + self.server.shutdown() + + +def _sample_usage(): + logging.basicConfig(level=logging.DEBUG) + + def simple_app(_, start_response): + start_response('200 OK', [('Content-Type', 'text/plain')]) + return [f'Hello from worker thread {threading.get_ident()}!\n'.encode('utf-8')] + + async def _run(): + async with serve(simple_app, WorkerConfig()) as w: + with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: + async for _ in signals: + w.shutdown() + break + + # noinspection PyTypeChecker + anyio.run(_run) + + +if __name__ == '__main__': + _sample_usage() diff --git a/pdm.lock b/pdm.lock deleted file mode 100644 index 727f634..0000000 --- a/pdm.lock +++ /dev/null @@ -1,3580 +0,0 @@ -# This file is @generated by PDM. -# It is not intended for manual editing. - -[metadata] -groups = ["default", "azure-queue", "azure-servicebus", "cron", "dev", "dev-consumers", "dev-hosting-grpc", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "kafka", "nats", "pubsub", "rabbitmq", "scheduler", "sqs", "tests", "unit-tests"] -strategy = ["inherit_metadata"] -lock_version = "4.5.0" -content_hash = "sha256:fd412350845c89307b3046a3a4d81c3bbab01d82e922e118651ca0c180cee970" - -[[metadata.targets]] -requires_python = ">=3.10" - -[[package]] -name = "aio-pika" -version = "9.5.8" -requires_python = "<4.0,>=3.10" -summary = "Wrapper around the aiormq for asyncio and humans" -groups = ["rabbitmq"] -dependencies = [ - "aiormq<7.0,>=6.8", - "exceptiongroup<2,>=1; python_version < \"3.11\"", - "typing-extensions; python_version < \"3.10\"", - "yarl", -] -files = [ - {file = "aio_pika-9.5.8-py3-none-any.whl", hash = "sha256:f4c6cb8a6c5176d00f39fd7431e9702e638449bc6e86d1769ad7548b2a506a8d"}, - {file = "aio_pika-9.5.8.tar.gz", hash = "sha256:7c36874115f522bbe7486c46d8dd711a4dbedd67c4e8a8c47efe593d01862c62"}, -] - -[[package]] -name = "aioboto3" -version = "15.5.0" -requires_python = ">=3.9" -summary = "Async boto3 wrapper" -groups = ["dev-consumers"] -dependencies = [ - "aiobotocore[boto3]==2.25.1", - "aiofiles>=23.2.1", -] -files = [ - {file = "aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6"}, - {file = "aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979"}, -] - -[[package]] -name = "aiobotocore" -version = "2.25.1" -requires_python = ">=3.9" -summary = "Async client for aws services using botocore and aiohttp" -groups = ["dev-consumers"] -dependencies = [ - "aiohttp<4.0.0,>=3.9.2", - "aioitertools<1.0.0,>=0.5.1", - "botocore<1.40.62,>=1.40.46", - "jmespath<2.0.0,>=0.7.1", - "multidict<7.0.0,>=6.0.0", - "python-dateutil<3.0.0,>=2.1", - "wrapt<2.0.0,>=1.10.10", -] -files = [ - {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, - {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, -] - -[[package]] -name = "aiobotocore" -version = "2.25.1" -extras = ["boto3"] -requires_python = ">=3.9" -summary = "Async client for aws services using botocore and aiohttp" -groups = ["dev-consumers"] -dependencies = [ - "aiobotocore==2.25.1", - "boto3<1.40.62,>=1.40.46", -] -files = [ - {file = "aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f"}, - {file = "aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc"}, -] - -[[package]] -name = "aiofiles" -version = "25.1.0" -requires_python = ">=3.9" -summary = "File support for asyncio." -groups = ["dev-consumers"] -files = [ - {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, - {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -requires_python = ">=3.9" -summary = "Happy Eyeballs for asyncio" -groups = ["dev-consumers"] -files = [ - {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, - {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -requires_python = ">=3.9" -summary = "Async http client/server framework (asyncio)" -groups = ["dev-consumers"] -dependencies = [ - "aiohappyeyeballs>=2.5.0", - "aiosignal>=1.4.0", - "async-timeout<6.0,>=4.0; python_version < \"3.11\"", - "attrs>=17.3.0", - "frozenlist>=1.1.1", - "multidict<7.0,>=4.5", - "propcache>=0.2.0", - "yarl<2.0,>=1.17.0", -] -files = [ - {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, - {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, - {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, - {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, - {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, - {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, - {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, - {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, - {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, - {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, - {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, - {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, - {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, - {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, - {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, - {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, - {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, - {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, - {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, - {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, - {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, - {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, - {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, - {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, - {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, - {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, - {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, - {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, - {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, - {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, - {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, - {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, - {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, -] - -[[package]] -name = "aioitertools" -version = "0.13.0" -requires_python = ">=3.9" -summary = "itertools and builtins for AsyncIO and mixed iterables" -groups = ["dev-consumers"] -dependencies = [ - "typing-extensions>=4.0; python_version < \"3.10\"", -] -files = [ - {file = "aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be"}, - {file = "aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c"}, -] - -[[package]] -name = "aiormq" -version = "6.9.2" -requires_python = "<4.0,>=3.10" -summary = "Pure python AMQP asynchronous client library" -groups = ["rabbitmq"] -dependencies = [ - "pamqp==3.3.0", - "yarl", -] -files = [ - {file = "aiormq-6.9.2-py3-none-any.whl", hash = "sha256:ab0f4e88e70f874b0ea344b3c41634d2484b5dc8b17cb6ae0ae7892a172ad003"}, - {file = "aiormq-6.9.2.tar.gz", hash = "sha256:d051d46086079934d3a7157f4d8dcb856b77683c2a94aee9faa165efa6a785d3"}, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -requires_python = ">=3.9" -summary = "aiosignal: a list of registered asynchronous callbacks" -groups = ["dev-consumers"] -dependencies = [ - "frozenlist>=1.1.0", - "typing-extensions>=4.2; python_version < \"3.13\"", -] -files = [ - {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, - {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, -] - -[[package]] -name = "annotated-doc" -version = "0.0.4" -requires_python = ">=3.8" -summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." -groups = ["examples"] -files = [ - {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, - {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -requires_python = ">=3.8" -summary = "Reusable constraint types to use with typing.Annotated" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.0.0; python_version < \"3.9\"", -] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.12.1" -requires_python = ">=3.9" -summary = "High-level concurrency and networking framework on top of asyncio or Trio" -groups = ["default", "dev-consumers", "examples", "tests"] -dependencies = [ - "exceptiongroup>=1.0.2; python_version < \"3.11\"", - "idna>=2.8", - "typing-extensions>=4.5; python_version < \"3.13\"", -] -files = [ - {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, - {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, -] - -[[package]] -name = "anyio" -version = "4.12.1" -extras = ["test"] -requires_python = ">=3.9" -summary = "High-level concurrency and networking framework on top of asyncio or Trio" -groups = ["tests"] -dependencies = [ - "anyio==4.12.1", -] -files = [ - {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, - {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, -] - -[[package]] -name = "asttokens" -version = "3.0.1" -requires_python = ">=3.8" -summary = "Annotate AST trees with source code positions" -groups = ["dev"] -files = [ - {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"}, - {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"}, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -requires_python = ">=3.8" -summary = "Timeout context manager for asyncio programs" -groups = ["dev-consumers"] -marker = "python_version < \"3.11\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - -[[package]] -name = "attrs" -version = "25.4.0" -requires_python = ">=3.9" -summary = "Classes Without Boilerplate" -groups = ["dev-consumers"] -files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, -] - -[[package]] -name = "authlib" -version = "1.6.6" -requires_python = ">=3.9" -summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." -groups = ["dev-consumers"] -dependencies = [ - "cryptography", -] -files = [ - {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, - {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, -] - -[[package]] -name = "aws-lambda-powertools" -version = "3.24.0" -requires_python = "<4.0.0,>=3.10" -summary = "Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverless best practices and increase developer velocity." -groups = ["dev-consumers"] -dependencies = [ - "jmespath<2.0.0,>=1.0.1", - "typing-extensions<5.0.0,>=4.11.0", -] -files = [ - {file = "aws_lambda_powertools-3.24.0-py3-none-any.whl", hash = "sha256:9c9002856f61b86f49271a9d7efa0dad322ecd22719ddc1c6bb373e57ee0421a"}, - {file = "aws_lambda_powertools-3.24.0.tar.gz", hash = "sha256:9f86959c4aeac9669da799999aae5feac7a3a86e642b52473892eaa4273d3cc3"}, -] - -[[package]] -name = "azure-core" -version = "1.37.0" -requires_python = ">=3.9" -summary = "Microsoft Azure Core Library for Python" -groups = ["azure-queue", "azure-servicebus"] -dependencies = [ - "requests>=2.21.0", - "typing-extensions>=4.6.0", -] -files = [ - {file = "azure_core-1.37.0-py3-none-any.whl", hash = "sha256:b3abe2c59e7d6bb18b38c275a5029ff80f98990e7c90a5e646249a56630fcc19"}, - {file = "azure_core-1.37.0.tar.gz", hash = "sha256:7064f2c11e4b97f340e8e8c6d923b822978be3016e46b7bc4aa4b337cfb48aee"}, -] - -[[package]] -name = "azure-identity" -version = "1.25.1" -requires_python = ">=3.9" -summary = "Microsoft Azure Identity Library for Python" -groups = ["azure-queue", "azure-servicebus"] -dependencies = [ - "azure-core>=1.31.0", - "cryptography>=2.5", - "msal-extensions>=1.2.0", - "msal>=1.30.0", - "typing-extensions>=4.0.0", -] -files = [ - {file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"}, - {file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"}, -] - -[[package]] -name = "azure-servicebus" -version = "7.14.3" -requires_python = ">=3.9" -summary = "Microsoft Azure Service Bus Client Library for Python" -groups = ["azure-servicebus"] -dependencies = [ - "azure-core>=1.28.0", - "isodate>=0.6.0", - "typing-extensions>=4.6.0", -] -files = [ - {file = "azure_servicebus-7.14.3-py3-none-any.whl", hash = "sha256:386f8d32dae8881661ec8d791c38978eca2bbf7ea9f489d6cff8ad9cc6990234"}, - {file = "azure_servicebus-7.14.3.tar.gz", hash = "sha256:70a63384557aec0bee727740e7b25ded29e9e701b77611764577fd7402389402"}, -] - -[[package]] -name = "azure-storage-queue" -version = "12.15.0" -requires_python = ">=3.9" -summary = "Microsoft Azure Azure Queue Storage Client Library for Python" -groups = ["azure-queue"] -dependencies = [ - "azure-core>=1.30.0", - "cryptography>=2.1.4", - "isodate>=0.6.1", - "typing-extensions>=4.6.0", -] -files = [ - {file = "azure_storage_queue-12.15.0-py3-none-any.whl", hash = "sha256:056cfce0cd60458f0b7653d804f639098b14593f843899c6c0fc65b3ebe61210"}, - {file = "azure_storage_queue-12.15.0.tar.gz", hash = "sha256:4e01dcae5aefd0c463f7bae5c75c8a91f955c893f14ed7590fc0cd447ac4666d"}, -] - -[[package]] -name = "boto3" -version = "1.40.61" -requires_python = ">=3.9" -summary = "The AWS SDK for Python" -groups = ["dev-consumers", "integration-tests"] -dependencies = [ - "botocore<1.41.0,>=1.40.61", - "jmespath<2.0.0,>=0.7.1", - "s3transfer<0.15.0,>=0.14.0", -] -files = [ - {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, - {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, -] - -[[package]] -name = "botocore" -version = "1.40.61" -requires_python = ">=3.9" -summary = "Low-level, data-driven core of boto 3." -groups = ["dev-consumers", "integration-tests", "sqs"] -dependencies = [ - "jmespath<2.0.0,>=0.7.1", - "python-dateutil<3.0.0,>=2.1", - "urllib3!=2.2.0,<3,>=1.25.4; python_version >= \"3.10\"", - "urllib3<1.27,>=1.25.4; python_version < \"3.10\"", -] -files = [ - {file = "botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7"}, - {file = "botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd"}, -] - -[[package]] -name = "botocore-stubs" -version = "1.42.24" -requires_python = ">=3.9" -summary = "Type annotations and code completion for botocore" -groups = ["dev-types"] -dependencies = [ - "types-awscrt", -] -files = [ - {file = "botocore_stubs-1.42.24-py3-none-any.whl", hash = "sha256:025999e68f419472cc8dfb7bcc2964fa0a06b447f43e7fc309012ff4c665b3db"}, - {file = "botocore_stubs-1.42.24.tar.gz", hash = "sha256:f5fbe240267b27036b1217a304de34bf2bf993087e049a300d17d6f52d77988b"}, -] - -[[package]] -name = "cachetools" -version = "6.2.4" -requires_python = ">=3.9" -summary = "Extensible memoizing collections and decorators" -groups = ["dev-consumers"] -files = [ - {file = "cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51"}, - {file = "cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607"}, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -requires_python = ">=3.7" -summary = "Python package for providing Mozilla's CA Bundle." -groups = ["azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "integration-tests", "pubsub"] -files = [ - {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, - {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -requires_python = ">=3.9" -summary = "Foreign Function Interface for Python calling C code." -groups = ["azure-queue", "azure-servicebus", "dev-consumers"] -marker = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\"" -dependencies = [ - "pycparser; implementation_name != \"PyPy\"", -] -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -requires_python = ">=3.7" -summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["azure-queue", "azure-servicebus", "dev-otel", "integration-tests", "pubsub"] -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - -[[package]] -name = "click" -version = "8.3.1" -requires_python = ">=3.10" -summary = "Composable command line interface toolkit" -groups = ["dev-hosting-http"] -dependencies = [ - "colorama; platform_system == \"Windows\"", -] -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -summary = "Cross-platform colored terminal text." -groups = ["dev", "dev-hosting-http", "tests", "unit-tests"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "confluent-kafka" -version = "2.13.0" -requires_python = ">=3.8" -summary = "Confluent's Python client for Apache Kafka" -groups = ["dev-consumers", "kafka"] -files = [ - {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:264f86956d3bbd26bc14fd2d4060908a0c53d26b14ff33d21fa83369f55d8d3b"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4af5f129acce30110c7a1c9c05c54821c1283850e38c67715bed7c3c8df322c6"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b975664e821d975c85ec9bba806b7c62eb9b23cf2ad3b41813862ee24caf00e"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fc633deedd3eba5c266bf12959d8c4173806d252db45d2c78d3bd9a874dc7ccf"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:2c0b19a83f519de8f2cb170bc7879b1d92ac342a75798722fbfe29965f21d5ad"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:af19d6a2ab49f02cf699bc1dfb6f4bdcc3588e077c7b5319d73335b81fef93fd"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a406800c29e568e61ab687540391ac8eab210b79d24d7eb41b75b6117882e269"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9bc38055f5a26bec15ca81b0465f90cb2663a31c19948d6421dc02090b4cbd4a"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7080a20f64293b1f81747748e6bda0d1e9a9d5d5f94cd1b0c625cfdf819f491f"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:39fdfd4fa6371ebabf9e7be858ae68d4fa9a9fd72fd5fc3d739fdaa99997fd9a"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a69263f22a8c53c7d55067e7795ed49d22c374b9473df91982816a0448e6d242"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0945c7f529e66a18aa19135aa18bfdc239ca6c0f6df6ca9b05a793d9b76c1c4b"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:321037a64c02acb13b5bde193b461c0514dca236a9f5236c847a3240313c297f"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d7a71ca8fd42d3eefa22eb202e7fc8a419e0fd4e3b59862918c21f4d1074f0c5"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:37dddb1b92829b8862bc4fbce07789a79b73aa31eca413f3db187721a09975ff"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0af90b3c566786017a01693da0ec4a876ca14cf37bc6164872652a6cf2702453"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f25d05604dd92e9de72707582dded53aeb4737ef2e2c097a3ca08650200fc446"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:74ddf5ec7fa6058221a619c850f44bdbe8d969d7ed6efe8abdc857d2e233df20"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f8d1d00397f3f32a1bcf4604d4164bf75838bd009e1e28282a7ae25e16814ea4"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1fd035e2c47c4db5fe9b0f59a28fe2f2f1012887290dd0ea7d46741f686e99"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d448537147a33dd8c17656732989ddfe1d4a25a40bcb5f59bc63dc0a5041dd83"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:1325585f9fc283c32c30df4226178dc89cf43d00f5c240e1f77ddedc94573690"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:266bbea18ce99f6e77ce0e9a118f353447c8705792ef5745eabcc5c6db08794a"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7a373a1a3dd8e02dd218946583e951791480921fd777faeaf601c2834e2a6c0d"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:da956b2141d9f425dbfc3cf1c244ef6d0633b83fc5ceada6f496099258e63a68"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:fa354e9fb95e26545decd429477072ea3a98a2e7acac11d09156772e06f14680"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:e256dc3a993bf5fde0fa4a1b6a5b72e3521cedbc9dc4d7a65f159afa4b72e5b4"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eb7988038b7f13ea490af93b9165ed40a3f385fb91e99ce8dacee8890e36e48a"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cfd8f011b0b0a109f8747312dba6cee45b39fb53fc7d53f28813bce84a91228d"}, - {file = "confluent_kafka-2.13.0.tar.gz", hash = "sha256:eff7a4391a9e6d4a33f0c05d0935b200a7463834f1f5d6e6253be318f910babd"}, -] - -[[package]] -name = "confluent-kafka" -version = "2.13.0" -extras = ["protobuf", "schemaregistry"] -requires_python = ">=3.8" -summary = "Confluent's Python client for Apache Kafka" -groups = ["dev-consumers"] -dependencies = [ - "attrs>=21.2.0", - "attrs>=21.2.0", - "authlib>=1.0.0", - "authlib>=1.0.0", - "cachetools>=5.5.0", - "cachetools>=5.5.0", - "certifi", - "certifi", - "confluent-kafka==2.13.0", - "googleapis-common-protos", - "httpx>=0.26", - "httpx>=0.26", - "protobuf", -] -files = [ - {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:264f86956d3bbd26bc14fd2d4060908a0c53d26b14ff33d21fa83369f55d8d3b"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4af5f129acce30110c7a1c9c05c54821c1283850e38c67715bed7c3c8df322c6"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b975664e821d975c85ec9bba806b7c62eb9b23cf2ad3b41813862ee24caf00e"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fc633deedd3eba5c266bf12959d8c4173806d252db45d2c78d3bd9a874dc7ccf"}, - {file = "confluent_kafka-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:2c0b19a83f519de8f2cb170bc7879b1d92ac342a75798722fbfe29965f21d5ad"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:af19d6a2ab49f02cf699bc1dfb6f4bdcc3588e077c7b5319d73335b81fef93fd"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a406800c29e568e61ab687540391ac8eab210b79d24d7eb41b75b6117882e269"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9bc38055f5a26bec15ca81b0465f90cb2663a31c19948d6421dc02090b4cbd4a"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7080a20f64293b1f81747748e6bda0d1e9a9d5d5f94cd1b0c625cfdf819f491f"}, - {file = "confluent_kafka-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:39fdfd4fa6371ebabf9e7be858ae68d4fa9a9fd72fd5fc3d739fdaa99997fd9a"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a69263f22a8c53c7d55067e7795ed49d22c374b9473df91982816a0448e6d242"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0945c7f529e66a18aa19135aa18bfdc239ca6c0f6df6ca9b05a793d9b76c1c4b"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:321037a64c02acb13b5bde193b461c0514dca236a9f5236c847a3240313c297f"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d7a71ca8fd42d3eefa22eb202e7fc8a419e0fd4e3b59862918c21f4d1074f0c5"}, - {file = "confluent_kafka-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:37dddb1b92829b8862bc4fbce07789a79b73aa31eca413f3db187721a09975ff"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0af90b3c566786017a01693da0ec4a876ca14cf37bc6164872652a6cf2702453"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f25d05604dd92e9de72707582dded53aeb4737ef2e2c097a3ca08650200fc446"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:74ddf5ec7fa6058221a619c850f44bdbe8d969d7ed6efe8abdc857d2e233df20"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f8d1d00397f3f32a1bcf4604d4164bf75838bd009e1e28282a7ae25e16814ea4"}, - {file = "confluent_kafka-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1fd035e2c47c4db5fe9b0f59a28fe2f2f1012887290dd0ea7d46741f686e99"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d448537147a33dd8c17656732989ddfe1d4a25a40bcb5f59bc63dc0a5041dd83"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:1325585f9fc283c32c30df4226178dc89cf43d00f5c240e1f77ddedc94573690"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:266bbea18ce99f6e77ce0e9a118f353447c8705792ef5745eabcc5c6db08794a"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7a373a1a3dd8e02dd218946583e951791480921fd777faeaf601c2834e2a6c0d"}, - {file = "confluent_kafka-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:da956b2141d9f425dbfc3cf1c244ef6d0633b83fc5ceada6f496099258e63a68"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:fa354e9fb95e26545decd429477072ea3a98a2e7acac11d09156772e06f14680"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:e256dc3a993bf5fde0fa4a1b6a5b72e3521cedbc9dc4d7a65f159afa4b72e5b4"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eb7988038b7f13ea490af93b9165ed40a3f385fb91e99ce8dacee8890e36e48a"}, - {file = "confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cfd8f011b0b0a109f8747312dba6cee45b39fb53fc7d53f28813bce84a91228d"}, - {file = "confluent_kafka-2.13.0.tar.gz", hash = "sha256:eff7a4391a9e6d4a33f0c05d0935b200a7463834f1f5d6e6253be318f910babd"}, -] - -[[package]] -name = "coverage" -version = "7.13.1" -requires_python = ">=3.10" -summary = "Code coverage measurement for Python" -groups = ["unit-tests"] -files = [ - {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, - {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, - {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, - {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, - {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, - {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, - {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, - {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, - {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, - {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, - {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, - {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, - {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, - {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, - {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, - {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, - {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, - {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, - {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, - {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, - {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, - {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, - {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, - {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, -] - -[[package]] -name = "coverage" -version = "7.13.1" -extras = ["toml"] -requires_python = ">=3.10" -summary = "Code coverage measurement for Python" -groups = ["unit-tests"] -dependencies = [ - "coverage==7.13.1", - "tomli; python_full_version <= \"3.11.0a6\"", -] -files = [ - {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, - {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, - {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, - {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, - {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, - {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, - {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, - {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, - {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, - {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, - {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, - {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, - {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, - {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, - {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, - {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, - {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, - {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, - {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, - {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, - {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, - {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, - {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, - {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, -] - -[[package]] -name = "croniter" -version = "3.0.4" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6" -summary = "croniter provides iteration for datetime object with cron like format" -groups = ["cron"] -dependencies = [ - "python-dateutil", - "pytz>2021.1", -] -files = [ - {file = "croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0"}, - {file = "croniter-3.0.4.tar.gz", hash = "sha256:f9dcd4bdb6c97abedb6f09d6ed3495b13ede4d4544503fa580b6372a56a0c520"}, -] - -[[package]] -name = "cryptography" -version = "46.0.3" -requires_python = "!=3.9.0,!=3.9.1,>=3.8" -summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -groups = ["azure-queue", "azure-servicebus", "dev-consumers"] -dependencies = [ - "cffi>=1.14; python_full_version == \"3.8.*\" and platform_python_implementation != \"PyPy\"", - "cffi>=2.0.0; python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", - "typing-extensions>=4.13.2; python_full_version < \"3.11\"", -] -files = [ - {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, - {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, - {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, - {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, - {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, - {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, - {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, - {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, - {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, - {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, - {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, -] - -[[package]] -name = "docker" -version = "7.1.0" -requires_python = ">=3.8" -summary = "A Python library for the Docker Engine API." -groups = ["integration-tests"] -dependencies = [ - "pywin32>=304; sys_platform == \"win32\"", - "requests>=2.26.0", - "urllib3>=1.26.0", -] -files = [ - {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, - {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -requires_python = ">=3.7" -summary = "Backport of PEP 654 (exception groups)" -groups = ["default", "dev-consumers", "dev-hosting-http", "examples", "rabbitmq", "tests", "unit-tests"] -marker = "python_version < \"3.11\"" -dependencies = [ - "typing-extensions>=4.6.0; python_version < \"3.13\"", -] -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[[package]] -name = "execnet" -version = "2.1.2" -requires_python = ">=3.8" -summary = "execnet: rapid multi-Python deployment" -groups = ["tests"] -files = [ - {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, - {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, -] - -[[package]] -name = "executing" -version = "2.2.1" -requires_python = ">=3.8" -summary = "Get the currently executing AST node of a frame, and other information" -groups = ["dev"] -files = [ - {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, - {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, -] - -[[package]] -name = "fast-depends" -version = "2.4.12" -requires_python = ">=3.8" -summary = "FastDepends - extracted and cleared from HTTP domain logic FastAPI Dependency Injection System. Async and sync are both supported." -groups = ["examples"] -dependencies = [ - "anyio<5.0.0,>=3.0.0", - "pydantic!=1.8,!=1.8.1,<3.0.0,>=1.7.4", - "typing-extensions<4.12.1; python_version < \"3.9\"", -] -files = [ - {file = "fast_depends-2.4.12-py3-none-any.whl", hash = "sha256:9e5d110ddc962329e46c9b35e5fe65655984247a13ee3ca5a33186db7d2d75c2"}, - {file = "fast_depends-2.4.12.tar.gz", hash = "sha256:9393e6de827f7afa0141e54fa9553b737396aaf06bd0040e159d1f790487b16d"}, -] - -[[package]] -name = "fastapi-slim" -version = "0.128.0" -requires_python = ">=3.9" -summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -groups = ["examples"] -dependencies = [ - "annotated-doc>=0.0.2", - "pydantic>=2.7.0", - "starlette<0.51.0,>=0.40.0", - "typing-extensions>=4.8.0", -] -files = [ - {file = "fastapi_slim-0.128.0-py3-none-any.whl", hash = "sha256:19034e3e48503573fe429d94906d4b5180aa7c754f9148a55ab1981abdb73293"}, - {file = "fastapi_slim-0.128.0.tar.gz", hash = "sha256:7916e28ec3bd897ea4adff9e8d257ff6edba0c1f383640896d54099f78a8afef"}, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -requires_python = ">=3.9" -summary = "A list-like structure which implements collections.abc.MutableSequence" -groups = ["dev-consumers"] -files = [ - {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, - {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, - {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, - {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, - {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, - {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, - {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, - {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, - {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, - {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, - {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, - {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, - {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, - {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, - {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, - {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, - {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, - {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, - {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, - {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, -] - -[[package]] -name = "google-api-core" -version = "2.29.0" -requires_python = ">=3.7" -summary = "Google API client core library" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "google-auth<3.0.0,>=2.14.1", - "googleapis-common-protos<2.0.0,>=1.56.2", - "importlib-metadata>=1.4; python_version < \"3.8\"", - "proto-plus<2.0.0,>=1.22.3", - "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", - "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.19.5", - "requests<3.0.0,>=2.18.0", -] -files = [ - {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, - {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, -] - -[[package]] -name = "google-api-core" -version = "2.29.0" -extras = ["grpc"] -requires_python = ">=3.7" -summary = "Google API client core library" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "google-api-core==2.29.0", - "grpcio-status<2.0.0,>=1.33.2", - "grpcio-status<2.0.0,>=1.49.1; python_version >= \"3.11\"", - "grpcio-status<2.0.0,>=1.75.1; python_version >= \"3.14\"", - "grpcio<2.0.0,>=1.33.2", - "grpcio<2.0.0,>=1.49.1; python_version >= \"3.11\"", - "grpcio<2.0.0,>=1.75.1; python_version >= \"3.14\"", -] -files = [ - {file = "google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9"}, - {file = "google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7"}, -] - -[[package]] -name = "google-auth" -version = "2.47.0" -requires_python = ">=3.8" -summary = "Google Authentication Library" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "pyasn1-modules>=0.2.1", - "rsa<5,>=3.1.4", -] -files = [ - {file = "google_auth-2.47.0-py3-none-any.whl", hash = "sha256:c516d68336bfde7cf0da26aab674a36fedcf04b37ac4edd59c597178760c3498"}, - {file = "google_auth-2.47.0.tar.gz", hash = "sha256:833229070a9dfee1a353ae9877dcd2dec069a8281a4e72e72f77d4a70ff945da"}, -] - -[[package]] -name = "google-cloud-core" -version = "2.5.0" -requires_python = ">=3.7" -summary = "Google Cloud API client core library" -groups = ["integration-tests"] -dependencies = [ - "google-api-core!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0,>=1.31.6", - "google-auth<3.0.0,>=1.25.0", - "importlib-metadata>1.0.0; python_version < \"3.8\"", -] -files = [ - {file = "google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc"}, - {file = "google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963"}, -] - -[[package]] -name = "google-cloud-datastore" -version = "2.23.0" -requires_python = ">=3.7" -summary = "Google Cloud Datastore API client library" -groups = ["integration-tests"] -dependencies = [ - "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0", - "google-auth!=2.24.0,!=2.25.0,<3.0.0,>=2.14.1", - "google-cloud-core<3.0.0,>=1.4.0", - "grpcio<2.0.0,>=1.38.0", - "grpcio<2.0.0,>=1.75.1; python_version >= \"3.14\"", - "proto-plus<2.0.0,>=1.22.0", - "proto-plus<2.0.0,>=1.22.2; python_version >= \"3.11\"", - "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", - "protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", -] -files = [ - {file = "google_cloud_datastore-2.23.0-py3-none-any.whl", hash = "sha256:24a1b1d29b902148fe41b109699f76fd3aa60591e9d547c0f8b87d7bf9ff213f"}, - {file = "google_cloud_datastore-2.23.0.tar.gz", hash = "sha256:80049883a4ae928fdcc661ba6803ec267665dc0e6f3ce2da91441079a6bb6387"}, -] - -[[package]] -name = "google-cloud-pubsub" -version = "2.34.0" -requires_python = ">=3.7" -summary = "Google Cloud Pub/Sub API client library" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0", - "google-auth<3.0.0,>=2.14.1", - "grpc-google-iam-v1<1.0.0,>=0.12.4", - "grpcio-status>=1.33.2", - "grpcio<2.0.0,>=1.51.3; python_version < \"3.14\"", - "grpcio<2.0.0,>=1.75.1; python_version >= \"3.14\"", - "opentelemetry-api<=1.22.0; python_version <= \"3.7\"", - "opentelemetry-api>=1.27.0; python_version >= \"3.8\"", - "opentelemetry-sdk<=1.22.0; python_version <= \"3.7\"", - "opentelemetry-sdk>=1.27.0; python_version >= \"3.8\"", - "proto-plus<2.0.0,>=1.22.0", - "proto-plus<2.0.0,>=1.22.2; python_version >= \"3.11\"", - "proto-plus<2.0.0,>=1.25.0; python_version >= \"3.13\"", - "protobuf!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", -] -files = [ - {file = "google_cloud_pubsub-2.34.0-py3-none-any.whl", hash = "sha256:aa11b2471c6d509058b42a103ed1b3643f01048311a34fd38501a16663267206"}, - {file = "google_cloud_pubsub-2.34.0.tar.gz", hash = "sha256:25f98c3ba16a69871f9ebbad7aece3fe63c8afe7ba392aad2094be730d545976"}, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -requires_python = ">=3.7" -summary = "Common protobufs used in Google APIs" -groups = ["dev-consumers", "dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", -] -files = [ - {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, - {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -extras = ["grpc"] -requires_python = ">=3.7" -summary = "Common protobufs used in Google APIs" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "googleapis-common-protos==1.72.0", - "grpcio<2.0.0,>=1.44.0", -] -files = [ - {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, - {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, -] - -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.3" -requires_python = ">=3.7" -summary = "IAM API client library" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "googleapis-common-protos[grpc]<2.0.0,>=1.56.0", - "grpcio<2.0.0,>=1.44.0", - "protobuf!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2", -] -files = [ - {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"}, - {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"}, -] - -[[package]] -name = "grpcio" -version = "1.76.0" -requires_python = ">=3.9" -summary = "HTTP/2-based RPC framework" -groups = ["dev-consumers", "dev-hosting-grpc", "dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "typing-extensions~=4.12", -] -files = [ - {file = "grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc"}, - {file = "grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde"}, - {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3"}, - {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990"}, - {file = "grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af"}, - {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2"}, - {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6"}, - {file = "grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3"}, - {file = "grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b"}, - {file = "grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b"}, - {file = "grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a"}, - {file = "grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c"}, - {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465"}, - {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48"}, - {file = "grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da"}, - {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397"}, - {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749"}, - {file = "grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00"}, - {file = "grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054"}, - {file = "grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d"}, - {file = "grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8"}, - {file = "grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280"}, - {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4"}, - {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11"}, - {file = "grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6"}, - {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8"}, - {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980"}, - {file = "grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882"}, - {file = "grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958"}, - {file = "grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347"}, - {file = "grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2"}, - {file = "grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468"}, - {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3"}, - {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb"}, - {file = "grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae"}, - {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77"}, - {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03"}, - {file = "grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42"}, - {file = "grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f"}, - {file = "grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8"}, - {file = "grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62"}, - {file = "grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd"}, - {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc"}, - {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a"}, - {file = "grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba"}, - {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09"}, - {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc"}, - {file = "grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc"}, - {file = "grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e"}, - {file = "grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e"}, - {file = "grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73"}, -] - -[[package]] -name = "grpcio-status" -version = "1.76.0" -requires_python = ">=3.9" -summary = "Status proto mapping for gRPC" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "googleapis-common-protos>=1.5.5", - "grpcio>=1.76.0", - "protobuf<7.0.0,>=6.31.1", -] -files = [ - {file = "grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18"}, - {file = "grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd"}, -] - -[[package]] -name = "grpcio-tools" -version = "1.76.0" -requires_python = ">=3.9" -summary = "Protobuf code generator for gRPC" -groups = ["dev-consumers"] -dependencies = [ - "grpcio>=1.76.0", - "protobuf<7.0.0,>=6.31.1", - "setuptools", -] -files = [ - {file = "grpcio_tools-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:9b99086080ca394f1da9894ee20dedf7292dd614e985dcba58209a86a42de602"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d95b5c2394bbbe911cbfc88d15e24c9e174958cb44dad6aa8c46fe367f6cc2a"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d54e9ce2ffc5d01341f0c8898c1471d887ae93d77451884797776e0a505bd503"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c83f39f64c2531336bd8d5c846a2159c9ea6635508b0f8ed3ad0d433e25b53c9"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be480142fae0d986d127d6cb5cbc0357e4124ba22e96bb8b9ece32c48bc2c8ea"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7fefd41fc4ca11fab36f42bdf0f3812252988f8798fca8bec8eae049418deacd"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:63551f371082173e259e7f6ec24b5f1fe7d66040fadd975c966647bca605a2d3"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75a2c34584c99ff47e5bb267866e7dec68d30cd3b2158e1ee495bfd6db5ad4f0"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-win32.whl", hash = "sha256:908758789b0a612102c88e8055b7191eb2c4290d5d6fc50fb9cac737f8011ef1"}, - {file = "grpcio_tools-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:ec6e49e7c4b2a222eb26d1e1726a07a572b6e629b2cf37e6bb784c9687904a52"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:c6480f6af6833850a85cca1c6b435ef4ffd2ac8e88ef683b4065233827950243"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c7c23fe1dc09818e16a48853477806ad77dd628b33996f78c05a293065f8210c"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fcdce7f7770ff052cd4e60161764b0b3498c909bde69138f8bd2e7b24a3ecd8f"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b598fdcebffa931c7da5c9e90b5805fff7e9bc6cf238319358a1b85704c57d33"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6a9818ff884796b12dcf8db32126e40ec1098cacf5697f27af9cfccfca1c1fae"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:105e53435b2eed3961da543db44a2a34479d98d18ea248219856f30a0ca4646b"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:454a1232c7f99410d92fa9923c7851fd4cdaf657ee194eac73ea1fe21b406d6e"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca9ccf667afc0268d45ab202af4556c72e57ea36ebddc93535e1a25cbd4f8aba"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-win32.whl", hash = "sha256:a83c87513b708228b4cad7619311daba65b40937745103cadca3db94a6472d9c"}, - {file = "grpcio_tools-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ce5e87ec71f2e4041dce4351f2a8e3b713e3bca6b54c69c3fbc6c7ad1f4c386"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3dafa34c2626a6691d103877e8a145f54c34cf6530975f695b396ed2fc5c98f8"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30f1d2dda6ece285b3d9084e94f66fa721ebdba14ae76b2bc4c581c8a166535c"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a889af059dc6dbb82d7b417aa581601316e364fe12eb54c1b8d95311ea50916d"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3f2c3c44c56eb5d479ab178f0174595d0a974c37dade442f05bb73dfec02f31"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:479ce02dff684046f909a487d452a83a96b4231f7c70a3b218a075d54e951f56"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-win32.whl", hash = "sha256:9ba4bb539936642a44418b38ee6c3e8823c037699e2cb282bd8a44d76a4be833"}, - {file = "grpcio_tools-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd489016766b05f9ed8a6b6596004b62c57d323f49593eac84add032a6d43f7"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ff48969f81858397ef33a36b326f2dbe2053a48b254593785707845db73c8f44"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa2f030fd0ef17926026ee8e2b700e388d3439155d145c568fa6b32693277613"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bacbf3c54f88c38de8e28f8d9b97c90b76b105fb9ddef05d2c50df01b32b92af"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d4e4afe9a0e3c24fad2f1af45f98cf8700b2bfc4d790795756ba035d2ea7bdc"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbbd4e1fc5af98001ceef5e780e8c10921d94941c3809238081e73818ef707f1"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b05efe5a59883ab8292d596657273a60e0c3e4f5a9723c32feb9fc3a06f2f3ef"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:be483b90e62b7892eb71fa1fc49750bee5b2ee35b5ec99dd2b32bed4bedb5d71"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:630cd7fd3e8a63e20703a7ad816979073c2253e591b5422583c27cae2570de73"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-win32.whl", hash = "sha256:eb2567280f9f6da5444043f0e84d8408c7a10df9ba3201026b30e40ef3814736"}, - {file = "grpcio_tools-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:0071b1c0bd0f5f9d292dca4efab32c92725d418e57f9c60acdc33c0172af8b53"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:c53c5719ef2a435997755abde3826ba4087174bd432aa721d8fac781fcea79e4"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e3db1300d7282264639eeee7243f5de7e6a7c0283f8bf05d66c0315b7b0f0b36"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b018a4b7455a7e8c16d0fdb3655a6ba6c9536da6de6c5d4f11b6bb73378165b"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ec6e4de3866e47cfde56607b1fae83ecc5aa546e06dec53de11f88063f4b5275"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8da4d828883913f1852bdd67383713ae5c11842f6c70f93f31893eab530aead"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c120c2cf4443121800e7f9bcfe2e94519fa25f3bb0b9882359dd3b252c78a7b"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8b7df5591d699cd9076065f1f15049e9c3597e0771bea51c8c97790caf5e4197"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a25048c5f984d33e3f5b6ad7618e98736542461213ade1bd6f2fcfe8ce804e3d"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-win32.whl", hash = "sha256:4b77ce6b6c17869858cfe14681ad09ed3a8a80e960e96035de1fd87f78158740"}, - {file = "grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc"}, - {file = "grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88"}, -] - -[[package]] -name = "h11" -version = "0.16.0" -requires_python = ">=3.8" -summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["dev-consumers", "dev-hosting-http"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "h2" -version = "4.3.0" -requires_python = ">=3.9" -summary = "Pure-Python HTTP/2 protocol implementation" -groups = ["dev-hosting-http"] -dependencies = [ - "hpack<5,>=4.1", - "hyperframe<7,>=6.1", -] -files = [ - {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, - {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, -] - -[[package]] -name = "hpack" -version = "4.1.0" -requires_python = ">=3.9" -summary = "Pure-Python HPACK header encoding" -groups = ["dev-hosting-http"] -files = [ - {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, - {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -requires_python = ">=3.8" -summary = "A minimal low-level HTTP client." -groups = ["dev-consumers"] -dependencies = [ - "certifi", - "h11>=0.16", -] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[[package]] -name = "httpx" -version = "0.28.1" -requires_python = ">=3.8" -summary = "The next generation HTTP client." -groups = ["dev-consumers"] -dependencies = [ - "anyio", - "certifi", - "httpcore==1.*", - "idna", -] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[[package]] -name = "humanize" -version = "4.15.0" -requires_python = ">=3.10" -summary = "Python humanize utilities" -groups = ["scheduler"] -files = [ - {file = "humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769"}, - {file = "humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10"}, -] - -[[package]] -name = "hypercorn" -version = "0.18.0" -requires_python = ">=3.10" -summary = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" -groups = ["dev-hosting-http"] -dependencies = [ - "exceptiongroup>=1.1.0; python_version < \"3.11\"", - "h11", - "h2>=4.3.0", - "priority", - "taskgroup; python_version < \"3.11\"", - "tomli; python_version < \"3.11\"", - "typing-extensions; python_version < \"3.11\"", - "wsproto>=0.14.0", -] -files = [ - {file = "hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd"}, - {file = "hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da"}, -] - -[[package]] -name = "hyperframe" -version = "6.1.0" -requires_python = ">=3.9" -summary = "Pure-Python HTTP/2 framing" -groups = ["dev-hosting-http"] -files = [ - {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, - {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, -] - -[[package]] -name = "icecream" -version = "2.1.8" -summary = "Never use print() to debug again: inspect variables, expressions, and program execution with a single, simple function call." -groups = ["dev"] -dependencies = [ - "asttokens>=2.0.1", - "colorama>=0.3.9", - "executing>=2.1.0", - "pygments>=2.2.0", -] -files = [ - {file = "icecream-2.1.8-py3-none-any.whl", hash = "sha256:10b1c39dcb54cb28eb487bac56c35dbf9c2b2f406d24340e1a615c3f17274852"}, - {file = "icecream-2.1.8.tar.gz", hash = "sha256:37269bbc62b02f0d85bfaf3a0eb4df272c967fad059f7ddcdaee5303ea2b2a62"}, -] - -[[package]] -name = "idna" -version = "3.11" -requires_python = ">=3.8" -summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default", "azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "examples", "integration-tests", "pubsub", "rabbitmq", "tests"] -files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.1" -requires_python = ">=3.9" -summary = "Read metadata from Python packages" -groups = ["dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "zipp>=3.20", -] -files = [ - {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, - {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -requires_python = ">=3.10" -summary = "brain-dead simple config-ini parsing" -groups = ["tests", "unit-tests"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "isodate" -version = "0.7.2" -requires_python = ">=3.7" -summary = "An ISO 8601 date/time/duration parser and formatter" -groups = ["azure-queue", "azure-servicebus"] -files = [ - {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, - {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, -] - -[[package]] -name = "jmespath" -version = "1.0.1" -requires_python = ">=3.7" -summary = "JSON Matching Expressions" -groups = ["dev-consumers", "integration-tests", "sqs"] -files = [ - {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, - {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, -] - -[[package]] -name = "msal" -version = "1.34.0" -requires_python = ">=3.8" -summary = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect." -groups = ["azure-queue", "azure-servicebus"] -dependencies = [ - "PyJWT[crypto]<3,>=1.0.0", - "cryptography<49,>=2.5", - "requests<3,>=2.0.0", -] -files = [ - {file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"}, - {file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"}, -] - -[[package]] -name = "msal-extensions" -version = "1.3.1" -requires_python = ">=3.9" -summary = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism." -groups = ["azure-queue", "azure-servicebus"] -dependencies = [ - "msal<2,>=1.29", -] -files = [ - {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, - {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, -] - -[[package]] -name = "multidict" -version = "6.7.0" -requires_python = ">=3.9" -summary = "multidict implementation" -groups = ["dev-consumers", "rabbitmq"] -dependencies = [ - "typing-extensions>=4.1.0; python_version < \"3.11\"", -] -files = [ - {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, - {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, - {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, - {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, - {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, - {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, - {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, - {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, - {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, - {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, - {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, - {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, - {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, - {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, - {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, - {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, - {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, - {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, - {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, - {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, - {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, - {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, - {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, - {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, - {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, - {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, - {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, - {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, - {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, - {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, - {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, - {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, - {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, - {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, - {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, - {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, - {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, - {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, - {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, - {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, - {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, - {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, - {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, - {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, - {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, - {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, - {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, - {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, - {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, - {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, - {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, - {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, - {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, - {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, - {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, - {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, - {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, - {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, - {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, - {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, - {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, - {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, - {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, - {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, - {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, - {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, - {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, - {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, - {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, - {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, - {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, - {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, - {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, - {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, - {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, - {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, - {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, - {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, - {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, - {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, - {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, - {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, - {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, - {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, - {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, - {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, - {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, - {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, - {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, - {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, - {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, - {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, - {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, - {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, - {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, - {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, - {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, - {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, - {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, - {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, - {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, - {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, - {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, - {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, - {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, - {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, - {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, - {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, - {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, - {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, - {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, - {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, - {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, - {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, - {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, - {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, - {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, - {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, - {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, - {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, - {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, - {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, - {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, - {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, - {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, - {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, - {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, - {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, -] - -[[package]] -name = "nats-py" -version = "2.12.0" -requires_python = ">=3.7" -summary = "NATS client for Python" -groups = ["integration-tests", "nats"] -files = [ - {file = "nats_py-2.12.0.tar.gz", hash = "sha256:2981ca4b63b8266c855573fa7871b1be741f1889fd429ee657e5ffc0971a38a1"}, -] - -[[package]] -name = "opentelemetry-api" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Python API" -groups = ["dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "importlib-metadata<8.8.0,>=6.0", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, - {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Collector Exporters" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-exporter-otlp-proto-grpc==1.39.1", - "opentelemetry-exporter-otlp-proto-http==1.39.1", -] -files = [ - {file = "opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe"}, - {file = "opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Protobuf encoding" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-proto==1.39.1", -] -files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Collector Protobuf over gRPC Exporter" -groups = ["dev-otel"] -dependencies = [ - "googleapis-common-protos~=1.57", - "grpcio<2.0.0,>=1.63.2; python_version < \"3.13\"", - "grpcio<2.0.0,>=1.66.2; python_version >= \"3.13\"", - "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.39.1", - "opentelemetry-proto==1.39.1", - "opentelemetry-sdk~=1.39.1", - "typing-extensions>=4.6.0", -] -files = [ - {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18"}, - {file = "opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad"}, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Collector Protobuf over HTTP Exporter" -groups = ["dev-otel"] -dependencies = [ - "googleapis-common-protos~=1.52", - "opentelemetry-api~=1.15", - "opentelemetry-exporter-otlp-proto-common==1.39.1", - "opentelemetry-proto==1.39.1", - "opentelemetry-sdk~=1.39.1", - "requests~=2.7", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb"}, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -requires_python = ">=3.9" -summary = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api~=1.4", - "opentelemetry-semantic-conventions==0.60b1", - "packaging>=18.0", - "wrapt<2.0.0,>=1.0.0", -] -files = [ - {file = "opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d"}, - {file = "opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a"}, -] - -[[package]] -name = "opentelemetry-instrumentation-botocore" -version = "0.60b1" -requires_python = ">=3.9" -summary = "OpenTelemetry Botocore instrumentation" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api~=1.37", - "opentelemetry-instrumentation==0.60b1", - "opentelemetry-propagator-aws-xray~=1.0", - "opentelemetry-semantic-conventions==0.60b1", -] -files = [ - {file = "opentelemetry_instrumentation_botocore-0.60b1-py3-none-any.whl", hash = "sha256:b5cb1e267545cdd96a81a1bef690b1d00c20497ac4d815ef7c79fb3c572d6fc6"}, - {file = "opentelemetry_instrumentation_botocore-0.60b1.tar.gz", hash = "sha256:198e7d74b78b1b19ea47a5f2191171227557c37bfc4f49076958d129f848a8ed"}, -] - -[[package]] -name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.60b1" -requires_python = ">=3.9" -summary = "OpenTelemetry Confluent Kafka instrumentation" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api~=1.12", - "opentelemetry-instrumentation==0.60b1", - "wrapt<2.0.0,>=1.0.0", -] -files = [ - {file = "opentelemetry_instrumentation_confluent_kafka-0.60b1-py3-none-any.whl", hash = "sha256:e25ca438f0a65266e5e580062a53e24df4e8d7e1902634f6c56122db8408e5bc"}, - {file = "opentelemetry_instrumentation_confluent_kafka-0.60b1.tar.gz", hash = "sha256:8272ecfa6ee07fefb21bcddcb1942d05d903e2b1516cb1c463a3e9d7f8dcdad1"}, -] - -[[package]] -name = "opentelemetry-propagator-aws-xray" -version = "1.0.2" -requires_python = ">=3.8" -summary = "AWS X-Ray Propagator for OpenTelemetry" -groups = ["dev-otel"] -dependencies = [ - "opentelemetry-api~=1.12", -] -files = [ - {file = "opentelemetry_propagator_aws_xray-1.0.2-py3-none-any.whl", hash = "sha256:1c99181ee228e99bddb638a0c911a297fa21f1c3a0af951f841e79919b5f1934"}, - {file = "opentelemetry_propagator_aws_xray-1.0.2.tar.gz", hash = "sha256:6b2cee5479d2ef0172307b66ed2ed151f598a0fd29b3c01133ac87ca06326260"}, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Python Proto" -groups = ["dev-otel"] -dependencies = [ - "protobuf<7.0,>=5.0", -] -files = [ - {file = "opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007"}, - {file = "opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8"}, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.39.1" -requires_python = ">=3.9" -summary = "OpenTelemetry Python SDK" -groups = ["dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "opentelemetry-api==1.39.1", - "opentelemetry-semantic-conventions==0.60b1", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, - {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.60b1" -requires_python = ">=3.9" -summary = "OpenTelemetry Semantic Conventions" -groups = ["dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "opentelemetry-api==1.39.1", - "typing-extensions>=4.5.0", -] -files = [ - {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, - {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, -] - -[[package]] -name = "packaging" -version = "25.0" -requires_python = ">=3.8" -summary = "Core utilities for Python packages" -groups = ["dev-otel", "tests", "unit-tests"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pamqp" -version = "3.3.0" -requires_python = ">=3.7" -summary = "RabbitMQ Focused AMQP low-level library" -groups = ["rabbitmq"] -files = [ - {file = "pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0"}, - {file = "pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -requires_python = ">=3.9" -summary = "plugin and hook calling mechanisms for python" -groups = ["tests", "unit-tests"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[[package]] -name = "priority" -version = "2.0.0" -requires_python = ">=3.6.1" -summary = "A pure-Python implementation of the HTTP/2 priority tree" -groups = ["dev-hosting-http"] -files = [ - {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, - {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, -] - -[[package]] -name = "propcache" -version = "0.4.1" -requires_python = ">=3.9" -summary = "Accelerated property cache" -groups = ["dev-consumers", "rabbitmq"] -files = [ - {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, - {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, - {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, - {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, - {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, - {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, - {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, - {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, - {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, - {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, - {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, - {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, - {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, - {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, - {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, - {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, - {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, - {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, - {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, - {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, - {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, - {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, - {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, - {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, - {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, - {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, - {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, - {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, - {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, - {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, - {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, - {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, - {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, - {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, - {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, - {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, - {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, - {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, - {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, - {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, - {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, - {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, - {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, - {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, - {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, - {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, - {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, - {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, - {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, - {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, - {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, - {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, - {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, - {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, - {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, - {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, - {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, - {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, -] - -[[package]] -name = "proto-plus" -version = "1.27.0" -requires_python = ">=3.7" -summary = "Beautiful, Pythonic protocol buffers" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "protobuf<7.0.0,>=3.19.0", -] -files = [ - {file = "proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82"}, - {file = "proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4"}, -] - -[[package]] -name = "protobuf" -version = "6.33.2" -requires_python = ">=3.9" -summary = "" -groups = ["dev-consumers", "dev-otel", "integration-tests", "pubsub"] -files = [ - {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"}, - {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"}, - {file = "protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43"}, - {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e"}, - {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872"}, - {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f"}, - {file = "protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c"}, - {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"}, -] - -[[package]] -name = "psycopg" -version = "3.3.2" -requires_python = ">=3.10" -summary = "PostgreSQL database adapter for Python" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.6; python_version < \"3.13\"", - "tzdata; sys_platform == \"win32\"", -] -files = [ - {file = "psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b"}, - {file = "psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7"}, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.2" -requires_python = ">=3.10" -summary = "PostgreSQL database adapter for Python -- C optimisation distribution" -groups = ["examples"] -marker = "implementation_name != \"pypy\"" -files = [ - {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0768c5f32934bb52a5df098317eca9bdcf411de627c5dca2ee57662b64b54b41"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:09b3014013f05cd89828640d3a1db5f829cc24ad8fa81b6e42b2c04685a0c9d4"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3789d452a9d17a841c7f4f97bbcba51a21f957ea35641a4c98507520e6b6a068"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44e89938d36acc4495735af70a886d206a5bfdc80258f95b69b52f68b2968d9e"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ed9da805e52985b0202aed4f352842c907c6b4fc6c7c109c6e646c32e2f43b"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3a9ccdfee4ae59cf9bf1822777e763bc097ed208f4901e21537fca1070e1391"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de9173f8cc0efd88ac2a89b3b6c287a9a0011cdc2f53b2a12c28d6fd55f9f81c"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0611f4822674f3269e507a307236efb62ae5a828fcfc923ac85fe22ca19fd7c8"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:522b79c7db547767ca923e441c19b97a2157f2f494272a119c854bba4804e186"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ea41c0229f3f5a3844ad0857a83a9f869aa7b840448fa0c200e6bcf85d33d19"}, - {file = "psycopg_binary-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ea05b499278790a8fa0ff9854ab0de2542aca02d661ddff94e830df971ff640"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4"}, - {file = "psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12"}, - {file = "psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121"}, - {file = "psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e"}, - {file = "psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1"}, -] - -[[package]] -name = "psycopg-pool" -version = "3.3.0" -requires_python = ">=3.10" -summary = "Connection Pool for Psycopg" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.6", -] -files = [ - {file = "psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063"}, - {file = "psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5"}, -] - -[[package]] -name = "psycopg" -version = "3.3.2" -extras = ["binary", "pool"] -requires_python = ">=3.10" -summary = "PostgreSQL database adapter for Python" -groups = ["examples"] -dependencies = [ - "psycopg-binary==3.3.2; implementation_name != \"pypy\"", - "psycopg-pool", - "psycopg==3.3.2", -] -files = [ - {file = "psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b"}, - {file = "psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7"}, -] - -[[package]] -name = "pyasn1" -version = "0.6.1" -requires_python = ">=3.8" -summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -groups = ["integration-tests", "pubsub"] -files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -requires_python = ">=3.8" -summary = "A collection of ASN.1-based protocols modules" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "pyasn1<0.7.0,>=0.6.1", -] -files = [ - {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, - {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, -] - -[[package]] -name = "pycparser" -version = "2.23" -requires_python = ">=3.8" -summary = "C parser in Python" -groups = ["azure-queue", "azure-servicebus", "dev-consumers"] -marker = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -requires_python = ">=3.9" -summary = "Data validation using Python type hints" -groups = ["examples"] -dependencies = [ - "annotated-types>=0.6.0", - "pydantic-core==2.41.5", - "typing-extensions>=4.14.1", - "typing-inspection>=0.4.2", -] -files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -requires_python = ">=3.9" -summary = "Core functionality for Pydantic validation and serialization" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.14.1", -] -files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, -] - -[[package]] -name = "pygments" -version = "2.19.2" -requires_python = ">=3.8" -summary = "Pygments is a syntax highlighting package written in Python." -groups = ["dev", "tests", "unit-tests"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -requires_python = ">=3.9" -summary = "JSON Web Token implementation in Python" -groups = ["azure-queue", "azure-servicebus"] -files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -extras = ["crypto"] -requires_python = ">=3.9" -summary = "JSON Web Token implementation in Python" -groups = ["azure-queue", "azure-servicebus"] -dependencies = [ - "PyJWT==2.10.1", - "cryptography>=3.4.0", -] -files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, -] - -[[package]] -name = "pytest" -version = "8.4.2" -requires_python = ">=3.9" -summary = "pytest: simple powerful testing with Python" -groups = ["tests", "unit-tests"] -dependencies = [ - "colorama>=0.4; sys_platform == \"win32\"", - "exceptiongroup>=1; python_version < \"3.11\"", - "iniconfig>=1", - "packaging>=20", - "pluggy<2,>=1.5", - "pygments>=2.7.2", - "tomli>=1; python_version < \"3.11\"", -] -files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, -] - -[[package]] -name = "pytest-cov" -version = "6.3.0" -requires_python = ">=3.9" -summary = "Pytest plugin for measuring coverage." -groups = ["unit-tests"] -dependencies = [ - "coverage[toml]>=7.5", - "pluggy>=1.2", - "pytest>=6.2.5", -] -files = [ - {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, - {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, -] - -[[package]] -name = "pytest-mock" -version = "3.15.1" -requires_python = ">=3.9" -summary = "Thin-wrapper around the mock package for easier use with pytest" -groups = ["unit-tests"] -dependencies = [ - "pytest>=6.2.5", -] -files = [ - {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, - {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, -] - -[[package]] -name = "pytest-xdist" -version = "3.8.0" -requires_python = ">=3.9" -summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" -groups = ["tests"] -dependencies = [ - "execnet>=2.1", - "pytest>=7.0.0", -] -files = [ - {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, - {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Extensions to the standard Python datetime module" -groups = ["cron", "dev-consumers", "integration-tests", "sqs"] -dependencies = [ - "six>=1.5", -] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -requires_python = ">=3.9" -summary = "Read key-value pairs from a .env file and set them as environment variables" -groups = ["integration-tests"] -files = [ - {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, - {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, -] - -[[package]] -name = "pytimeparse2" -version = "1.7.1" -requires_python = ">=3.6" -summary = "Time expression parser." -groups = ["scheduler"] -files = [ - {file = "pytimeparse2-1.7.1-py3-none-any.whl", hash = "sha256:a162ea6a7707fd0bb82dd99556efb783935f51885c8bdced0fce3fffe85ab002"}, - {file = "pytimeparse2-1.7.1.tar.gz", hash = "sha256:98668cdcba4890e1789e432e8ea0059ccf72402f13f5d52be15bdfaeb3a8b253"}, -] - -[[package]] -name = "pytz" -version = "2025.2" -summary = "World timezone definitions, modern and historical" -groups = ["cron"] -files = [ - {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, - {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, -] - -[[package]] -name = "pywin32" -version = "311" -summary = "Python for Window Extensions" -groups = ["integration-tests"] -marker = "sys_platform == \"win32\"" -files = [ - {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, - {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, - {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, - {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, - {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, - {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, - {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, - {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, - {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, - {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, - {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, - {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, - {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, - {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, - {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, -] - -[[package]] -name = "requests" -version = "2.32.5" -requires_python = ">=3.9" -summary = "Python HTTP for Humans." -groups = ["azure-queue", "azure-servicebus", "dev-otel", "integration-tests", "pubsub"] -dependencies = [ - "certifi>=2017.4.17", - "charset-normalizer<4,>=2", - "idna<4,>=2.5", - "urllib3<3,>=1.21.1", -] -files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, -] - -[[package]] -name = "rsa" -version = "4.9.1" -requires_python = "<4,>=3.6" -summary = "Pure-Python RSA implementation" -groups = ["integration-tests", "pubsub"] -dependencies = [ - "pyasn1>=0.1.3", -] -files = [ - {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, - {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, -] - -[[package]] -name = "s3transfer" -version = "0.14.0" -requires_python = ">=3.9" -summary = "An Amazon S3 Transfer Manager" -groups = ["dev-consumers", "integration-tests"] -dependencies = [ - "botocore<2.0a.0,>=1.37.4", -] -files = [ - {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, - {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, -] - -[[package]] -name = "setproctitle" -version = "1.3.7" -requires_python = ">=3.8" -summary = "A Python module to customize the process title" -groups = ["tests"] -files = [ - {file = "setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b"}, - {file = "setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7"}, - {file = "setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c"}, - {file = "setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3"}, - {file = "setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f"}, - {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64"}, - {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5"}, - {file = "setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381"}, - {file = "setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c"}, - {file = "setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95"}, - {file = "setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0"}, - {file = "setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4"}, - {file = "setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f"}, - {file = "setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9"}, - {file = "setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba"}, - {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307"}, - {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee"}, - {file = "setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1"}, - {file = "setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d"}, - {file = "setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4"}, - {file = "setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e"}, - {file = "setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798"}, - {file = "setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629"}, - {file = "setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1"}, - {file = "setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6"}, - {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c"}, - {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a"}, - {file = "setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739"}, - {file = "setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f"}, - {file = "setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300"}, - {file = "setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922"}, - {file = "setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee"}, - {file = "setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd"}, - {file = "setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0"}, - {file = "setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929"}, - {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f"}, - {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698"}, - {file = "setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c"}, - {file = "setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd"}, - {file = "setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f"}, - {file = "setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9"}, - {file = "setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5"}, - {file = "setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29"}, - {file = "setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152"}, - {file = "setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c"}, - {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b"}, - {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18"}, - {file = "setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c"}, - {file = "setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29"}, - {file = "setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9"}, - {file = "setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63"}, - {file = "setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e"}, - {file = "setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f"}, - {file = "setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5"}, - {file = "setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17"}, - {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e"}, - {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0"}, - {file = "setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8"}, - {file = "setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed"}, - {file = "setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416"}, - {file = "setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3"}, - {file = "setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309"}, - {file = "setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b"}, - {file = "setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45"}, - {file = "setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4"}, - {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1"}, - {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070"}, - {file = "setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73"}, - {file = "setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2"}, - {file = "setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a"}, - {file = "setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6"}, - {file = "setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2"}, - {file = "setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78"}, - {file = "setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1"}, - {file = "setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65"}, - {file = "setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a"}, - {file = "setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e"}, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -requires_python = ">=3.9" -summary = "Easily download, build, install, upgrade, and uninstall Python packages" -groups = ["dev-consumers"] -files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, -] - -[[package]] -name = "six" -version = "1.17.0" -requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -summary = "Python 2 and 3 compatibility utilities" -groups = ["cron", "dev-consumers", "integration-tests", "sqs"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "starlette" -version = "0.50.0" -requires_python = ">=3.10" -summary = "The little ASGI library that shines." -groups = ["examples"] -dependencies = [ - "anyio<5,>=3.6.2", - "typing-extensions>=4.10.0; python_version < \"3.13\"", -] -files = [ - {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, - {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, -] - -[[package]] -name = "structlog" -version = "25.5.0" -requires_python = ">=3.8" -summary = "Structured Logging for Python" -groups = ["dev"] -dependencies = [ - "typing-extensions; python_version < \"3.11\"", -] -files = [ - {file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"}, - {file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"}, -] - -[[package]] -name = "taskgroup" -version = "0.2.2" -summary = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout" -groups = ["dev-hosting-http"] -marker = "python_version < \"3.11\"" -dependencies = [ - "exceptiongroup", - "typing-extensions<5,>=4.12.2", -] -files = [ - {file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"}, - {file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"}, -] - -[[package]] -name = "testcontainers" -version = "4.14.0" -requires_python = ">=3.10" -summary = "Python library for throwaway instances of anything that can run in a Docker container" -groups = ["integration-tests"] -dependencies = [ - "docker", - "python-dotenv", - "typing-extensions", - "urllib3", - "wrapt", -] -files = [ - {file = "testcontainers-4.14.0-py3-none-any.whl", hash = "sha256:64e79b6b1e6d2b9b9e125539d35056caab4be739f7b7158c816d717f3596fa59"}, - {file = "testcontainers-4.14.0.tar.gz", hash = "sha256:3b2d4fa487af23024f00fcaa2d1cf4a5c6ad0c22e638a49799813cb49b3176c7"}, -] - -[[package]] -name = "testcontainers" -version = "4.14.0" -extras = ["google", "localstack", "nats"] -requires_python = ">=3.10" -summary = "Python library for throwaway instances of anything that can run in a Docker container" -groups = ["integration-tests"] -dependencies = [ - "boto3<2,>=1", - "google-cloud-datastore<3,>=2", - "google-cloud-pubsub<3,>=2", - "nats-py<3,>=2", - "testcontainers==4.14.0", -] -files = [ - {file = "testcontainers-4.14.0-py3-none-any.whl", hash = "sha256:64e79b6b1e6d2b9b9e125539d35056caab4be739f7b7158c816d717f3596fa59"}, - {file = "testcontainers-4.14.0.tar.gz", hash = "sha256:3b2d4fa487af23024f00fcaa2d1cf4a5c6ad0c22e638a49799813cb49b3176c7"}, -] - -[[package]] -name = "tomli" -version = "2.3.0" -requires_python = ">=3.8" -summary = "A lil' TOML parser" -groups = ["dev-hosting-http", "tests", "unit-tests"] -marker = "python_version < \"3.11\"" -files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, -] - -[[package]] -name = "types-aioboto3-lite" -version = "15.5.0" -requires_python = ">=3.8" -summary = "Lite type annotations for aioboto3 15.5.0 generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "botocore-stubs", - "types-aiobotocore-lite", - "types-s3transfer", - "typing-extensions>=4.1.0; python_version < \"3.12\"", -] -files = [ - {file = "types_aioboto3_lite-15.5.0-py3-none-any.whl", hash = "sha256:851430dca47508fbdaca86d421b48dea4dee97d1d085ca9483c2e87b783e6de7"}, - {file = "types_aioboto3_lite-15.5.0.tar.gz", hash = "sha256:a6a7bad0fb36b8e6a3e16767ddcb69637148216319312b5b1e5e537f998aee3c"}, -] - -[[package]] -name = "types-aioboto3-lite" -version = "15.5.0" -extras = ["sqs"] -requires_python = ">=3.8" -summary = "Lite type annotations for aioboto3 15.5.0 generated with mypy-boto3-builder 8.11.0" -groups = ["dev-types"] -dependencies = [ - "types-aioboto3-lite==15.5.0", - "types-aiobotocore-sqs<2.26.0,>=2.25.0", -] -files = [ - {file = "types_aioboto3_lite-15.5.0-py3-none-any.whl", hash = "sha256:851430dca47508fbdaca86d421b48dea4dee97d1d085ca9483c2e87b783e6de7"}, - {file = "types_aioboto3_lite-15.5.0.tar.gz", hash = "sha256:a6a7bad0fb36b8e6a3e16767ddcb69637148216319312b5b1e5e537f998aee3c"}, -] - -[[package]] -name = "types-aiobotocore-lite" -version = "2.25.2" -requires_python = ">=3.9" -summary = "Lite type annotations for aiobotocore 2.25.2 generated with mypy-boto3-builder 8.12.0" -groups = ["dev-types"] -dependencies = [ - "botocore-stubs", - "typing-extensions>=4.1.0; python_version < \"3.12\"", -] -files = [ - {file = "types_aiobotocore_lite-2.25.2-py3-none-any.whl", hash = "sha256:a4592006240dc2d338bfd4aca9a84235bd2636d115227ab274ec1b6e214fac77"}, - {file = "types_aiobotocore_lite-2.25.2.tar.gz", hash = "sha256:43596309690898f21c5a71f21da1d58af77180db9118c549b9226eba3aab8ecc"}, -] - -[[package]] -name = "types-aiobotocore-lite" -version = "2.25.2" -extras = ["sqs"] -requires_python = ">=3.9" -summary = "Lite type annotations for aiobotocore 2.25.2 generated with mypy-boto3-builder 8.12.0" -groups = ["dev-types"] -dependencies = [ - "types-aiobotocore-lite==2.25.2", - "types-aiobotocore-sqs<2.26.0,>=2.25.0", -] -files = [ - {file = "types_aiobotocore_lite-2.25.2-py3-none-any.whl", hash = "sha256:a4592006240dc2d338bfd4aca9a84235bd2636d115227ab274ec1b6e214fac77"}, - {file = "types_aiobotocore_lite-2.25.2.tar.gz", hash = "sha256:43596309690898f21c5a71f21da1d58af77180db9118c549b9226eba3aab8ecc"}, -] - -[[package]] -name = "types-aiobotocore-sqs" -version = "2.25.2" -requires_python = ">=3.9" -summary = "Type annotations for aiobotocore SQS 2.25.2 service generated with mypy-boto3-builder 8.12.0" -groups = ["dev-types"] -dependencies = [ - "typing-extensions; python_version < \"3.12\"", -] -files = [ - {file = "types_aiobotocore_sqs-2.25.2-py3-none-any.whl", hash = "sha256:04c5f20bc8da8f02d6db20d2369fedf8012596829a61aa40c68ff83d3970bcb3"}, - {file = "types_aiobotocore_sqs-2.25.2.tar.gz", hash = "sha256:60b38f4ede6d83cc6bd82db2b0721a983cd5010f1cfa78edaa8e5b380917b077"}, -] - -[[package]] -name = "types-awscrt" -version = "0.30.0" -requires_python = ">=3.8" -summary = "Type annotations and code completion for awscrt" -groups = ["dev-types"] -files = [ - {file = "types_awscrt-0.30.0-py3-none-any.whl", hash = "sha256:8204126e01a00eaa4a746e7a0076538ca0e4e3f52408adec0ab9b471bb0bb64b"}, - {file = "types_awscrt-0.30.0.tar.gz", hash = "sha256:362fd8f5eaebcfcd922cb9fd8274fb375df550319f78031ee3779eac0b9ecc79"}, -] - -[[package]] -name = "types-boto3-lite" -version = "1.42.24" -requires_python = ">=3.9" -summary = "Lite type annotations for boto3 1.42.24 generated with mypy-boto3-builder 8.12.0" -groups = ["dev-types"] -dependencies = [ - "botocore-stubs", - "types-s3transfer", - "typing-extensions>=4.1.0; python_version < \"3.12\"", -] -files = [ - {file = "types_boto3_lite-1.42.24-py3-none-any.whl", hash = "sha256:c31c4be0262ccf9ca91bc2a38aee357f3a4e9530bf125543bc002d185dc31135"}, - {file = "types_boto3_lite-1.42.24.tar.gz", hash = "sha256:be0e425bc02759949963eca86cb405f12922377052caa5dd01cbe5fd16dff9fd"}, -] - -[[package]] -name = "types-boto3-lite" -version = "1.42.24" -extras = ["sqs"] -requires_python = ">=3.9" -summary = "Lite type annotations for boto3 1.42.24 generated with mypy-boto3-builder 8.12.0" -groups = ["dev-types"] -dependencies = [ - "types-boto3-lite==1.42.24", - "types-boto3-sqs<1.43.0,>=1.42.0", -] -files = [ - {file = "types_boto3_lite-1.42.24-py3-none-any.whl", hash = "sha256:c31c4be0262ccf9ca91bc2a38aee357f3a4e9530bf125543bc002d185dc31135"}, - {file = "types_boto3_lite-1.42.24.tar.gz", hash = "sha256:be0e425bc02759949963eca86cb405f12922377052caa5dd01cbe5fd16dff9fd"}, -] - -[[package]] -name = "types-boto3-sqs" -version = "1.42.3" -requires_python = ">=3.9" -summary = "Type annotations for boto3 SQS 1.42.3 service generated with mypy-boto3-builder 8.12.0" -groups = ["dev-types"] -dependencies = [ - "typing-extensions; python_version < \"3.12\"", -] -files = [ - {file = "types_boto3_sqs-1.42.3-py3-none-any.whl", hash = "sha256:9290509e99f22464d39cba39feb8034b295ca312a84e43f8c7ad9b511c488e40"}, - {file = "types_boto3_sqs-1.42.3.tar.gz", hash = "sha256:b7df81d6f1cc94ac9d59ee8ddafb21b1c4e9c1140960156c55e19b1cdc3358e3"}, -] - -[[package]] -name = "types-croniter" -version = "6.0.0.20250809" -requires_python = ">=3.9" -summary = "Typing stubs for croniter" -groups = ["dev-types"] -files = [ - {file = "types_croniter-6.0.0.20250809-py3-none-any.whl", hash = "sha256:d9f53f3e837eb6af509e2090fd2f5bb29b38425dd78f77d7b3bf37ccd2b2bf93"}, - {file = "types_croniter-6.0.0.20250809.tar.gz", hash = "sha256:c829295d4d65eaddcfafec905b0fbab59e72c3c91ee934a4d504dcafad79ff95"}, -] - -[[package]] -name = "types-grpcio" -version = "1.0.0.20251009" -requires_python = ">=3.9" -summary = "Typing stubs for grpcio" -groups = ["dev-types"] -files = [ - {file = "types_grpcio-1.0.0.20251009-py3-none-any.whl", hash = "sha256:112ac4312a5b0a273a4c414f7f2c7668f342990d9c6ab0f647391c36331f95ed"}, - {file = "types_grpcio-1.0.0.20251009.tar.gz", hash = "sha256:a8f615ea7a47b31f10da028ab5258d4f1611fbd70719ca450fc0ab3fb9c62b63"}, -] - -[[package]] -name = "types-protobuf" -version = "6.32.1.20251210" -requires_python = ">=3.9" -summary = "Typing stubs for protobuf" -groups = ["dev-types"] -files = [ - {file = "types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140"}, - {file = "types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763"}, -] - -[[package]] -name = "types-s3transfer" -version = "0.16.0" -requires_python = ">=3.9" -summary = "Type annotations and code completion for s3transfer" -groups = ["dev-types"] -files = [ - {file = "types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef"}, - {file = "types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -requires_python = ">=3.9" -summary = "Backported and Experimental Type Hints for Python 3.9+" -groups = ["default", "azure-queue", "azure-servicebus", "dev", "dev-consumers", "dev-hosting-grpc", "dev-hosting-http", "dev-otel", "dev-types", "examples", "integration-tests", "pubsub", "rabbitmq", "tests", "unit-tests"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -requires_python = ">=3.9" -summary = "Runtime typing introspection tools" -groups = ["examples"] -dependencies = [ - "typing-extensions>=4.12.0", -] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[[package]] -name = "tzdata" -version = "2025.3" -requires_python = ">=2" -summary = "Provider of IANA time zone data" -groups = ["examples"] -marker = "sys_platform == \"win32\"" -files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -requires_python = ">=3.9" -summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["azure-queue", "azure-servicebus", "dev-consumers", "dev-otel", "integration-tests", "pubsub", "sqs"] -files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, -] - -[[package]] -name = "uvicorn" -version = "0.40.0" -requires_python = ">=3.10" -summary = "The lightning-fast ASGI server." -groups = ["dev-hosting-http"] -dependencies = [ - "click>=7.0", - "h11>=0.8", - "typing-extensions>=4.0; python_version < \"3.11\"", -] -files = [ - {file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"}, - {file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"}, -] - -[[package]] -name = "wrapt" -version = "1.17.3" -requires_python = ">=3.8" -summary = "Module for decorators, wrappers and monkey patching." -groups = ["dev-consumers", "dev-otel", "integration-tests"] -files = [ - {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, - {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, - {file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"}, - {file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"}, - {file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"}, - {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"}, - {file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"}, - {file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"}, - {file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"}, - {file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"}, - {file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"}, - {file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"}, - {file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"}, - {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"}, - {file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"}, - {file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"}, - {file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"}, - {file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"}, - {file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"}, - {file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"}, - {file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"}, - {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"}, - {file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"}, - {file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"}, - {file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"}, - {file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"}, - {file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"}, - {file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"}, - {file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"}, - {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"}, - {file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"}, - {file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"}, - {file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"}, - {file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"}, - {file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"}, - {file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"}, - {file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"}, - {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"}, - {file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"}, - {file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"}, - {file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"}, - {file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"}, - {file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"}, - {file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"}, - {file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"}, - {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"}, - {file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"}, - {file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"}, - {file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"}, - {file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"}, - {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, - {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, -] - -[[package]] -name = "wsproto" -version = "1.3.2" -requires_python = ">=3.10" -summary = "Pure-Python WebSocket protocol implementation" -groups = ["dev-hosting-http"] -dependencies = [ - "h11<1,>=0.16.0", -] -files = [ - {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, - {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, -] - -[[package]] -name = "yarl" -version = "1.22.0" -requires_python = ">=3.9" -summary = "Yet another URL library" -groups = ["dev-consumers", "rabbitmq"] -dependencies = [ - "idna>=2.0", - "multidict>=4.0", - "propcache>=0.2.1", -] -files = [ - {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, - {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, - {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, - {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, - {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, - {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, - {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, - {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, - {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, - {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, - {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, - {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, - {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, - {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, - {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, - {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, - {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, - {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, - {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, - {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, - {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, - {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, - {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, - {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, - {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, - {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, - {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, - {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, - {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, - {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, - {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, - {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, - {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, - {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, - {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, - {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, - {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, - {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, - {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, - {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, - {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, - {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, - {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, - {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, - {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, - {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, - {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, - {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, - {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, - {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, - {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, - {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, - {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, - {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, - {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, - {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, - {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, - {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, -] - -[[package]] -name = "zipp" -version = "3.23.0" -requires_python = ">=3.9" -summary = "Backport of pathlib-compatible object wrapper for zip files" -groups = ["dev-otel", "integration-tests", "pubsub"] -files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, -] diff --git a/pyproject.toml b/pyproject.toml index 7195d6d..25481c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["pdm-backend", "pdm-build-locked"] -build-backend = "pdm.backend" +requires = ["uv_build>=0.9.10,<0.10.0"] +build-backend = "uv_build" # See also https://daniel.feldroy.com/posts/2023-08-pypi-project-urls-cheatsheet [project.urls] @@ -10,9 +10,9 @@ Docs = 'https://alexeyshockov.github.io/localpost.py/' [project] name = "localpost" +version = "0.6.0.dev0" description = "Consumers framework for different message brokers & simple in-process task scheduler" -requires-python = ">=3.10" -dynamic = ["version"] +requires-python = ">=3.12" readme = "pypi_readme.md" license = "MIT" # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#pep-639-license-declaration authors = [ @@ -24,10 +24,10 @@ keywords = [ ] classifiers = [ "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", "Framework :: AsyncIO", "Framework :: AnyIO", @@ -43,14 +43,14 @@ classifiers = [ ] dependencies = [ "typing_extensions ~=4.10", - "anyio >=3.6,<5.0", + "anyio ~=4.12", ] [project.optional-dependencies] cron = [ "croniter >=2.0,<4.0", ] -scheduler = [ # TODO Better name +scheduler = [ # Better name?.. # "localpost[cron]", "humanize >=3.0,<5.0", "pytimeparse2 ~=1.6", @@ -58,8 +58,6 @@ scheduler = [ # TODO Better name sqs = [ "botocore ~=1.38", # Sync SQS client (default) # "boto3 ~=1.38", # In case boto3 is available, the default session will be used -# "aiobotocore", # Async SQS client -# "aioboto3 >=14.1", # In case aioboto3 is available, the default session will be used ] kafka = [ "confluent-kafka ~=2.4", @@ -67,10 +65,10 @@ kafka = [ nats = [ "nats-py ~=2.8", ] -rabbitmq = [ - "aio-pika ~=9.1", +#rabbitmq = [ +# "aio-pika ~=9.1", # "pika ~=1.3", -] +#] pubsub = [ "google-cloud-pubsub ~=2.28", ] @@ -83,8 +81,6 @@ azure-servicebus = [ "azure-servicebus ~=7.10", ] -# Yay, https://peps.python.org/pep-0735/ is finally here! -#[tool.pdm.dev-dependencies] [dependency-groups] dev = [ "icecream ~=2.1", @@ -100,16 +96,19 @@ dev-hosting-grpc = [ dev-consumers = [ "boto3 ~=1.38", "aws-lambda-powertools ~=3.10", - "aiobotocore ~=2.21", - "aioboto3 >=14.1", +# "aiobotocore ~=2.21", +# "aioboto3 >=14.1", "confluent-kafka[schemaregistry,protobuf]", "grpcio-tools ~=1.68", ] +dev-sentry = [ + "sentry-sdk ~=2.51", +] dev-otel = [ "opentelemetry-exporter-otlp", # Both gRPC and HTTP - "opentelemetry-instrumentation-confluent-kafka >=0.50b0", -# "opentelemetry-instrumentation-aiokafka >=0.50b0", - "opentelemetry-instrumentation-botocore >=0.50b0", + "opentelemetry-instrumentation-confluent-kafka >=0.60b1", +# "opentelemetry-instrumentation-aiokafka >=0.60b1", + "opentelemetry-instrumentation-botocore >=0.60b1", "protobuf ~=6.29", # See https://github.com/open-telemetry/opentelemetry-python/issues/4639#issuecomment-2997003823 ] dev-types = [ @@ -118,23 +117,20 @@ dev-types = [ "types-protobuf ~=6.29", # Must be in sync with protobuf version, see above "types-grpcio", # Replaces grpc-stubs, see: https://github.com/python/typeshed/pull/11204 "types-boto3-lite[sqs] ~=1.38", - "types-aiobotocore-lite[sqs] ~=2.21", - "types-aioboto3-lite[sqs] >=14.1", ] examples = [ - "fast-depends ~=2.4", - "fastapi-slim ~=0.111", + "fast-depends ~=3.0", + "fastapi-slim ~=0.128", "psycopg[binary,pool] ~=3.2", ] tests = [ - "pytest ~=8.1", - "anyio[test]", + "pytest ~=9.0", "pytest-xdist", "setproctitle", # For pytest-xdist, to have descriptive process names ] unit-tests = [ "pytest-mock ~=3.14", - "pytest-cov ~=6.0", + "pytest-cov ~=7.0", "coverage[toml] ~=7.6", ] integration-tests = [ @@ -226,19 +222,12 @@ ignore = [ "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` ] -[tool.pdm] -distribution = true - -[tool.pdm.version] -source = "scm" -write_to = "localpost/__meta__.py" -write_template = "version = '{}'" - -[tool.pdm.build] -excludes = ["./**/.git", "tests", "examples"] +[tool.uv] +build-backend.module-root = "" +build-backend.module-name = "localpost" [tool.pytest.ini_options] -minversion = "8.0" +anyio_mode = "auto" #addopts = "-q -m 'not integration'" testpaths = [ "tests", diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..21b6cf1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2058 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.6" +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" } +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" }, +] + +[[package]] +name = "aws-lambda-powertools" +version = "3.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/33/d8666a4fc8bae7c783e3dafa9bd4ca463080a8ef83d264a2379662e1313f/aws_lambda_powertools-3.24.0.tar.gz", hash = "sha256:9f86959c4aeac9669da799999aae5feac7a3a86e642b52473892eaa4273d3cc3", size = 704516, upload-time = "2026-01-05T12:30:38.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/11/0602c8c31fc48e77ed370279ccef1a258dedcbfad1a9dba8e39b7c5df367/aws_lambda_powertools-3.24.0-py3-none-any.whl", hash = "sha256:9c9002856f61b86f49271a9d7efa0dad322ecd22719ddc1c6bb373e57ee0421a", size = 849835, upload-time = "2026-01-05T12:30:36.962Z" }, +] + +[[package]] +name = "azure-core" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, +] + +[[package]] +name = "azure-servicebus" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/d2/a5c11d4c955e2875de2383c1af8e43ce4899e8418b22e61d32d2bf103626/azure_servicebus-7.14.3.tar.gz", hash = "sha256:70a63384557aec0bee727740e7b25ded29e9e701b77611764577fd7402389402", size = 534786, upload-time = "2025-10-31T05:30:03.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/e9/d9fd0b2bef14d85b408c51802142b1c8b7bc3ab08514c89432547b1d87d3/azure_servicebus-7.14.3-py3-none-any.whl", hash = "sha256:386f8d32dae8881661ec8d791c38978eca2bbf7ea9f489d6cff8ad9cc6990234", size = 412522, upload-time = "2025-10-31T05:30:05.252Z" }, +] + +[[package]] +name = "azure-storage-queue" +version = "12.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/23/e3b46de244a133675c8c20f3ef2be6cbaf22a41f03e04e1cb2acd609bf5f/azure_storage_queue-12.15.0.tar.gz", hash = "sha256:4e01dcae5aefd0c463f7bae5c75c8a91f955c893f14ed7590fc0cd447ac4666d", size = 197521, upload-time = "2026-01-07T00:18:03.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/22/5da115105c9fe7e2fc11804018649b394f60a62735e19642acf336e3807a/azure_storage_queue-12.15.0-py3-none-any.whl", hash = "sha256:056cfce0cd60458f0b7653d804f639098b14593f843899c6c0fc65b3ebe61210", size = 187547, upload-time = "2026-01-07T00:18:05.23Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/b0/f2a371146877b4d60cd2a6f51c440503cbf77a73c85347ad282eebe90269/boto3-1.42.41.tar.gz", hash = "sha256:86dd2bc577e33da5cd9f10e21b22cdda01a24f83f31ca1bb5ac1f5f8b8e9e67e", size = 112806, upload-time = "2026-02-03T20:38:58.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/e8/e671b9aed3e8f6bb5f85aed7fd9dd4e79074798b2a14391fab6b6da04ff7/boto3-1.42.41-py3-none-any.whl", hash = "sha256:32470b1d32208e03b47cf6ce4a7adb337b8a1730aaefb97c336cfd4e2be2577f", size = 140602, upload-time = "2026-02-03T20:38:56.352Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/1d/5fd43319250a5a484bcbde334da2e2297956cdae40de8eb0808759538a7c/botocore-1.42.41.tar.gz", hash = "sha256:0698967741d873d819134bea1ffe9c35decc00c741f49a42885dbd7ad8198fbc", size = 14923189, upload-time = "2026-02-03T20:38:47.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/84/df765c7942f52eab12540ef1880b9489d45b364b777fad522fa84435ed11/botocore-1.42.41-py3-none-any.whl", hash = "sha256:567530f7d4da668af891c1259fb80c01578ab8d9f1098127cc250451a46fb54f", size = 14597792, upload-time = "2026-02-03T20:38:45.058Z" }, +] + +[[package]] +name = "botocore-stubs" +version = "1.42.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/a8/a26608ff39e3a5866c6c79eda10133490205cbddd45074190becece3ff2a/botocore_stubs-1.42.41.tar.gz", hash = "sha256:dbeac2f744df6b814ce83ec3f3777b299a015cbea57a2efc41c33b8c38265825", size = 42411, upload-time = "2026-02-03T20:46:14.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/76/cab7af7f16c0b09347f2ebe7ffda7101132f786acb767666dce43055faab/botocore_stubs-1.42.41-py3-none-any.whl", hash = "sha256:9423110fb0e391834bd2ed44ae5f879d8cb370a444703d966d30842ce2bcb5f0", size = 66759, upload-time = "2026-02-03T20:46:13.02Z" }, +] + +[[package]] +name = "cachetools" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796, upload-time = "2026-02-01T18:59:47.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487, upload-time = "2026-02-01T18:59:45.981Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "confluent-kafka" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/d0/1f5055331fa660225de6829b143e6f083913f0a96481134a91390bad62c1/confluent_kafka-2.13.0.tar.gz", hash = "sha256:eff7a4391a9e6d4a33f0c05d0935b200a7463834f1f5d6e6253be318f910babd", size = 273621, upload-time = "2026-01-05T10:25:08.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/cc/6bf9e5b3ee4bfdb39d3fcc7efabc9f577aa51e9e20139adc8d10e61593a5/confluent_kafka-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a69263f22a8c53c7d55067e7795ed49d22c374b9473df91982816a0448e6d242", size = 3631367, upload-time = "2026-01-05T10:23:48.076Z" }, + { url = "https://files.pythonhosted.org/packages/10/3d/c299df69885be6fdfc8b105b8106b2b4c73c745fa608cf579eb7e14a3da9/confluent_kafka-2.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0945c7f529e66a18aa19135aa18bfdc239ca6c0f6df6ca9b05a793d9b76c1c4b", size = 3191073, upload-time = "2026-01-05T10:23:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/82/2e/854aedcd9c9042491c1fcafaadc03a3b0e357ddc23044781d02920b46ea2/confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:321037a64c02acb13b5bde193b461c0514dca236a9f5236c847a3240313c297f", size = 3720530, upload-time = "2026-01-05T10:23:51.957Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4a/210f0e1f8e77956ed1296c8b9bac2093413c1669ea0e923f590f2827aa1a/confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d7a71ca8fd42d3eefa22eb202e7fc8a419e0fd4e3b59862918c21f4d1074f0c5", size = 3977296, upload-time = "2026-01-05T10:23:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bc/d51f48200bb7bec521289c0b70691ea73f91aa76529b1bff807bcbf89b12/confluent_kafka-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:37dddb1b92829b8862bc4fbce07789a79b73aa31eca413f3db187721a09975ff", size = 4093802, upload-time = "2026-01-05T10:23:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/77/bd/c2ab440b4c4847a37b21e9623bbeaf17982ca45595ad62b8435a26d680f7/confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0af90b3c566786017a01693da0ec4a876ca14cf37bc6164872652a6cf2702453", size = 3195849, upload-time = "2026-01-05T10:24:00.854Z" }, + { url = "https://files.pythonhosted.org/packages/6a/40/a25a5895cf522bada81fc7ba6c0ad29219843206ede4e8d2138b7b095652/confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f25d05604dd92e9de72707582dded53aeb4737ef2e2c097a3ca08650200fc446", size = 3634806, upload-time = "2026-01-05T10:24:05.496Z" }, + { url = "https://files.pythonhosted.org/packages/fd/46/db30d27184ac8fb673ca469d576ef582def0b613a69456631734f7a5e267/confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:74ddf5ec7fa6058221a619c850f44bdbe8d969d7ed6efe8abdc857d2e233df20", size = 3720848, upload-time = "2026-01-05T10:24:07.831Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f8/5b940a080ab71fc3c585839eae0c26341a749a7ebc001fca0276aff9df40/confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f8d1d00397f3f32a1bcf4604d4164bf75838bd009e1e28282a7ae25e16814ea4", size = 3977677, upload-time = "2026-01-05T10:24:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cf/f9f979c08cfe1b8fd9203b329fc5c8c410e948f01272434bc1ce0c6334a5/confluent_kafka-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1fd035e2c47c4db5fe9b0f59a28fe2f2f1012887290dd0ea7d46741f686e99", size = 4153481, upload-time = "2026-01-05T10:24:12.167Z" }, + { url = "https://files.pythonhosted.org/packages/06/3f/c2120c002f85c5401d8ea29558acf041ad968b33cfca83916a7c0a740d53/confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d448537147a33dd8c17656732989ddfe1d4a25a40bcb5f59bc63dc0a5041dd83", size = 3195696, upload-time = "2026-01-05T10:24:14.422Z" }, + { url = "https://files.pythonhosted.org/packages/35/b9/da4ef8fca4cbc76b6040a97a92085c09c0f23c42424989a2d68cec42c8d6/confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:1325585f9fc283c32c30df4226178dc89cf43d00f5c240e1f77ddedc94573690", size = 3634632, upload-time = "2026-01-05T10:24:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/09/ed/c7d5cc3c57aec126ffb12ffa5f4584acd264446a4117db3ad2dd69e47afe/confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:266bbea18ce99f6e77ce0e9a118f353447c8705792ef5745eabcc5c6db08794a", size = 3720650, upload-time = "2026-01-05T10:24:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2b/0a93b63a46b2ceaac3de92d494e32aab21bec398b40ac89108bbaa6894c3/confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7a373a1a3dd8e02dd218946583e951791480921fd777faeaf601c2834e2a6c0d", size = 3977364, upload-time = "2026-01-05T10:24:23.677Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/80ea6872421c4e30e033e035e5bdcf44c054993d5721623841c59b5f04c1/confluent_kafka-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:da956b2141d9f425dbfc3cf1c244ef6d0633b83fc5ceada6f496099258e63a68", size = 4271874, upload-time = "2026-01-05T10:24:26.265Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/e6fe016bea63343010f485a1ecebfdc00d9b6e95ef6fe52a1d7793c0339c/confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:fa354e9fb95e26545decd429477072ea3a98a2e7acac11d09156772e06f14680", size = 3194480, upload-time = "2026-01-05T10:24:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/fb/54/c9668f214f2e736e51e2874053fe1ebcb8784425fefcfd6fd0d384dc5dcf/confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:e256dc3a993bf5fde0fa4a1b6a5b72e3521cedbc9dc4d7a65f159afa4b72e5b4", size = 3632866, upload-time = "2026-01-05T10:24:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/83e989962077003d91ba249158c7a1e21696604c301b3680fcbb427005d0/confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eb7988038b7f13ea490af93b9165ed40a3f385fb91e99ce8dacee8890e36e48a", size = 3718956, upload-time = "2026-01-05T10:24:33.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/84/1aaa5cfe1695f02d11e02ef39f180567780348b1f3e1161575f002a207fe/confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cfd8f011b0b0a109f8747312dba6cee45b39fb53fc7d53f28813bce84a91228d", size = 3976235, upload-time = "2026-01-05T10:24:35.122Z" }, +] + +[package.optional-dependencies] +protobuf = [ + { name = "attrs" }, + { name = "authlib" }, + { name = "cachetools" }, + { name = "certifi" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "protobuf" }, +] +schemaregistry = [ + { name = "attrs" }, + { name = "authlib" }, + { name = "cachetools" }, + { name = "certifi" }, + { name = "httpx" }, +] + +[[package]] +name = "coverage" +version = "7.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +] + +[[package]] +name = "croniter" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ea/98665cd116af6d3c4e79c8dc91bbd9a13746cb3c7d72efbfdef5b720c43b/croniter-3.0.4.tar.gz", hash = "sha256:f9dcd4bdb6c97abedb6f09d6ed3495b13ede4d4544503fa580b6372a56a0c520", size = 54500, upload-time = "2024-10-25T12:22:33.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f5/135d0e57e5bdd2f978388d77ee818f1ac5ac584eb48034362770001f4cad/croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0", size = 23220, upload-time = "2024-10-25T12:22:30.75Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fast-depends" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/f3/41e955f5f0811de6ef9f00f8462f2ade7bc4a99b93714c9b134646baa831/fast_depends-3.0.5.tar.gz", hash = "sha256:c915a54d6e0d0f0393686d37c14d54d9ec7c43d7b9def3f3fc4f7b4d52f67f2a", size = 18235, upload-time = "2025-11-30T20:26:12.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/dd/76697228ae63dcbaf0a0a1b20fc996433a33f184ac4f578382b681dcf5ea/fast_depends-3.0.5-py3-none-any.whl", hash = "sha256:38a3d7044d3d6d0b1bed703691275c870316426e8a9bfa6b1c89e979b15659e2", size = 25362, upload-time = "2025-11-30T20:26:10.96Z" }, +] + +[[package]] +name = "fastapi-slim" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/6b/2310ece5695e7025d32dcb2a4d31b91b6d52b97e4ddc8aa69a6e1bb45a26/fastapi_slim-0.128.0.tar.gz", hash = "sha256:7916e28ec3bd897ea4adff9e8d257ff6edba0c1f383640896d54099f78a8afef", size = 365903, upload-time = "2025-12-27T15:21:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/af/8b646358f86d9606552613f1b17f78d6f53dae6d69af8af952b0138f09da/fastapi_slim-0.128.0-py3-none-any.whl", hash = "sha256:19034e3e48503573fe429d94906d4b5180aa7c754f9148a55ab1981abdb73293", size = 103149, upload-time = "2025-12-27T15:21:14.184Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, +] + +[[package]] +name = "google-cloud-datastore" +version = "2.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/4b/0e27a94a9a2be1b2ba53cbe571314c84a2555e5d9295c5d63bf43ede5483/google_cloud_datastore-2.23.0.tar.gz", hash = "sha256:80049883a4ae928fdcc661ba6803ec267665dc0e6f3ce2da91441079a6bb6387", size = 264913, upload-time = "2025-12-16T22:09:33.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/88/348c09570a03886356c02337f06d69532fa17a66ad2a9dff584f7b60eb04/google_cloud_datastore-2.23.0-py3-none-any.whl", hash = "sha256:24a1b1d29b902148fe41b109699f76fd3aa60591e9d547c0f8b87d7bf9ff213f", size = 206815, upload-time = "2025-12-16T22:09:31.608Z" }, +] + +[[package]] +name = "google-cloud-pubsub" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "grpcio-status" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { 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" } +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" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] + +[[package]] +name = "grpcio" +version = "1.76.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/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" }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.76.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" } +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" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/77/17d60d636ccd86a0db0eccc24d02967bbc3eea86b9db7324b04507ebaa40/grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88", size = 5390807, upload-time = "2025-10-21T16:26:55.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/ca/a931c1439cabfe305c9afd07e233150cd0565aa062c20d1ee412ed188852/grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a", size = 2546852, upload-time = "2025-10-21T16:25:15.024Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/935cfbb7dccd602723482a86d43fbd992f91e9867bca0056a1e9f348473e/grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1", size = 5841777, upload-time = "2025-10-21T16:25:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/8fcb5acebdccb647e0fa3f002576480459f6cf81e79692d7b3c4d6e29605/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b", size = 2594004, upload-time = "2025-10-21T16:25:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ea/64838e8113b7bfd4842b15c815a7354cb63242fdce9d6648d894b5d50897/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3dafa34c2626a6691d103877e8a145f54c34cf6530975f695b396ed2fc5c98f8", size = 2905563, upload-time = "2025-10-21T16:25:21.889Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d6/53798827d821098219e58518b6db52161ce4985620850aa74ce3795da8a7/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30f1d2dda6ece285b3d9084e94f66fa721ebdba14ae76b2bc4c581c8a166535c", size = 2656936, upload-time = "2025-10-21T16:25:24.369Z" }, + { url = "https://files.pythonhosted.org/packages/89/a3/d9c1cefc46a790eec520fe4e70e87279abb01a58b1a3b74cf93f62b824a2/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a889af059dc6dbb82d7b417aa581601316e364fe12eb54c1b8d95311ea50916d", size = 3109811, upload-time = "2025-10-21T16:25:26.711Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/5997752644b73b5d59377d333a51c8a916606df077f5a487853e37dca289/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3f2c3c44c56eb5d479ab178f0174595d0a974c37dade442f05bb73dfec02f31", size = 3658786, upload-time = "2025-10-21T16:25:28.819Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/dcf8380df4bd7931ffba32fc6adc2de635b6569ca27fdec7121733797062/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:479ce02dff684046f909a487d452a83a96b4231f7c70a3b218a075d54e951f56", size = 3325144, upload-time = "2025-10-21T16:25:30.863Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/ea3e5fdb874d8c2d04488e4b9d05056537fba70915593f0c283ac77df188/grpcio_tools-1.76.0-cp312-cp312-win32.whl", hash = "sha256:9ba4bb539936642a44418b38ee6c3e8823c037699e2cb282bd8a44d76a4be833", size = 993523, upload-time = "2025-10-21T16:25:32.594Z" }, + { url = "https://files.pythonhosted.org/packages/de/b1/ce7d59d147675ec191a55816be46bc47a343b5ff07279eef5817c09cc53e/grpcio_tools-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd489016766b05f9ed8a6b6596004b62c57d323f49593eac84add032a6d43f7", size = 1158493, upload-time = "2025-10-21T16:25:34.5Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b16fe73f129df49811d886dc99d3813a33cf4d1c6e101252b81c895e929f/grpcio_tools-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ff48969f81858397ef33a36b326f2dbe2053a48b254593785707845db73c8f44", size = 2546312, upload-time = "2025-10-21T16:25:37.138Z" }, + { url = "https://files.pythonhosted.org/packages/25/17/2594c5feb76bb0b25bfbf91ec1075b276e1b2325e4bc7ea649a7b5dbf353/grpcio_tools-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa2f030fd0ef17926026ee8e2b700e388d3439155d145c568fa6b32693277613", size = 5839627, upload-time = "2025-10-21T16:25:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c6/097b1aa26fbf72fb3cdb30138a2788529e4f10d8759de730a83f5c06726e/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bacbf3c54f88c38de8e28f8d9b97c90b76b105fb9ddef05d2c50df01b32b92af", size = 2592817, upload-time = "2025-10-21T16:25:42.301Z" }, + { url = "https://files.pythonhosted.org/packages/03/78/d1d985b48592a674509a85438c1a3d4c36304ddfc99d1b05d27233b51062/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d4e4afe9a0e3c24fad2f1af45f98cf8700b2bfc4d790795756ba035d2ea7bdc", size = 2905186, upload-time = "2025-10-21T16:25:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/770afbb47f0b5f594b93a7b46a95b892abda5eebe60efb511e96cee52170/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbbd4e1fc5af98001ceef5e780e8c10921d94941c3809238081e73818ef707f1", size = 2656188, upload-time = "2025-10-21T16:25:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2b/017c2fcf4c5d3cf00cf7d5ce21eb88521de0d89bdcf26538ad2862ec6d07/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b05efe5a59883ab8292d596657273a60e0c3e4f5a9723c32feb9fc3a06f2f3ef", size = 3109141, upload-time = "2025-10-21T16:25:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/2495f88e3d50c6f2c2da2752bad4fa3a30c52ece6c9d8b0c636cd8b1430b/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:be483b90e62b7892eb71fa1fc49750bee5b2ee35b5ec99dd2b32bed4bedb5d71", size = 3657892, upload-time = "2025-10-21T16:25:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1d/c4f39d31b19d9baf35d900bf3f969ce1c842f63a8560c8003ed2e5474760/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:630cd7fd3e8a63e20703a7ad816979073c2253e591b5422583c27cae2570de73", size = 3324778, upload-time = "2025-10-21T16:25:54.629Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b6/35ee3a6e4af85a93da28428f81f4b29bcb36f6986b486ad71910fcc02e25/grpcio_tools-1.76.0-cp313-cp313-win32.whl", hash = "sha256:eb2567280f9f6da5444043f0e84d8408c7a10df9ba3201026b30e40ef3814736", size = 993084, upload-time = "2025-10-21T16:25:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7a/5bd72344d86ee860e5920c9a7553cfe3bc7b1fce79f18c00ac2497f5799f/grpcio_tools-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:0071b1c0bd0f5f9d292dca4efab32c92725d418e57f9c60acdc33c0172af8b53", size = 1158151, upload-time = "2025-10-21T16:25:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c0/aa20eebe8f3553b7851643e9c88d237c3a6ca30ade646897e25dbb27be99/grpcio_tools-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:c53c5719ef2a435997755abde3826ba4087174bd432aa721d8fac781fcea79e4", size = 2546297, upload-time = "2025-10-21T16:26:01.258Z" }, + { url = "https://files.pythonhosted.org/packages/d9/98/6af702804934443c1d0d4d27d21b990d92d22ddd1b6bec6b056558cbbffa/grpcio_tools-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e3db1300d7282264639eeee7243f5de7e6a7c0283f8bf05d66c0315b7b0f0b36", size = 5839804, upload-time = "2025-10-21T16:26:05.495Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8d/7725fa7b134ef8405ffe0a37c96eeb626e5af15d70e1bdac4f8f1abf842e/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b018a4b7455a7e8c16d0fdb3655a6ba6c9536da6de6c5d4f11b6bb73378165b", size = 2593922, upload-time = "2025-10-21T16:26:07.563Z" }, + { url = "https://files.pythonhosted.org/packages/de/ff/5b6b5012c79fa72f9107dc13f7226d9ce7e059ea639fd8c779e0dd284386/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ec6e4de3866e47cfde56607b1fae83ecc5aa546e06dec53de11f88063f4b5275", size = 2905327, upload-time = "2025-10-21T16:26:09.668Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/2691d369ea462cd6b6c92544122885ca01f7fa5ac75dee023e975e675858/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8da4d828883913f1852bdd67383713ae5c11842f6c70f93f31893eab530aead", size = 2656214, upload-time = "2025-10-21T16:26:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e7/3f8856e6ec3dd492336a91572993344966f237b0e3819fbe96437b19d313/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c120c2cf4443121800e7f9bcfe2e94519fa25f3bb0b9882359dd3b252c78a7b", size = 3109889, upload-time = "2025-10-21T16:26:15.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ce5248072e47db276dc7e069e93978dcde490c959788ce7cce8081d0bfdc/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8b7df5591d699cd9076065f1f15049e9c3597e0771bea51c8c97790caf5e4197", size = 3657939, upload-time = "2025-10-21T16:26:17.34Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/81ff88af93c52135e425cd5ec9fe8b186169c7d5f9e0409bdf2bbedc3919/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a25048c5f984d33e3f5b6ad7618e98736542461213ade1bd6f2fcfe8ce804e3d", size = 3324752, upload-time = "2025-10-21T16:26:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/f6b83044afbf6522254a3b509515a00fed16a819c87731a478dbdd1d35c1/grpcio_tools-1.76.0-cp314-cp314-win32.whl", hash = "sha256:4b77ce6b6c17869858cfe14681ad09ed3a8a80e960e96035de1fd87f78158740", size = 1015578, upload-time = "2025-10-21T16:26:22.517Z" }, + { url = "https://files.pythonhosted.org/packages/95/4d/31236cddb7ffb09ba4a49f4f56d2608fec3bbb21c7a0a975d93bca7cd22e/grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc", size = 1190242, upload-time = "2025-10-21T16:26:25.296Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "icecream" +version = "2.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "colorama" }, + { name = "executing" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/e2/3d064dfedbbc16687e0f56cd9b1d55e4e6dfd13d61b9435b61c250aaef3c/icecream-2.1.10.tar.gz", hash = "sha256:15900126ba7dbe1f83819583cbe5ff79a2943224600878d89307e4633b32e528", size = 13924, upload-time = "2026-01-21T07:34:17.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/4f/91c4ee1af60bb4b2519540fd46fb56dfc70ede0cf3d97f57aff62d61190b/icecream-2.1.10-py3-none-any.whl", hash = "sha256:6b0ae3e899de12954cd26d8611dcff86518ff19f40deef333427da2ccf4036b2", size = 16373, upload-time = "2026-01-21T07:34:15.801Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "localpost" +version = "0.6.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +azure-queue = [ + { name = "azure-identity" }, + { name = "azure-storage-queue" }, +] +azure-servicebus = [ + { name = "azure-identity" }, + { name = "azure-servicebus" }, +] +cron = [ + { name = "croniter" }, +] +kafka = [ + { name = "confluent-kafka" }, +] +nats = [ + { name = "nats-py" }, +] +pubsub = [ + { name = "google-cloud-pubsub" }, +] +scheduler = [ + { name = "humanize" }, + { name = "pytimeparse2" }, +] +sqs = [ + { name = "botocore" }, +] + +[package.dev-dependencies] +dev = [ + { name = "icecream" }, + { name = "structlog" }, +] +dev-consumers = [ + { name = "aws-lambda-powertools" }, + { name = "boto3" }, + { name = "confluent-kafka", extra = ["protobuf", "schemaregistry"] }, + { name = "grpcio-tools" }, +] +dev-hosting-grpc = [ + { name = "grpcio" }, +] +dev-hosting-http = [ + { name = "hypercorn" }, + { name = "uvicorn" }, +] +dev-otel = [ + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-instrumentation-botocore" }, + { name = "opentelemetry-instrumentation-confluent-kafka" }, + { name = "protobuf" }, +] +dev-sentry = [ + { name = "sentry-sdk" }, +] +dev-types = [ + { name = "types-boto3-lite", extra = ["sqs"] }, + { name = "types-croniter" }, + { name = "types-grpcio" }, + { name = "types-protobuf" }, +] +examples = [ + { name = "fast-depends" }, + { name = "fastapi-slim" }, + { name = "psycopg", extra = ["binary", "pool"] }, +] +integration-tests = [ + { name = "boto3" }, + { name = "testcontainers", extra = ["google", "localstack", "nats"] }, +] +tests = [ + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "setproctitle" }, +] +unit-tests = [ + { name = "coverage" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = "~=4.12" }, + { name = "azure-identity", marker = "extra == 'azure-queue'" }, + { name = "azure-identity", marker = "extra == 'azure-servicebus'" }, + { name = "azure-servicebus", marker = "extra == 'azure-servicebus'", specifier = "~=7.10" }, + { name = "azure-storage-queue", marker = "extra == 'azure-queue'", specifier = "~=12.8" }, + { name = "botocore", marker = "extra == 'sqs'", specifier = "~=1.38" }, + { name = "confluent-kafka", marker = "extra == 'kafka'", specifier = "~=2.4" }, + { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, + { name = "google-cloud-pubsub", marker = "extra == 'pubsub'", specifier = "~=2.28" }, + { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, + { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, + { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, + { name = "typing-extensions", specifier = "~=4.10" }, +] +provides-extras = ["cron", "scheduler", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] + +[package.metadata.requires-dev] +dev = [ + { name = "icecream", specifier = "~=2.1" }, + { name = "structlog", specifier = "~=25.0" }, +] +dev-consumers = [ + { name = "aws-lambda-powertools", specifier = "~=3.10" }, + { name = "boto3", specifier = "~=1.38" }, + { name = "confluent-kafka", extras = ["schemaregistry", "protobuf"] }, + { name = "grpcio-tools", specifier = "~=1.68" }, +] +dev-hosting-grpc = [{ name = "grpcio", specifier = "~=1.68" }] +dev-hosting-http = [ + { name = "hypercorn", specifier = "~=0.17" }, + { name = "uvicorn", specifier = "~=0.30" }, +] +dev-otel = [ + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-instrumentation-botocore", specifier = ">=0.60b1" }, + { name = "opentelemetry-instrumentation-confluent-kafka", specifier = ">=0.60b1" }, + { name = "protobuf", specifier = "~=6.29" }, +] +dev-sentry = [{ name = "sentry-sdk", specifier = "~=2.51" }] +dev-types = [ + { name = "types-boto3-lite", extras = ["sqs"], specifier = "~=1.38" }, + { name = "types-croniter" }, + { name = "types-grpcio" }, + { name = "types-protobuf", specifier = "~=6.29" }, +] +examples = [ + { name = "fast-depends", specifier = "~=3.0" }, + { name = "fastapi-slim", specifier = "~=0.128" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = "~=3.2" }, +] +integration-tests = [ + { name = "boto3", specifier = "~=1.38" }, + { name = "testcontainers", extras = ["google", "localstack", "nats"], specifier = "~=4.10" }, +] +tests = [ + { name = "pytest", specifier = "~=9.0" }, + { name = "pytest-xdist" }, + { name = "setproctitle" }, +] +unit-tests = [ + { name = "coverage", extras = ["toml"], specifier = "~=7.6" }, + { name = "pytest-cov", specifier = "~=7.0" }, + { name = "pytest-mock", specifier = "~=3.14" }, +] + +[[package]] +name = "msal" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + +[[package]] +name = "nats-py" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/67/990309c99a64bc5dfe811c34ecf14782ea0338945781fcaabf3e22c4c88f/nats_py-2.13.0.tar.gz", hash = "sha256:3dfe941306926a687eb7cce242ab33c0eaf23413e55ddba1fd8dc835f127c285", size = 116480, upload-time = "2026-02-04T11:19:22.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/25/1460e86915cc626a361e3c3528a3db92e0a244419eba64c4df007cbc27a4/nats_py-2.13.0-py3-none-any.whl", hash = "sha256:0513dbf63ea0abf2131e273cd02004a7a9b0c93670cdea46722b43f7495b7705", size = 80914, upload-time = "2026-02-04T11:19:21.746Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/9c/3ab1db90f32da200dba332658f2bbe602369e3d19f6aba394031a42635be/opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c", size = 6147, upload-time = "2025-12-11T13:32:40.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6c/bdc82a066e6fb1dcf9e8cc8d4e026358fe0f8690700cc6369a6bf9bd17a7/opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe", size = 7019, upload-time = "2025-12-11T13:32:19.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-botocore" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-propagator-aws-xray" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/52/f4f3ffae9b83fc388ea4eb520d32605a8047216b523ce75a5766510e4464/opentelemetry_instrumentation_botocore-0.60b1.tar.gz", hash = "sha256:198e7d74b78b1b19ea47a5f2191171227557c37bfc4f49076958d129f848a8ed", size = 120933, upload-time = "2025-12-11T13:36:51.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/6d/0d37f8756f6a27f3ecc6956d78813993bacce037aa799b447b6fdea5af96/opentelemetry_instrumentation_botocore-0.60b1-py3-none-any.whl", hash = "sha256:b5cb1e267545cdd96a81a1bef690b1d00c20497ac4d815ef7c79fb3c572d6fc6", size = 38136, upload-time = "2025-12-11T13:35:50.59Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-confluent-kafka" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/16/eb5aa8f3a1f155fbc132ceecb475645635e221a2542ac914df101aedb345/opentelemetry_instrumentation_confluent_kafka-0.60b1.tar.gz", hash = "sha256:8272ecfa6ee07fefb21bcddcb1942d05d903e2b1516cb1c463a3e9d7f8dcdad1", size = 11606, upload-time = "2025-12-11T13:36:54.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/7a/ce8286a308d749e8060889a3f2ce8a49a69b44ac405937f3bc0a209606e8/opentelemetry_instrumentation_confluent_kafka-0.60b1-py3-none-any.whl", hash = "sha256:e25ca438f0a65266e5e580062a53e24df4e8d7e1902634f6c56122db8408e5bc", size = 12623, upload-time = "2025-12-11T13:35:55.426Z" }, +] + +[[package]] +name = "opentelemetry-propagator-aws-xray" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/31/40004e9e55b1e5694ef3a7526f0b7637df44196fc68a8b7d248a3684680f/opentelemetry_propagator_aws_xray-1.0.2.tar.gz", hash = "sha256:6b2cee5479d2ef0172307b66ed2ed151f598a0fd29b3c01133ac87ca06326260", size = 10994, upload-time = "2024-08-05T17:45:57.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/89/849a0847871fd9745315896ad9e23d6479db84d90b8b36c4c26dc46e92b8/opentelemetry_propagator_aws_xray-1.0.2-py3-none-any.whl", hash = "sha256:1c99181ee228e99bddb638a0c911a297fa21f1c3a0af951f841e79919b5f1934", size = 10856, upload-time = "2024-08-05T17:45:56.492Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" }, + { url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" }, + { url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" }, + { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" }, + { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" }, + { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" }, + { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" }, + { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" }, + { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pytimeparse2" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/10/cc63fecd69905eb4d300fe71bd580e4a631483e9f53fdcb8c0ad345ce832/pytimeparse2-1.7.1.tar.gz", hash = "sha256:98668cdcba4890e1789e432e8ea0059ccf72402f13f5d52be15bdfaeb3a8b253", size = 10431, upload-time = "2023-05-11T21:40:55.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9e/85abf91ef5df452f56498927affdb7128194d15644084f6c6722477c305b/pytimeparse2-1.7.1-py3-none-any.whl", hash = "sha256:a162ea6a7707fd0bb82dd99556efb783935f51885c8bdced0fce3fffe85ab002", size = 6136, upload-time = "2023-05-11T21:40:46.051Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" }, +] + +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, +] + +[package.optional-dependencies] +google = [ + { name = "google-cloud-datastore" }, + { name = "google-cloud-pubsub" }, +] +localstack = [ + { name = "boto3" }, +] +nats = [ + { name = "nats-py" }, +] + +[[package]] +name = "types-awscrt" +version = "0.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/be/589b7bba42b5681a72bac4d714287afef4e1bb84d07c859610ff631d449e/types_awscrt-0.31.1.tar.gz", hash = "sha256:08b13494f93f45c1a92eb264755fce50ed0d1dc75059abb5e31670feb9a09724", size = 17839, upload-time = "2026-01-16T02:01:23.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/fd/ddca80617f230bd833f99b4fb959abebffd8651f520493cae2e96276b1bd/types_awscrt-0.31.1-py3-none-any.whl", hash = "sha256:7e4364ac635f72bd57f52b093883640b1448a6eded0ecbac6e900bf4b1e4777b", size = 42516, upload-time = "2026-01-16T02:01:21.637Z" }, +] + +[[package]] +name = "types-boto3-lite" +version = "1.42.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/7f/16bf81e78af08a653ba6229bb3ce31b9e8ad0be0822852aa6cc277ecf554/types_boto3_lite-1.42.41.tar.gz", hash = "sha256:d8330df692afa9e2a57c28b0b819cd7a454537d7e3e2fdea70df162a815a3e4a", size = 73077, upload-time = "2026-02-03T21:06:14.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/492e88532bebbf1ab69a68f7d330fa9e613684e5d066347a4cad0f116f25/types_boto3_lite-1.42.41-py3-none-any.whl", hash = "sha256:34da438e34cca14acc2e01252b07d57bca46b31a02c8ea3fc15e23241f893a22", size = 42695, upload-time = "2026-02-03T21:06:10.474Z" }, +] + +[package.optional-dependencies] +sqs = [ + { name = "types-boto3-sqs" }, +] + +[[package]] +name = "types-boto3-sqs" +version = "1.42.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/54/94fd263aedb90113ab95d05c54958b9a71a5c4dd46b7d4faa0f3b7794df0/types_boto3_sqs-1.42.3.tar.gz", hash = "sha256:b7df81d6f1cc94ac9d59ee8ddafb21b1c4e9c1140960156c55e19b1cdc3358e3", size = 23134, upload-time = "2025-12-04T21:12:55.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/fa/65a1e627791fe5408a1551bacc081b4e49f7fac87053dd1549957b5edd8d/types_boto3_sqs-1.42.3-py3-none-any.whl", hash = "sha256:9290509e99f22464d39cba39feb8034b295ca312a84e43f8c7ad9b511c488e40", size = 33300, upload-time = "2025-12-04T21:12:54.202Z" }, +] + +[[package]] +name = "types-croniter" +version = "6.0.0.20250809" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/ac/7b26a9b19cc2b137293b14af71402ba83d13674c208b141474b6887465ae/types_croniter-6.0.0.20250809.tar.gz", hash = "sha256:c829295d4d65eaddcfafec905b0fbab59e72c3c91ee934a4d504dcafad79ff95", size = 11745, upload-time = "2025-08-09T03:14:10.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/99/e8e40592fbecb6671b32e4dab4cca2a1d4fa7d5b2f54aece134ccb42e839/types_croniter-6.0.0.20250809-py3-none-any.whl", hash = "sha256:d9f53f3e837eb6af509e2090fd2f5bb29b38425dd78f77d7b3bf37ccd2b2bf93", size = 9712, upload-time = "2025-08-09T03:14:09.966Z" }, +] + +[[package]] +name = "types-grpcio" +version = "1.0.0.20251009" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/93/78aa083216853c667c9412df4ef8284b2a68c6bcd2aef833f970b311f3c1/types_grpcio-1.0.0.20251009.tar.gz", hash = "sha256:a8f615ea7a47b31f10da028ab5258d4f1611fbd70719ca450fc0ab3fb9c62b63", size = 14479, upload-time = "2025-10-09T02:54:14.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/93/66d28f41b16bb4e6b611bd608ef28dffc740facec93250b30cf83138da21/types_grpcio-1.0.0.20251009-py3-none-any.whl", hash = "sha256:112ac4312a5b0a273a4c414f7f2c7668f342990d9c6ab0f647391c36331f95ed", size = 15208, upload-time = "2025-10-09T02:54:13.588Z" }, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20251210" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, +] + +[[package]] +name = "types-s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/64/42689150509eb3e6e82b33ee3d89045de1592488842ddf23c56957786d05/types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443", size = 13557, upload-time = "2025-12-08T08:13:09.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +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 = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From ceac9beb666c88b214a8c3596531e3e03dd49ba0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Feb 2026 14:15:50 +0000 Subject: [PATCH 008/286] WIP --- localpost/_sync_utils.py | 26 +++ localpost/http/config.py | 16 +- localpost/http/server.py | 277 ++++++++++--------------------- localpost/http/server_manager.py | 29 +++- localpost/http/server_wsgi.py | 108 ++++++++++++ pyproject.toml | 3 + uv.lock | 156 +++++++++++++++-- 7 files changed, 395 insertions(+), 220 deletions(-) create mode 100644 localpost/_sync_utils.py create mode 100644 localpost/http/server_wsgi.py diff --git a/localpost/_sync_utils.py b/localpost/_sync_utils.py new file mode 100644 index 0000000..9d3e130 --- /dev/null +++ b/localpost/_sync_utils.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import socket +import threading +from collections.abc import Callable +from contextlib import suppress + +from anyio import from_thread + +CHECK_TIMEOUT: float = 1.0 +"""Timeout (seconds) for cancellation checks (e.g. in the server loop).""" + + +def _sock_op[**P, T](op: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: + """Perform a socket operation with automatic retry on timeout.""" + while True: + from_thread.check_cancelled() + with suppress(socket.timeout): + return op(*args, **kwargs) + + +def _acquire(sem: threading.Semaphore): + while True: + from_thread.check_cancelled() + if sem.acquire(timeout=CHECK_TIMEOUT): + return diff --git a/localpost/http/config.py b/localpost/http/config.py index 4103c7a..16b0508 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -3,6 +3,8 @@ from dataclasses import dataclass, field from typing import final +from localpost._sync_utils import CHECK_TIMEOUT + __all__ = [ "LOGGER_NAME", "WorkerConfig", @@ -19,16 +21,22 @@ class ServerConfig: port: int = 8000 backlog: int = 16 """Maximum number of queued connections.""" - read_timeout: float = 5.0 - """Timeout (seconds) for read operations (keep-alive timeout too).""" + rw_timeout: float = CHECK_TIMEOUT + """Timeout (seconds) for read/write operations.""" + keep_alive_timeout: float = 5.0 + """Timeout (seconds) for idle connections.""" + max_body_size: int = 10 * 1024 * 1024 # 10 MiB + """Maximum request body size (bytes).""" @final @dataclass(frozen=True, slots=True) class WorkerConfig: server: ServerConfig = field(default_factory=ServerConfig) - max_concurrent_requests: int = 10 - """Max concurrent requests (connections).""" + max_connections: int = 10 + """Max connections = max concurrent requests.""" + max_idle_connections: int = 5 + """Maximum number of idle (keep-alive) connections (<= max_connections).""" @final diff --git a/localpost/http/server.py b/localpost/http/server.py index 8740847..c93860a 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -8,108 +8,27 @@ from __future__ import annotations +import itertools import logging import socket -import sys -from collections.abc import Iterator, Callable -from contextlib import contextmanager, closing, suppress -from io import BufferedReader, DEFAULT_BUFFER_SIZE, RawIOBase -from typing import Any, final -from wsgiref.types import WSGIApplication +from collections.abc import Iterator, Callable, Iterable +from contextlib import contextmanager +from io import DEFAULT_BUFFER_SIZE, RawIOBase +from typing import final import h11 +from anyio import from_thread +from localpost._sync_utils import _sock_op, CHECK_TIMEOUT from .config import ServerConfig, LOGGER_NAME -__all__ = ['Server', 'ClientConn', 'start_http_server'] +import dataclasses as dc -CHECK_TIMEOUT = 0.5 # Seconds - -try: - from anyio import from_thread, NoEventLoopError # noqa - - - def _checkpoint() -> None: - try: - from_thread.check_cancelled() - except NoEventLoopError: - pass -except ImportError: - def _checkpoint() -> None: - pass - - -def _sock_op[**P, T](op: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: - """Perform a socket operation with automatic retry on timeout.""" - while True: - try: - _checkpoint() - return op(*args, **kwargs) - except socket.timeout: - continue - - -@final -class RequestBodyStream(RawIOBase): - def __init__(self, conn: h11.Connection, sock: socket.socket) -> None: - self._conn = conn - self._sock = sock - self._finished = False - - def writable(self): - return False - - def seekable(self): - return False - - def readable(self) -> bool: - return True - - def readall(self): - chunks = bytearray() - with suppress(EOFError): - while True: - chunks.extend(self._receive(DEFAULT_BUFFER_SIZE)) - return chunks - - def readinto(self, b: bytearray, /) -> int: - try: - data = self._receive(len(b)) - size = len(data) - b[:size] = data - return size - except EOFError: - return 0 - - def _receive(self, size: int, /) -> bytes: - """Receive next chunk of body data from the socket via h11.""" - if self._finished: - raise EOFError() - conn, sock = self._conn, self._sock - while True: - event = conn.next_event() - if event is h11.NEED_DATA: - data = _sock_op(sock.recv, size) - if not data: - raise ConnectionAbortedError("Client closed connection unexpectedly") - conn.receive_data(data) - elif isinstance(event, h11.Data): - return event.data - elif isinstance(event, h11.EndOfMessage): - self._finished = True - raise EOFError() - else: - raise RuntimeError(f"Unexpected h11 event: {event!r}") - - def drain(self) -> None: - """Consume any remaining body data. Required before starting next request cycle.""" - with suppress(EOFError): - while not self._finished: - self._receive(DEFAULT_BUFFER_SIZE) +__all__ = ['Server', 'ClientConn', 'RequestHandler', 'start_http_server'] @contextmanager -def start_http_server(app: WSGIApplication, config: ServerConfig) -> Iterator[Server]: +def start_http_server(config: ServerConfig) -> Iterator[Server]: _socket = socket.create_server( (config.host, config.port), backlog=config.backlog, @@ -119,10 +38,22 @@ def start_http_server(app: WSGIApplication, config: ServerConfig) -> Iterator[Se logger = logging.getLogger(LOGGER_NAME) - server = Server(_socket, app, config, logger) + server = Server(_socket, config, logger) logger.info(f"Serving on {config.host}:{server.port}") - with _socket: + try: yield server + finally: + server.shutdown() + + +@dc.dataclass(frozen=True, slots=True) +class ConnectionActive: + pass + + +@dc.dataclass(frozen=True, slots=True) +class ConnectionIdle: + last_active_at: float @final @@ -130,12 +61,10 @@ class Server: def __init__( self, server_sock: socket.socket, - app: WSGIApplication, config: ServerConfig, logger: logging.Logger, ) -> None: self._socket = server_sock - self.app = app self.config = config self.port = server_sock.getsockname()[1] """ @@ -147,15 +76,21 @@ def __init__( self._closed = False def shutdown(self) -> None: - """Graceful shutdown (stop accepting connections).""" + """Stop accepting new connections and close the server socket.""" if self._closed: return self._closed = True + self._socket.close() # Safe to call if from another thread, will cause accept() to raise OSError def __iter__(self) -> Iterator[ClientConn]: while not self._closed: - client_sock, client_addr = _sock_op(self._socket.accept) - yield ClientConn(self, client_sock, self._logger) + try: + client_sock, client_addr = _sock_op(self._socket.accept) + yield ClientConn(self, client_sock, client_addr, self._logger) + except OSError: + if self._closed: + return # Socket was closed, exit gracefully + raise # Unexpected error @final @@ -164,27 +99,38 @@ def __init__( self, server: Server, client_sock: socket.socket, + client_addr: tuple[str, int], logger: logging.Logger, ) -> None: self.server = server self.config = server.config - self._socket = client_sock - self._conn = h11.Connection(h11.SERVER) + self.socket = client_sock + self.address = client_addr + self.conn = h11.Connection(h11.SERVER) self._logger = logger - def __call__(self) -> None: + def __call__(self, h: RequestHandler) -> None: # TODO Assert it's not called concurrently - with self._socket: - self._socket.settimeout(self.config.read_timeout) + + with self.socket: + self.socket.settimeout(self.config.rw_timeout) while True: - keep_alive = self._handle_request() + keep_alive = self._handle_request(h) + self._drain_body() if not keep_alive: return # Prepare for next request on this client's connection - self._conn.start_next_cycle() + self.conn.start_next_cycle() + + def _read_body(self) -> RawIOBase: + pass # FIXME Implement - def _handle_request(self) -> bool: - conn, sock = self._conn, self._socket + def _drain_body(self) -> None: + """Drain any unread body data before next request cycle.""" + pass + + def _handle_request(self, h: RequestHandler) -> bool: + conn, sock = self.conn, self.socket request: h11.Request | None = None # Only read until we get the Request headers - body is read lazily @@ -192,8 +138,8 @@ def _handle_request(self) -> bool: event = conn.next_event() if event is h11.NEED_DATA: try: - data = _sock_op(sock.recv, DEFAULT_BUFFER_SIZE) - except socket.timeout: + data = sock.recv(DEFAULT_BUFFER_SIZE) + except TimeoutError: return False # Idle timeout, close connection if not data: return False # Client closed connection @@ -201,97 +147,44 @@ def _handle_request(self) -> bool: elif isinstance(event, h11.Request): request = event - # Create lazy body that will read from socket on demand - body = RequestBodyStream(conn, sock) - environ = self._build_environ(request, body) + body = self._read_body() keep_alive: bool = False - def start_response(status: str, headers: list[tuple[str, str]], /) -> None: + def start_response(response: h11.Response) -> None: + from_thread.check_cancelled() + nonlocal keep_alive - keep_alive = _should_keep_alive(request, headers) - - # Add Connection header if not present - if not any(name.lower() == 'connection' for name, _ in headers): - headers.append(('Connection', 'keep-alive' if keep_alive else 'close')) - - # Send response headers immediately - status_code = int(status.split(' ', 1)[0]) - response = h11.Response( - status_code=status_code, - headers=[(name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) - for name, value in headers]) + keep_alive = _should_keep_alive(request, response) + + # TODO Add Connection header if not present + sock.sendall(conn.send(response)) - response_body = self.server.app(environ, start_response) # type: ignore + response_chunks = h(self, request, body, start_response) try: - for chunk in response_body: - # TODO Check from_thread.check_cancelled() - if chunk: - sock.sendall(conn.send(h11.Data(data=chunk))) + from_thread.check_cancelled() + for chunk in response_chunks: + from_thread.check_cancelled() + sock.sendall(conn.send(chunk)) finally: - if hasattr(response_body, 'close'): - response_body.close() # Generator cleanup + if hasattr(response_chunks, 'close'): + response_chunks.close() # Generator cleanup sock.sendall(conn.send(h11.EndOfMessage())) - # Drain any unread body data before next request cycle - if keep_alive: - body.drain() - return keep_alive - def _build_environ(self, request: h11.Request, body: RequestBodyStream) -> dict[str, Any]: - # Decode path and parse query string - path = request.target.decode('ISO-8859-1') - if '?' in path: - path, query_string = path.split('?', 1) - else: - query_string = '' - - headers_dict = {} - for name, value in request.headers: - headers_dict[name.decode('ISO-8859-1').lower()] = value.decode('ISO-8859-1') - - # See https://wsgi.readthedocs.io/en/latest/definitions.html - environ = { - 'REQUEST_METHOD': request.method.decode('ascii'), - 'PATH_INFO': path, - 'QUERY_STRING': query_string, - 'CONTENT_TYPE': headers_dict.get('content-type', ''), - 'CONTENT_LENGTH': headers_dict.get('content-length', ''), - 'SERVER_NAME': self.config.host, # TODO Actual name - 'SERVER_PORT': str(self.server.port), - 'SERVER_PROTOCOL': f'HTTP/{request.http_version.decode("ascii")}', - 'wsgi.version': (1, 0), - 'wsgi.url_scheme': 'http', - 'wsgi.input': BufferedReader(body), - 'wsgi.errors': sys.stderr, # Handle it later with the logger - 'wsgi.multithread': True, - 'wsgi.multiprocess': False, - 'wsgi.run_once': False, - } - - # Add HTTP headers - for name, value in request.headers: - name_str = name.decode('ISO-8859-1').upper().replace('-', '_') - if name_str not in ('CONTENT_TYPE', 'CONTENT_LENGTH'): - name_str = f'HTTP_{name_str}' - environ[name_str] = value.decode('ISO-8859-1') - - return environ - - -def _should_keep_alive(request: h11.Request, response_headers: list[tuple[str, str]]) -> bool: - """Determine if the connection should be kept alive.""" - # Check if response explicitly sets Connection header - for name, value in response_headers: - if name.lower() == 'connection': - return value.lower() == 'keep-alive' - # Check request's Connection header - for name, value in request.headers: +StartResponse = Callable[[h11.Response], None] +RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase, StartResponse], Iterable[h11.Data]] + + +def _should_keep_alive(request: h11.Request, response: h11.Response) -> bool: + """Determine if the connection should be kept alive.""" + # Check if either request or response has an explicit Connection header + for name, value in itertools.chain(response.headers, request.headers): if name.lower() == b'connection': - return value.lower() == b'keep-alive' + return value.lower() == 'keep-alive' # HTTP/1.1 defaults to keep-alive return request.http_version == b'1.1' @@ -305,9 +198,9 @@ def simple_app(_, start_response): yield b'Hello, World!\n' # return [b'Hello, World!\n'] - with start_http_server(simple_app, ServerConfig()) as server: + with start_http_server(ServerConfig()) as server: for client_conn in server: - client_conn() + client_conn(simple_app) def _main_flask(): @@ -322,11 +215,11 @@ def hello(name): user_agent = request.headers.get('User-Agent', 'Unknown') return f'Hello, {name}! Your User-Agent is: {user_agent}\n' - with start_http_server(app, ServerConfig()) as server: + with start_http_server(ServerConfig()) as server: for client_conn in server: - client_conn() + client_conn(app) if __name__ == '__main__': - # _main() - _main_flask() + _main() + # _main_flask() diff --git a/localpost/http/server_manager.py b/localpost/http/server_manager.py index ef312ff..916b0cb 100644 --- a/localpost/http/server_manager.py +++ b/localpost/http/server_manager.py @@ -3,7 +3,7 @@ import logging import signal import threading -from collections.abc import Awaitable +from collections.abc import Awaitable, Iterable from contextlib import asynccontextmanager from dataclasses import dataclass from typing import final @@ -12,17 +12,29 @@ import anyio from anyio import to_thread, CancelScope, from_thread, create_task_group +from localpost._sync_utils import _acquire from .config import WorkerConfig from .server import start_http_server, ClientConn, Server __all__ = ['Worker', 'serve'] +class AbstractSyncWorker[T]: + def __init__(self, server: Iterable[T], max_concurrency: int) -> None: + self.server = server + self.max_concurrency = max_concurrency + + def shutdown(self) -> None: + """Graceful shutdown (stop handling new connections, wait for in-flight requests).""" + self.server.shutdown() + + + @asynccontextmanager async def serve(app: WSGIApplication, config: WorkerConfig, /): """Run multiple servers (workers).""" - threads_limiter = anyio.CapacityLimiter(config.max_concurrent_requests) - conn_sem = threading.BoundedSemaphore(config.max_concurrent_requests) + threads_limiter = anyio.CapacityLimiter(config.max_connections) + conn_sem = threading.BoundedSemaphore(config.max_connections) def handle_client(c: ClientConn) -> None: try: @@ -37,17 +49,16 @@ def handle_clients_thread() -> Awaitable[None]: return to_thread.run_sync(handle_clients, limiter=anyio.CapacityLimiter(1)) def handle_clients() -> None: + _acquire(conn_sem) for client_conn in server: - conn_sem.acquire() # TODO Check from_thread.check_cancelled() periodically # Process the client in a separate thread, to support multiple concurrent connections from_thread.run_sync(tg.start_soon, handle_client_thread, client_conn) + _acquire(conn_sem) - with start_http_server(app, config.server) as server: - async with create_task_group() as tg: + async with create_task_group() as tg: + with start_http_server(app, config.server) as server: tg.start_soon(handle_clients_thread) - sm = Worker(server, config, tg.cancel_scope) - yield sm - sm.shutdown() + yield Worker(server, config, tg.cancel_scope) @final diff --git a/localpost/http/server_wsgi.py b/localpost/http/server_wsgi.py new file mode 100644 index 0000000..5186f89 --- /dev/null +++ b/localpost/http/server_wsgi.py @@ -0,0 +1,108 @@ +""" +Simple WSGI server implementation using h11 for HTTP protocol handling. + +Notes: +- ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. +- The server supports keep-alive connections and graceful shutdown. +""" + +from __future__ import annotations + +import io +import logging +import sys +from io import BufferedReader +from typing import Any +from wsgiref.types import WSGIApplication + +import h11 + +from .server import RequestHandler + + +def wrap_wsgi_app(app: WSGIApplication) -> RequestHandler: + # def start_response(status: str, headers: list[tuple[str, str]], /) -> None: + # nonlocal keep_alive + # keep_alive = _should_keep_alive(request, headers) + # + # # Add Connection header if not present + # if not any(name.lower() == 'connection' for name, _ in headers): + # headers.append(('Connection', 'keep-alive' if keep_alive else 'close')) + # + # # Send response headers immediately + # status_code = int(status.split(' ', 1)[0]) + # response = h11.Response( + # status_code=status_code, + # headers=[(name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) + # for name, value in headers]) + # sock.sendall(conn.send(response)) + # + # response_body = self.server.app(environ, start_response) # type: ignore + # try: + # for chunk in response_body: + # # TODO Check from_thread.check_cancelled() + # if chunk: + # sock.sendall(conn.send(h11.Data(data=chunk))) + # finally: + # if hasattr(response_body, 'close'): + # response_body.close() # Generator cleanup + # + # sock.sendall(conn.send(h11.EndOfMessage())) + # + # # Drain any unread body data before next request cycle + # if keep_alive: + # body.drain() + # + # return keep_alive + pass # TODO Implement this function + + +def _build_environ(self, request: h11.Request, body: io.RawIOBase) -> dict[str, Any]: + # Decode path and parse query string + path = request.target.decode('ISO-8859-1') + if '?' in path: + path, query_string = path.split('?', 1) + else: + query_string = '' + + headers_dict = {} + for name, value in request.headers: + headers_dict[name.decode('ISO-8859-1').lower()] = value.decode('ISO-8859-1') + + # See https://wsgi.readthedocs.io/en/latest/definitions.html + environ = { + 'REQUEST_METHOD': request.method.decode('ascii'), + 'PATH_INFO': path, + 'QUERY_STRING': query_string, + 'CONTENT_TYPE': headers_dict.get('content-type', ''), + 'CONTENT_LENGTH': headers_dict.get('content-length', ''), + 'SERVER_NAME': self.config.host, # TODO Actual name + 'SERVER_PORT': str(self.server.port), + 'SERVER_PROTOCOL': f'HTTP/{request.http_version.decode("ascii")}', + 'wsgi.version': (1, 0), + 'wsgi.url_scheme': 'http', + 'wsgi.input': BufferedReader(body), + 'wsgi.errors': sys.stderr, # Handle it later with the logger + 'wsgi.multithread': True, + 'wsgi.multiprocess': False, + 'wsgi.run_once': False, + } + + # Add HTTP headers + for name, value in request.headers: + name_str = name.decode('ISO-8859-1').upper().replace('-', '_') + if name_str not in ('CONTENT_TYPE', 'CONTENT_LENGTH'): + name_str = f'HTTP_{name_str}' + environ[name_str] = value.decode('ISO-8859-1') + + return environ + + + +def _main(): + logging.basicConfig(level=logging.DEBUG) + + pass + +if __name__ == '__main__': + _main() diff --git a/pyproject.toml b/pyproject.toml index 25481c9..1c72557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,9 @@ dev-consumers = [ "confluent-kafka[schemaregistry,protobuf]", "grpcio-tools ~=1.68", ] +dev-http = [ + "flask ~=3.1", +] dev-sentry = [ "sentry-sdk ~=2.51", ] diff --git a/uv.lock b/uv.lock index 21b6cf1..fcd795a 100644 --- a/uv.lock +++ b/uv.lock @@ -139,32 +139,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/22/5da115105c9fe7e2fc11804018649b394f60a62735e19642acf336e3807a/azure_storage_queue-12.15.0-py3-none-any.whl", hash = "sha256:056cfce0cd60458f0b7653d804f639098b14593f843899c6c0fc65b3ebe61210", size = 187547, upload-time = "2026-01-07T00:18:05.23Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "boto3" -version = "1.42.41" +version = "1.42.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/b0/f2a371146877b4d60cd2a6f51c440503cbf77a73c85347ad282eebe90269/boto3-1.42.41.tar.gz", hash = "sha256:86dd2bc577e33da5cd9f10e21b22cdda01a24f83f31ca1bb5ac1f5f8b8e9e67e", size = 112806, upload-time = "2026-02-03T20:38:58.004Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/ac/9ef2cc431e00ecbf1ac197938ddea120e75b516b4c57028a1fd517d3e882/boto3-1.42.42.tar.gz", hash = "sha256:8c2537156f5ccd72bbbfe4fc27a8a80bf3e4f80523f306417f3fb6023d13edda", size = 112831, upload-time = "2026-02-04T20:28:45.494Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/e8/e671b9aed3e8f6bb5f85aed7fd9dd4e79074798b2a14391fab6b6da04ff7/boto3-1.42.41-py3-none-any.whl", hash = "sha256:32470b1d32208e03b47cf6ce4a7adb337b8a1730aaefb97c336cfd4e2be2577f", size = 140602, upload-time = "2026-02-03T20:38:56.352Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0a/3966f239e1d9da93cb755dc0213835ce4e9ed93645192878d0a055ecdc31/boto3-1.42.42-py3-none-any.whl", hash = "sha256:8c78169ef47dc29863ebb11ba99134b1b418d3dfdd836419830f22552f8afe43", size = 140599, upload-time = "2026-02-04T20:28:43.056Z" }, ] [[package]] name = "botocore" -version = "1.42.41" +version = "1.42.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/1d/5fd43319250a5a484bcbde334da2e2297956cdae40de8eb0808759538a7c/botocore-1.42.41.tar.gz", hash = "sha256:0698967741d873d819134bea1ffe9c35decc00c741f49a42885dbd7ad8198fbc", size = 14923189, upload-time = "2026-02-03T20:38:47.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/96/4eca9755ca444402c46c73cc8ff252c8eb73ab0ccf35ca76d89e7b7820ac/botocore-1.42.42.tar.gz", hash = "sha256:cb75639f5ba7bf73b83ac18bcd87f07b7f484f302748da974dad2801a83a1d60", size = 14926585, upload-time = "2026-02-04T20:28:33.66Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/84/df765c7942f52eab12540ef1880b9489d45b364b777fad522fa84435ed11/botocore-1.42.41-py3-none-any.whl", hash = "sha256:567530f7d4da668af891c1259fb80c01578ab8d9f1098127cc250451a46fb54f", size = 14597792, upload-time = "2026-02-03T20:38:45.058Z" }, + { url = "https://files.pythonhosted.org/packages/e6/51/aac7e419521d5519e13087a7198623655648c939822bd7f4bdc9ccbe07f9/botocore-1.42.42-py3-none-any.whl", hash = "sha256:1c9df5fc31e9073a9aa956271c4007d72f5d342cafca5f4154ea099bc6f83085", size = 14600186, upload-time = "2026-02-04T20:28:29.268Z" }, ] [[package]] @@ -564,7 +573,7 @@ wheels = [ [[package]] name = "fastapi-slim" -version = "0.128.0" +version = "0.128.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -572,9 +581,26 @@ dependencies = [ { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/6b/2310ece5695e7025d32dcb2a4d31b91b6d52b97e4ddc8aa69a6e1bb45a26/fastapi_slim-0.128.0.tar.gz", hash = "sha256:7916e28ec3bd897ea4adff9e8d257ff6edba0c1f383640896d54099f78a8afef", size = 365903, upload-time = "2025-12-27T15:21:16.008Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/4c/44ef7d6b52eea55056ce7e531ea486872c1d3bbc78adae7fa1aeca3525d9/fastapi_slim-0.128.1.tar.gz", hash = "sha256:f03db3328129cc7ea966bd1f38f55e5df29e0c97798e2824acac5efd711b433f", size = 374376, upload-time = "2026-02-04T17:35:14.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/d7/619a9176e10fc455d1c732523606961937248cd3142ee23934ebc15578fd/fastapi_slim-0.128.1-py3-none-any.whl", hash = "sha256:483aa027976cd0bf9591482e7f0fd6b5a460a233b66f23436dc5f8fa43d3e69a", size = 103866, upload-time = "2026-02-04T17:35:15.595Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/af/8b646358f86d9606552613f1b17f78d6f53dae6d69af8af952b0138f09da/fastapi_slim-0.128.0-py3-none-any.whl", hash = "sha256:19034e3e48503573fe429d94906d4b5180aa7c754f9148a55ab1981abdb73293", size = 103149, upload-time = "2025-12-27T15:21:14.184Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, ] [[package]] @@ -938,6 +964,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -1003,6 +1050,9 @@ dev-hosting-http = [ { name = "hypercorn" }, { name = "uvicorn" }, ] +dev-http = [ + { name = "flask" }, +] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-instrumentation-botocore" }, @@ -1072,6 +1122,7 @@ dev-hosting-http = [ { name = "hypercorn", specifier = "~=0.17" }, { name = "uvicorn", specifier = "~=0.30" }, ] +dev-http = [{ name = "flask", specifier = "~=3.1" }] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-instrumentation-botocore", specifier = ">=0.60b1" }, @@ -1105,6 +1156,69 @@ unit-tests = [ { name = "pytest-mock", specifier = "~=3.14" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "msal" version = "1.34.0" @@ -1726,15 +1840,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.51.0" +version = "2.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" }, + { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, ] [[package]] @@ -1874,15 +1988,15 @@ wheels = [ [[package]] name = "types-boto3-lite" -version = "1.42.41" +version = "1.42.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/7f/16bf81e78af08a653ba6229bb3ce31b9e8ad0be0822852aa6cc277ecf554/types_boto3_lite-1.42.41.tar.gz", hash = "sha256:d8330df692afa9e2a57c28b0b819cd7a454537d7e3e2fdea70df162a815a3e4a", size = 73077, upload-time = "2026-02-03T21:06:14.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/3f/f9cf32c60f1acee031821dad94d3178047406de9151745cbed7ebbe93ed7/types_boto3_lite-1.42.42.tar.gz", hash = "sha256:0052bb5299c3602b236564d592ca69076575f14534bc31454f4bd5ad05b0b575", size = 73071, upload-time = "2026-02-04T20:53:24.09Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/cb/492e88532bebbf1ab69a68f7d330fa9e613684e5d066347a4cad0f116f25/types_boto3_lite-1.42.41-py3-none-any.whl", hash = "sha256:34da438e34cca14acc2e01252b07d57bca46b31a02c8ea3fc15e23241f893a22", size = 42695, upload-time = "2026-02-03T21:06:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/97/13/e7a31dc809f1dbebda73c2738706edd153f11339eb4841cf1f10ddb3c995/types_boto3_lite-1.42.42-py3-none-any.whl", hash = "sha256:46a5f044e92adb1af6d6f2897634d0990d4a8ec818f57db2621b6620813ce189", size = 42692, upload-time = "2026-02-04T20:53:20.667Z" }, ] [package.optional-dependencies] @@ -1987,6 +2101,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" From 689875965a8e77caea0d74c3ff8c790a49e19b18 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Feb 2026 14:57:35 +0000 Subject: [PATCH 009/286] WIP --- localpost/http/server.py | 115 +++++++++++++++++++++------------- localpost/http/server_wsgi.py | 18 ++++-- 2 files changed, 86 insertions(+), 47 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index c93860a..3b9d405 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -12,8 +12,8 @@ import logging import socket from collections.abc import Iterator, Callable, Iterable -from contextlib import contextmanager -from io import DEFAULT_BUFFER_SIZE, RawIOBase +from contextlib import contextmanager, suppress +from io import DEFAULT_BUFFER_SIZE, RawIOBase, BufferedReader from typing import final import h11 @@ -46,16 +46,6 @@ def start_http_server(config: ServerConfig) -> Iterator[Server]: server.shutdown() -@dc.dataclass(frozen=True, slots=True) -class ConnectionActive: - pass - - -@dc.dataclass(frozen=True, slots=True) -class ConnectionIdle: - last_active_at: float - - @final class Server: def __init__( @@ -93,6 +83,70 @@ def __iter__(self) -> Iterator[ClientConn]: raise # Unexpected error +@final +class RequestBodyStream(RawIOBase): + def __init__(self, conn: h11.Connection, sock: socket.socket) -> None: + self._conn = conn + self._sock = sock + self._finished = False + + def writable(self): + return False + + def seekable(self): + return False + + def readable(self) -> bool: + return True + + def readall(self): + chunks = bytearray() + for chunk in self.receive_chunks(): + chunks.extend(chunk) + return chunks + + def readinto(self, b: bytearray, /) -> int: + try: + data = self.receive_chunk(len(b)) + size = len(data) + b[:size] = data + return size + except EOFError: + return 0 + + def receive_chunks(self) -> Iterable[bytes]: + with suppress(EOFError): + while True: + yield self.receive_chunk(DEFAULT_BUFFER_SIZE) + + def receive_chunk(self, size: int, /) -> bytes: + """Receive next chunk of body data from the socket via h11.""" + conn, sock = self._conn, self._sock + while True: + if self.closed: + raise ValueError("Read on closed request body stream") + if self._finished: + raise EOFError("End of request body stream") + event = conn.next_event() + if event is h11.NEED_DATA: + data = _sock_op(sock.recv, size) + if not data: + raise ConnectionAbortedError("Client closed connection unexpectedly") + conn.receive_data(data) + elif isinstance(event, h11.Data): + return event.data + elif isinstance(event, h11.EndOfMessage): + self._finished = True + else: + raise RuntimeError(f"Unexpected h11 event: {event!r}") + + def drain(self) -> None: + """Consume any remaining body data. Required before starting next request cycle.""" + with suppress(EOFError): + while not self._finished: + self.receive_chunk(DEFAULT_BUFFER_SIZE) + + @final class ClientConn: def __init__( @@ -115,21 +169,16 @@ def __call__(self, h: RequestHandler) -> None: with self.socket: self.socket.settimeout(self.config.rw_timeout) while True: - keep_alive = self._handle_request(h) - self._drain_body() + rb = RequestBodyStream(self.conn, self.socket) + keep_alive = self._handle_request(h, rb) if not keep_alive: return + rb.drain() # Prepare for next request on this client's connection self.conn.start_next_cycle() + # TODO Proper keep-alive timeout - def _read_body(self) -> RawIOBase: - pass # FIXME Implement - - def _drain_body(self) -> None: - """Drain any unread body data before next request cycle.""" - pass - - def _handle_request(self, h: RequestHandler) -> bool: + def _handle_request(self, h: RequestHandler, body: RequestBodyStream) -> bool: conn, sock = self.conn, self.socket request: h11.Request | None = None @@ -147,7 +196,6 @@ def _handle_request(self, h: RequestHandler) -> bool: elif isinstance(event, h11.Request): request = event - body = self._read_body() keep_alive: bool = False def start_response(response: h11.Response) -> None: @@ -156,8 +204,7 @@ def start_response(response: h11.Response) -> None: nonlocal keep_alive keep_alive = _should_keep_alive(request, response) - # TODO Add Connection header if not present - + # Add Connection header if not present?.. sock.sendall(conn.send(response)) response_chunks = h(self, request, body, start_response) @@ -203,23 +250,5 @@ def simple_app(_, start_response): client_conn(simple_app) -def _main_flask(): - logging.basicConfig(level=logging.DEBUG) - - from flask import Flask, request - - app = Flask(__name__) - - @app.route('/hello/') - def hello(name): - user_agent = request.headers.get('User-Agent', 'Unknown') - return f'Hello, {name}! Your User-Agent is: {user_agent}\n' - - with start_http_server(ServerConfig()) as server: - for client_conn in server: - client_conn(app) - - if __name__ == '__main__': _main() - # _main_flask() diff --git a/localpost/http/server_wsgi.py b/localpost/http/server_wsgi.py index 5186f89..10c3efd 100644 --- a/localpost/http/server_wsgi.py +++ b/localpost/http/server_wsgi.py @@ -98,11 +98,21 @@ def _build_environ(self, request: h11.Request, body: io.RawIOBase) -> dict[str, return environ - -def _main(): +def _main_flask(): logging.basicConfig(level=logging.DEBUG) - pass + from flask import Flask, request + + app = Flask(__name__) + + @app.route('/hello/') + def hello(name): + user_agent = request.headers.get('User-Agent', 'Unknown') + return f'Hello, {name}! Your User-Agent is: {user_agent}\n' + + with start_http_server(ServerConfig()) as server: + for client_conn in server: + client_conn(app) if __name__ == '__main__': - _main() + _main_flask() From 69a3332ef7a75f9064249933db89f83a0f2a9f8b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Feb 2026 15:19:43 +0000 Subject: [PATCH 010/286] WIP --- localpost/__init__.py | 4 +- localpost/_sync_utils.py | 10 ++- localpost/_utils.py | 4 +- localpost/http/config.py | 12 ---- localpost/http/server.py | 41 +++++------- localpost/http/server_wsgi.py | 119 ++++++++++++++++++---------------- 6 files changed, 93 insertions(+), 97 deletions(-) diff --git a/localpost/__init__.py b/localpost/__init__.py index fdeca75..fc0a80a 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -2,7 +2,7 @@ from importlib.metadata import version from ._debug import debug -from ._run import arun, run +# from ._run import arun, run from ._utils import Result try: @@ -11,7 +11,7 @@ __version__ = "dev" -__all__ = ["Result", "__version__", "arun", "debug", "run"] +# __all__ = ["Result", "__version__", "arun", "debug", "run"] # Set up logging according to the best practices: diff --git a/localpost/_sync_utils.py b/localpost/_sync_utils.py index 9d3e130..d7e7fcc 100644 --- a/localpost/_sync_utils.py +++ b/localpost/_sync_utils.py @@ -5,22 +5,28 @@ from collections.abc import Callable from contextlib import suppress +import anyio from anyio import from_thread CHECK_TIMEOUT: float = 1.0 """Timeout (seconds) for cancellation checks (e.g. in the server loop).""" +def check_cancelled() -> None: + with suppress(anyio.NoEventLoopError): + from_thread.check_cancelled() + + def _sock_op[**P, T](op: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: """Perform a socket operation with automatic retry on timeout.""" while True: - from_thread.check_cancelled() + check_cancelled() with suppress(socket.timeout): return op(*args, **kwargs) def _acquire(sem: threading.Semaphore): while True: - from_thread.check_cancelled() + check_cancelled() if sem.acquire(timeout=CHECK_TIMEOUT): return diff --git a/localpost/_utils.py b/localpost/_utils.py index d1a2ec7..8052003 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -30,7 +30,7 @@ from anyio import CancelScope, CapacityLimiter, WouldBlock, create_task_group, from_thread, to_thread from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, MemoryObjectStreamState +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, _MemoryObjectStreamState from typing_extensions import NotRequired, Self, TypeVar if sys.version_info >= (3, 11): @@ -325,7 +325,7 @@ def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjec if max_buffer_size < 0: raise ValueError("max_buffer_size cannot be negative") - state: MemoryObjectStreamState[T] = MemoryObjectStreamState(max_buffer_size) + state: _MemoryObjectStreamState[T] = _MemoryObjectStreamState(max_buffer_size) return MemorySendStream(state), MemoryObjectReceiveStream(state) diff --git a/localpost/http/config.py b/localpost/http/config.py index 16b0508..1b7aa23 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -37,15 +37,3 @@ class WorkerConfig: """Max connections = max concurrent requests.""" max_idle_connections: int = 5 """Maximum number of idle (keep-alive) connections (<= max_connections).""" - - -@final -@dataclass(frozen=True, slots=True) -class ProcessWorkerConfig: - pass # For the future - - -@final -@dataclass(frozen=True, slots=True) -class InterpreterWorkerConfig: - pass # For the future diff --git a/localpost/http/server.py b/localpost/http/server.py index 3b9d405..92b63d0 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -11,20 +11,18 @@ import itertools import logging import socket -from collections.abc import Iterator, Callable, Iterable -from contextlib import contextmanager, suppress -from io import DEFAULT_BUFFER_SIZE, RawIOBase, BufferedReader -from typing import final +import time +from collections.abc import Iterator, Callable, Iterable, Generator +from contextlib import contextmanager, suppress, closing +from io import DEFAULT_BUFFER_SIZE, RawIOBase +from typing import final, Literal import h11 -from anyio import from_thread -from localpost._sync_utils import _sock_op, CHECK_TIMEOUT -from .config import ServerConfig, LOGGER_NAME +from localpost._sync_utils import _sock_op, CHECK_TIMEOUT, check_cancelled +from localpost.http.config import ServerConfig, LOGGER_NAME -import dataclasses as dc - -__all__ = ['Server', 'ClientConn', 'RequestHandler', 'start_http_server'] +__all__ = ['Server', 'ClientConn', 'RequestHandler', 'StartResponse', 'start_http_server'] @contextmanager @@ -162,6 +160,8 @@ def __init__( self.address = client_addr self.conn = h11.Connection(h11.SERVER) self._logger = logger + self.state: Literal['active', 'idle', 'closed'] = 'idle' + self.last_active_at: float = time.monotonic() def __call__(self, h: RequestHandler) -> None: # TODO Assert it's not called concurrently @@ -199,7 +199,7 @@ def _handle_request(self, h: RequestHandler, body: RequestBodyStream) -> bool: keep_alive: bool = False def start_response(response: h11.Response) -> None: - from_thread.check_cancelled() + check_cancelled() nonlocal keep_alive keep_alive = _should_keep_alive(request, response) @@ -207,15 +207,11 @@ def start_response(response: h11.Response) -> None: # Add Connection header if not present?.. sock.sendall(conn.send(response)) - response_chunks = h(self, request, body, start_response) - try: - from_thread.check_cancelled() + with closing(h(self, request, body, start_response)) as response_chunks: + check_cancelled() for chunk in response_chunks: - from_thread.check_cancelled() + check_cancelled() sock.sendall(conn.send(chunk)) - finally: - if hasattr(response_chunks, 'close'): - response_chunks.close() # Generator cleanup sock.sendall(conn.send(h11.EndOfMessage())) @@ -223,7 +219,7 @@ def start_response(response: h11.Response) -> None: StartResponse = Callable[[h11.Response], None] -RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase, StartResponse], Iterable[h11.Data]] +RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase, StartResponse], Generator[h11.Data]] def _should_keep_alive(request: h11.Request, response: h11.Response) -> bool: @@ -240,10 +236,9 @@ def _should_keep_alive(request: h11.Request, response: h11.Response) -> bool: def _main(): logging.basicConfig(level=logging.DEBUG) - def simple_app(_, start_response): - start_response('200 OK', [('Content-Type', 'text/plain')]) - yield b'Hello, World!\n' - # return [b'Hello, World!\n'] + def simple_app(c: ClientConn, r: h11.Request, rb: RawIOBase, start_response: StartResponse): + start_response(h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')])) + yield h11.Data(data=b'Hello, World!\n') with start_http_server(ServerConfig()) as server: for client_conn in server: diff --git a/localpost/http/server_wsgi.py b/localpost/http/server_wsgi.py index 10c3efd..85673b1 100644 --- a/localpost/http/server_wsgi.py +++ b/localpost/http/server_wsgi.py @@ -1,63 +1,65 @@ -""" -Simple WSGI server implementation using h11 for HTTP protocol handling. - -Notes: -- ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. -- The server supports keep-alive connections and graceful shutdown. -""" - from __future__ import annotations -import io import logging import sys -from io import BufferedReader +from collections.abc import Callable +from io import BufferedReader, RawIOBase from typing import Any from wsgiref.types import WSGIApplication import h11 -from .server import RequestHandler - - -def wrap_wsgi_app(app: WSGIApplication) -> RequestHandler: - # def start_response(status: str, headers: list[tuple[str, str]], /) -> None: - # nonlocal keep_alive - # keep_alive = _should_keep_alive(request, headers) - # - # # Add Connection header if not present - # if not any(name.lower() == 'connection' for name, _ in headers): - # headers.append(('Connection', 'keep-alive' if keep_alive else 'close')) - # - # # Send response headers immediately - # status_code = int(status.split(' ', 1)[0]) - # response = h11.Response( - # status_code=status_code, - # headers=[(name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) - # for name, value in headers]) - # sock.sendall(conn.send(response)) - # - # response_body = self.server.app(environ, start_response) # type: ignore - # try: - # for chunk in response_body: - # # TODO Check from_thread.check_cancelled() - # if chunk: - # sock.sendall(conn.send(h11.Data(data=chunk))) - # finally: - # if hasattr(response_body, 'close'): - # response_body.close() # Generator cleanup - # - # sock.sendall(conn.send(h11.EndOfMessage())) - # - # # Drain any unread body data before next request cycle - # if keep_alive: - # body.drain() - # - # return keep_alive - pass # TODO Implement this function - - -def _build_environ(self, request: h11.Request, body: io.RawIOBase) -> dict[str, Any]: +from localpost.http.server import ClientConn, RequestHandler, StartResponse + + +def wrap_wsgi(app: WSGIApplication) -> RequestHandler: + """Wrap a WSGI application as a RequestHandler.""" + + def handler(client: ClientConn, request: h11.Request, body: RawIOBase, start_response: StartResponse): + environ = _build_environ(client, request, body) + + def wsgi_start_response( + status: str, + headers: list[tuple[str, str]], + exc_info: Any = None, + ) -> Callable[[bytes], None]: + if exc_info: + try: + raise exc_info[1].with_traceback(exc_info[2]) + finally: + exc_info = None + + # Parse status code from "200 OK" format + status_code = int(status.split(' ', 1)[0]) + + # Convert headers to h11 format (bytes) + h11_headers = [ + (name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) + for name, value in headers + ] + + response = h11.Response(status_code=status_code, headers=h11_headers) + start_response(response) + + return _wsgi_response_write + + response_body = app(environ, wsgi_start_response) + try: + for chunk in response_body: + if chunk: + yield h11.Data(data=chunk) + finally: + if hasattr(response_body, 'close'): + response_body.close() + + return handler + + +def _wsgi_response_write(_: bytes) -> None: + raise NotImplementedError("write() is deprecated and not supported") + + +def _build_environ(client: ClientConn, request: h11.Request, body: RawIOBase) -> dict[str, Any]: # Decode path and parse query string path = request.target.decode('ISO-8859-1') if '?' in path: @@ -76,8 +78,8 @@ def _build_environ(self, request: h11.Request, body: io.RawIOBase) -> dict[str, 'QUERY_STRING': query_string, 'CONTENT_TYPE': headers_dict.get('content-type', ''), 'CONTENT_LENGTH': headers_dict.get('content-length', ''), - 'SERVER_NAME': self.config.host, # TODO Actual name - 'SERVER_PORT': str(self.server.port), + 'SERVER_NAME': client.config.host, + 'SERVER_PORT': str(client.server.port), 'SERVER_PROTOCOL': f'HTTP/{request.http_version.decode("ascii")}', 'wsgi.version': (1, 0), 'wsgi.url_scheme': 'http', @@ -101,18 +103,23 @@ def _build_environ(self, request: h11.Request, body: io.RawIOBase) -> dict[str, def _main_flask(): logging.basicConfig(level=logging.DEBUG) - from flask import Flask, request + from flask import Flask, request as flask_request + + from localpost.http.config import ServerConfig + from localpost.http.server import start_http_server app = Flask(__name__) @app.route('/hello/') def hello(name): - user_agent = request.headers.get('User-Agent', 'Unknown') + user_agent = flask_request.headers.get('User-Agent', 'Unknown') return f'Hello, {name}! Your User-Agent is: {user_agent}\n' + handler = wrap_wsgi(app) with start_http_server(ServerConfig()) as server: for client_conn in server: - client_conn(app) + client_conn(handler) + if __name__ == '__main__': _main_flask() From 980e583667c2e0fb66adab2ecbbd2dd7378b843d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 6 Feb 2026 14:52:29 +0000 Subject: [PATCH 011/286] WIP --- localpost/_sync_utils.py | 9 +- localpost/http/server.py | 179 ++++++++++++++++++++++++--------------- 2 files changed, 114 insertions(+), 74 deletions(-) diff --git a/localpost/_sync_utils.py b/localpost/_sync_utils.py index d7e7fcc..888eee6 100644 --- a/localpost/_sync_utils.py +++ b/localpost/_sync_utils.py @@ -4,6 +4,7 @@ import threading from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass import anyio from anyio import from_thread @@ -17,14 +18,6 @@ def check_cancelled() -> None: from_thread.check_cancelled() -def _sock_op[**P, T](op: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: - """Perform a socket operation with automatic retry on timeout.""" - while True: - check_cancelled() - with suppress(socket.timeout): - return op(*args, **kwargs) - - def _acquire(sem: threading.Semaphore): while True: check_cancelled() diff --git a/localpost/http/server.py b/localpost/http/server.py index 92b63d0..5938f47 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -14,12 +14,13 @@ import time from collections.abc import Iterator, Callable, Iterable, Generator from contextlib import contextmanager, suppress, closing +from dataclasses import dataclass from io import DEFAULT_BUFFER_SIZE, RawIOBase from typing import final, Literal import h11 -from localpost._sync_utils import _sock_op, CHECK_TIMEOUT, check_cancelled +from localpost._sync_utils import CHECK_TIMEOUT, check_cancelled from localpost.http.config import ServerConfig, LOGGER_NAME __all__ = ['Server', 'ClientConn', 'RequestHandler', 'StartResponse', 'start_http_server'] @@ -61,32 +62,41 @@ def __init__( Can be useful when port 0 is specified to auto-assign a free port. """ self._logger = logger - self._closed = False + self._running = False + + @property + def running(self) -> bool: + return self._running def shutdown(self) -> None: """Stop accepting new connections and close the server socket.""" - if self._closed: + if not self._running: return - self._closed = True self._socket.close() # Safe to call if from another thread, will cause accept() to raise OSError def __iter__(self) -> Iterator[ClientConn]: - while not self._closed: + self._running = True + while True: try: - client_sock, client_addr = _sock_op(self._socket.accept) - yield ClientConn(self, client_sock, client_addr, self._logger) + check_cancelled() + client_sock, client_addr = self._socket.accept() + cs = ClientSocket(self, client_sock, client_addr, self.config.rw_timeout) + yield ClientConn(self, cs, self._logger) + except TimeoutError: + pass except OSError: - if self._closed: + if self._running: + self._running = False return # Socket was closed, exit gracefully raise # Unexpected error @final +@dataclass(slots=True) class RequestBodyStream(RawIOBase): - def __init__(self, conn: h11.Connection, sock: socket.socket) -> None: - self._conn = conn - self._sock = sock - self._finished = False + _conn: h11.Connection + _sock: ClientSocket + _finished: bool = False def writable(self): return False @@ -127,10 +137,7 @@ def receive_chunk(self, size: int, /) -> bytes: raise EOFError("End of request body stream") event = conn.next_event() if event is h11.NEED_DATA: - data = _sock_op(sock.recv, size) - if not data: - raise ConnectionAbortedError("Client closed connection unexpectedly") - conn.receive_data(data) + conn.receive_data(sock.recv(size)) elif isinstance(event, h11.Data): return event.data elif isinstance(event, h11.EndOfMessage): @@ -145,65 +152,118 @@ def drain(self) -> None: self.receive_chunk(DEFAULT_BUFFER_SIZE) +class ServerShutdown(Exception): + pass + + +@dataclass(slots=True) +class ClientSocket: + server: Server + sock: socket.socket + addr: tuple[str, int] + timeout: float = CHECK_TIMEOUT * 5 + """Timeout for receive/send operations""" + _recv_buf: bytes | None = None + + def __post_init__(self): + self.sock.settimeout(CHECK_TIMEOUT) + + def wait_for_req(self, timeout: float) -> None: + for _ in range(int(timeout / CHECK_TIMEOUT)): + check_cancelled() + if not self.server.running: + raise ServerShutdown() + with suppress(TimeoutError): + self._recv_buf = self.sock.recv(DEFAULT_BUFFER_SIZE) + return + raise TimeoutError() # TODO Message + + def recv(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + buf = self._recv_buf + if buf is not None: + self._recv_buf = None + else: + for _ in range(int(self.timeout / CHECK_TIMEOUT)): + check_cancelled() + with suppress(TimeoutError): + buf = self.sock.recv(size) + if buf is None: + raise TimeoutError() # TODO Message + elif buf == b"": + raise ConnectionAbortedError("Client closed connection unexpectedly") + return buf + + def sendall(self, buf, /) -> None: + for _ in range(int(self.timeout / CHECK_TIMEOUT)): + check_cancelled() + with suppress(TimeoutError): + return self.sock.sendall(buf) + raise TimeoutError() # TODO Message + + def close(self) -> None: + self.sock.close() + + @final class ClientConn: def __init__( self, server: Server, - client_sock: socket.socket, - client_addr: tuple[str, int], + client_sock: ClientSocket, logger: logging.Logger, ) -> None: self.server = server self.config = server.config self.socket = client_sock - self.address = client_addr self.conn = h11.Connection(h11.SERVER) self._logger = logger - self.state: Literal['active', 'idle', 'closed'] = 'idle' - self.last_active_at: float = time.monotonic() + + @property + def state(self) -> Literal['active', 'idle', 'closed']: + if self.conn.our_state is h11.IDLE: + return 'idle' + elif self.conn.our_state is h11.MUST_CLOSE: + return 'closed' + return 'active' def __call__(self, h: RequestHandler) -> None: # TODO Assert it's not called concurrently + conn, sock = self.conn, self.socket - with self.socket: - self.socket.settimeout(self.config.rw_timeout) - while True: - rb = RequestBodyStream(self.conn, self.socket) - keep_alive = self._handle_request(h, rb) - if not keep_alive: + try: + # Only read until we get the Request headers - body is read lazily + while self.server.running: + if conn.our_state is h11.MUST_CLOSE: return - rb.drain() - # Prepare for next request on this client's connection - self.conn.start_next_cycle() - # TODO Proper keep-alive timeout + elif conn.our_state is h11.DONE: + sock.wait_for_req(self.config.keep_alive_timeout) + event = conn.next_event() + if event is h11.NEED_DATA: + conn.receive_data(sock.recv()) + elif isinstance(event, h11.Request): + body = RequestBodyStream(conn, sock) + self._handle_request(h, event, body) + body.drain() + if conn.our_state is h11.MUST_CLOSE: + return + # conn.start_next_cycle() + elif event == h11.ConnectionClosed: + # TODO Actually close, gracefully + return # Client closed connection + else: + raise RuntimeError(f"Unexpected h11 event: {event!r}") + except ServerShutdown: + return + except TimeoutError | ConnectionAbortedError as exc: + self._logger.debug(exc.message) + finally: + sock.close() - def _handle_request(self, h: RequestHandler, body: RequestBodyStream) -> bool: + def _handle_request(self, h: RequestHandler, request: h11.Request, body: RequestBodyStream) -> None: conn, sock = self.conn, self.socket - request: h11.Request | None = None - - # Only read until we get the Request headers - body is read lazily - while request is None: - event = conn.next_event() - if event is h11.NEED_DATA: - try: - data = sock.recv(DEFAULT_BUFFER_SIZE) - except TimeoutError: - return False # Idle timeout, close connection - if not data: - return False # Client closed connection - conn.receive_data(data) - elif isinstance(event, h11.Request): - request = event - - keep_alive: bool = False def start_response(response: h11.Response) -> None: check_cancelled() - - nonlocal keep_alive - keep_alive = _should_keep_alive(request, response) - # Add Connection header if not present?.. sock.sendall(conn.send(response)) @@ -215,24 +275,11 @@ def start_response(response: h11.Response) -> None: sock.sendall(conn.send(h11.EndOfMessage())) - return keep_alive - StartResponse = Callable[[h11.Response], None] RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase, StartResponse], Generator[h11.Data]] -def _should_keep_alive(request: h11.Request, response: h11.Response) -> bool: - """Determine if the connection should be kept alive.""" - # Check if either request or response has an explicit Connection header - for name, value in itertools.chain(response.headers, request.headers): - if name.lower() == b'connection': - return value.lower() == 'keep-alive' - - # HTTP/1.1 defaults to keep-alive - return request.http_version == b'1.1' - - def _main(): logging.basicConfig(level=logging.DEBUG) From f3b1be298c880a3f929c83b117d215e7cca44794 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 6 Feb 2026 17:28:42 +0000 Subject: [PATCH 012/286] WIP --- localpost/http/config.py | 4 +-- localpost/http/server.py | 61 +++++++++++++++------------------------- 2 files changed, 25 insertions(+), 40 deletions(-) diff --git a/localpost/http/config.py b/localpost/http/config.py index 1b7aa23..c8cfbfd 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -21,9 +21,9 @@ class ServerConfig: port: int = 8000 backlog: int = 16 """Maximum number of queued connections.""" - rw_timeout: float = CHECK_TIMEOUT + rw_timeout: float = 5.0 """Timeout (seconds) for read/write operations.""" - keep_alive_timeout: float = 5.0 + keep_alive_timeout: float = 15.0 """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB """Maximum request body size (bytes).""" diff --git a/localpost/http/server.py b/localpost/http/server.py index 5938f47..771aee9 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -152,10 +152,6 @@ def drain(self) -> None: self.receive_chunk(DEFAULT_BUFFER_SIZE) -class ServerShutdown(Exception): - pass - - @dataclass(slots=True) class ClientSocket: server: Server @@ -168,37 +164,32 @@ class ClientSocket: def __post_init__(self): self.sock.settimeout(CHECK_TIMEOUT) - def wait_for_req(self, timeout: float) -> None: + def wait_for_req(self, timeout: float) -> bool: for _ in range(int(timeout / CHECK_TIMEOUT)): check_cancelled() if not self.server.running: - raise ServerShutdown() + return False with suppress(TimeoutError): self._recv_buf = self.sock.recv(DEFAULT_BUFFER_SIZE) - return - raise TimeoutError() # TODO Message + return True + return False def recv(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - buf = self._recv_buf - if buf is not None: + if (buf := self._recv_buf) is not None: self._recv_buf = None - else: - for _ in range(int(self.timeout / CHECK_TIMEOUT)): - check_cancelled() - with suppress(TimeoutError): - buf = self.sock.recv(size) - if buf is None: - raise TimeoutError() # TODO Message - elif buf == b"": - raise ConnectionAbortedError("Client closed connection unexpectedly") - return buf + return buf + for _ in range(int(self.timeout / CHECK_TIMEOUT)): + check_cancelled() + with suppress(TimeoutError): + return self.sock.recv(size) + raise TimeoutError("receive timeout") def sendall(self, buf, /) -> None: for _ in range(int(self.timeout / CHECK_TIMEOUT)): check_cancelled() with suppress(TimeoutError): return self.sock.sendall(buf) - raise TimeoutError() # TODO Message + raise TimeoutError("send timeout") def close(self) -> None: self.sock.close() @@ -227,35 +218,29 @@ def state(self) -> Literal['active', 'idle', 'closed']: return 'active' def __call__(self, h: RequestHandler) -> None: - # TODO Assert it's not called concurrently conn, sock = self.conn, self.socket - try: - # Only read until we get the Request headers - body is read lazily - while self.server.running: - if conn.our_state is h11.MUST_CLOSE: - return - elif conn.our_state is h11.DONE: - sock.wait_for_req(self.config.keep_alive_timeout) + while True: event = conn.next_event() if event is h11.NEED_DATA: conn.receive_data(sock.recv()) elif isinstance(event, h11.Request): body = RequestBodyStream(conn, sock) self._handle_request(h, event, body) - body.drain() - if conn.our_state is h11.MUST_CLOSE: + if conn.our_state is h11.MUST_CLOSE: # IDLE | SEND_RESPONSE | DONE | MUST_CLOSE return - # conn.start_next_cycle() - elif event == h11.ConnectionClosed: - # TODO Actually close, gracefully + body.drain() + elif isinstance(event, h11.ConnectionClosed): return # Client closed connection + # elif isinstance(event, h11.EndOfMessage): + elif event is h11.PAUSED: + conn.start_next_cycle() + # if not sock.wait_for_req(self.config.keep_alive_timeout): + # break else: raise RuntimeError(f"Unexpected h11 event: {event!r}") - except ServerShutdown: - return - except TimeoutError | ConnectionAbortedError as exc: - self._logger.debug(exc.message) + except TimeoutError: + self._logger.debug("Client connection timed out", exc_info=True) finally: sock.close() From 84938888bd388a08aeed71b7c049e2b1f9771815 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 7 Feb 2026 09:05:29 +0000 Subject: [PATCH 013/286] WIP (working fine) --- localpost/http/config.py | 2 +- localpost/http/server.py | 106 ++++++++------------------------------- 2 files changed, 22 insertions(+), 86 deletions(-) diff --git a/localpost/http/config.py b/localpost/http/config.py index c8cfbfd..5c25bd3 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -21,7 +21,7 @@ class ServerConfig: port: int = 8000 backlog: int = 16 """Maximum number of queued connections.""" - rw_timeout: float = 5.0 + rw_timeout: float = 3.0 """Timeout (seconds) for read/write operations.""" keep_alive_timeout: float = 15.0 """Timeout (seconds) for idle connections.""" diff --git a/localpost/http/server.py b/localpost/http/server.py index 771aee9..cc36e56 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -8,15 +8,13 @@ from __future__ import annotations -import itertools import logging import socket -import time -from collections.abc import Iterator, Callable, Iterable, Generator +from collections.abc import Iterator, Callable, Generator from contextlib import contextmanager, suppress, closing from dataclasses import dataclass from io import DEFAULT_BUFFER_SIZE, RawIOBase -from typing import final, Literal +from typing import final import h11 @@ -91,67 +89,6 @@ def __iter__(self) -> Iterator[ClientConn]: raise # Unexpected error -@final -@dataclass(slots=True) -class RequestBodyStream(RawIOBase): - _conn: h11.Connection - _sock: ClientSocket - _finished: bool = False - - def writable(self): - return False - - def seekable(self): - return False - - def readable(self) -> bool: - return True - - def readall(self): - chunks = bytearray() - for chunk in self.receive_chunks(): - chunks.extend(chunk) - return chunks - - def readinto(self, b: bytearray, /) -> int: - try: - data = self.receive_chunk(len(b)) - size = len(data) - b[:size] = data - return size - except EOFError: - return 0 - - def receive_chunks(self) -> Iterable[bytes]: - with suppress(EOFError): - while True: - yield self.receive_chunk(DEFAULT_BUFFER_SIZE) - - def receive_chunk(self, size: int, /) -> bytes: - """Receive next chunk of body data from the socket via h11.""" - conn, sock = self._conn, self._sock - while True: - if self.closed: - raise ValueError("Read on closed request body stream") - if self._finished: - raise EOFError("End of request body stream") - event = conn.next_event() - if event is h11.NEED_DATA: - conn.receive_data(sock.recv(size)) - elif isinstance(event, h11.Data): - return event.data - elif isinstance(event, h11.EndOfMessage): - self._finished = True - else: - raise RuntimeError(f"Unexpected h11 event: {event!r}") - - def drain(self) -> None: - """Consume any remaining body data. Required before starting next request cycle.""" - with suppress(EOFError): - while not self._finished: - self.receive_chunk(DEFAULT_BUFFER_SIZE) - - @dataclass(slots=True) class ClientSocket: server: Server @@ -209,34 +146,33 @@ def __init__( self.conn = h11.Connection(h11.SERVER) self._logger = logger - @property - def state(self) -> Literal['active', 'idle', 'closed']: - if self.conn.our_state is h11.IDLE: - return 'idle' - elif self.conn.our_state is h11.MUST_CLOSE: - return 'closed' - return 'active' - def __call__(self, h: RequestHandler) -> None: conn, sock = self.conn, self.socket try: + request: h11.Request | None = None + body: bytearray = bytearray() while True: event = conn.next_event() if event is h11.NEED_DATA: conn.receive_data(sock.recv()) + elif isinstance(event, h11.Data): + body.extend(event.data) + # TODO Max body size check (by the header?..) elif isinstance(event, h11.Request): - body = RequestBodyStream(conn, sock) - self._handle_request(h, event, body) - if conn.our_state is h11.MUST_CLOSE: # IDLE | SEND_RESPONSE | DONE | MUST_CLOSE - return - body.drain() - elif isinstance(event, h11.ConnectionClosed): - return # Client closed connection - # elif isinstance(event, h11.EndOfMessage): - elif event is h11.PAUSED: + request = event + elif isinstance(event, h11.ConnectionClosed): # Client closed connection + self._logger.debug("Client connection closed unexpectedly") + return + elif isinstance(event, h11.EndOfMessage): + self._handle_request(h, request, body) + request = None + body.clear() conn.start_next_cycle() - # if not sock.wait_for_req(self.config.keep_alive_timeout): - # break + if not sock.wait_for_req(self.config.keep_alive_timeout): + if self.server.running: + self._logger.debug("Closing idle client connection (keep-alive timeout: %s seconds)", + self.config.keep_alive_timeout) + return else: raise RuntimeError(f"Unexpected h11 event: {event!r}") except TimeoutError: @@ -244,7 +180,7 @@ def __call__(self, h: RequestHandler) -> None: finally: sock.close() - def _handle_request(self, h: RequestHandler, request: h11.Request, body: RequestBodyStream) -> None: + def _handle_request(self, h: RequestHandler, request: h11.Request, body: bytearray) -> None: conn, sock = self.conn, self.socket def start_response(response: h11.Response) -> None: From 5a1fb9c49f8f079dbd85c12d77c191b1cb021cfb Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 7 Feb 2026 17:16:53 +0000 Subject: [PATCH 014/286] WIP (small and flexible) --- localpost/http/config.py | 10 ++-- localpost/http/server.py | 121 +++++++++++++++++++++++++++------------ 2 files changed, 88 insertions(+), 43 deletions(-) diff --git a/localpost/http/config.py b/localpost/http/config.py index 5c25bd3..9d2ca24 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -22,7 +22,7 @@ class ServerConfig: backlog: int = 16 """Maximum number of queued connections.""" rw_timeout: float = 3.0 - """Timeout (seconds) for read/write operations.""" + """Timeout (seconds) for receive/send operations on a client connection.""" keep_alive_timeout: float = 15.0 """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB @@ -33,7 +33,7 @@ class ServerConfig: @dataclass(frozen=True, slots=True) class WorkerConfig: server: ServerConfig = field(default_factory=ServerConfig) - max_connections: int = 10 - """Max connections = max concurrent requests.""" - max_idle_connections: int = 5 - """Maximum number of idle (keep-alive) connections (<= max_connections).""" + max_connections: int = 100 + """Max open connections (including idle).""" + max_requests: int = 5 + """Max parallel requests.""" diff --git a/localpost/http/server.py b/localpost/http/server.py index cc36e56..3a0d855 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -10,7 +10,7 @@ import logging import socket -from collections.abc import Iterator, Callable, Generator +from collections.abc import Iterator, Callable, Iterable from contextlib import contextmanager, suppress, closing from dataclasses import dataclass from io import DEFAULT_BUFFER_SIZE, RawIOBase @@ -21,7 +21,7 @@ from localpost._sync_utils import CHECK_TIMEOUT, check_cancelled from localpost.http.config import ServerConfig, LOGGER_NAME -__all__ = ['Server', 'ClientConn', 'RequestHandler', 'StartResponse', 'start_http_server'] +__all__ = ['Server', 'ClientConn', 'RequestHandler', 'start_http_server'] @contextmanager @@ -94,8 +94,8 @@ class ClientSocket: server: Server sock: socket.socket addr: tuple[str, int] - timeout: float = CHECK_TIMEOUT * 5 - """Timeout for receive/send operations""" + timeout: float + """Timeout for receive/send operations.""" _recv_buf: bytes | None = None def __post_init__(self): @@ -149,64 +149,109 @@ def __init__( def __call__(self, h: RequestHandler) -> None: conn, sock = self.conn, self.socket try: - request: h11.Request | None = None - body: bytearray = bytearray() - while True: + while conn.our_state is not h11.MUST_CLOSE: + if (prev_state := conn.our_state) is h11.DONE: + conn.start_next_cycle() event = conn.next_event() if event is h11.NEED_DATA: + if prev_state is h11.DONE: + if not sock.wait_for_req(self.config.keep_alive_timeout): + if self.server.running: + self._logger.debug("Closing idle client connection (keep-alive timeout: %s seconds)", + self.config.keep_alive_timeout) + return conn.receive_data(sock.recv()) - elif isinstance(event, h11.Data): - body.extend(event.data) - # TODO Max body size check (by the header?..) elif isinstance(event, h11.Request): - request = event - elif isinstance(event, h11.ConnectionClosed): # Client closed connection - self._logger.debug("Client connection closed unexpectedly") + self._handle_request(h, event) + elif isinstance(event, h11.ConnectionClosed): + self._logger.debug("Client closed connection") return - elif isinstance(event, h11.EndOfMessage): - self._handle_request(h, request, body) - request = None - body.clear() - conn.start_next_cycle() - if not sock.wait_for_req(self.config.keep_alive_timeout): - if self.server.running: - self._logger.debug("Closing idle client connection (keep-alive timeout: %s seconds)", - self.config.keep_alive_timeout) - return - else: - raise RuntimeError(f"Unexpected h11 event: {event!r}") + else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) + raise RuntimeError(f"Unexpected {event!r} in the connection loop") except TimeoutError: self._logger.debug("Client connection timed out", exc_info=True) finally: sock.close() - def _handle_request(self, h: RequestHandler, request: h11.Request, body: bytearray) -> None: + def _handle_request(self, h: RequestHandler, request: h11.Request) -> None: conn, sock = self.conn, self.socket - - def start_response(response: h11.Response) -> None: - check_cancelled() - # Add Connection header if not present?.. + body = RequestBodyStream(conn, sock) + response, response_chunks = h(self, request, body) + with closing(response_chunks) if hasattr(response_chunks, 'close') else suppress(): # noqa sock.sendall(conn.send(response)) - - with closing(h(self, request, body, start_response)) as response_chunks: check_cancelled() for chunk in response_chunks: check_cancelled() sock.sendall(conn.send(chunk)) + sock.sendall(conn.send(h11.EndOfMessage())) + body.drain() + + +@dataclass(slots=True) +class RequestBodyStream(RawIOBase): + conn: h11.Connection + sock: ClientSocket + finished: bool = False + + def writable(self): + return False + + def seekable(self): + return False + + def readable(self): + return True + + def readall(self): + chunks = bytearray() + with suppress(EOFError): + while True: + chunks.extend(self._receive(DEFAULT_BUFFER_SIZE)) + return chunks + + def readinto(self, b: bytearray, /) -> int: + try: + data = self._receive(len(b)) + size = len(data) + b[:size] = data + return size + except EOFError: + return 0 + + def _receive(self, size: int, /) -> bytes: + """Receive next chunk of body data from the socket via h11.""" + if self.finished: + raise EOFError() + conn, sock = self.conn, self.sock + while True: + event = conn.next_event() + if event is h11.NEED_DATA: + conn.receive_data(sock.recv(size)) + elif isinstance(event, h11.Data): + return event.data + elif isinstance(event, h11.EndOfMessage): + self.finished = True + raise EOFError() + elif isinstance(event, h11.ConnectionClosed): + raise ConnectionAbortedError("Client closed connection unexpectedly") + else: + raise RuntimeError(f"Unexpected h11 event: {event!r}") - sock.sendall(conn.send(h11.EndOfMessage())) + def drain(self) -> None: + """Consume any remaining body data. Required before starting next request cycle.""" + with suppress(EOFError): + while not self.finished: + self._receive(DEFAULT_BUFFER_SIZE) -StartResponse = Callable[[h11.Response], None] -RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase, StartResponse], Generator[h11.Data]] +RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase], tuple[h11.Response, Iterable[h11.Data]]] def _main(): logging.basicConfig(level=logging.DEBUG) - def simple_app(c: ClientConn, r: h11.Request, rb: RawIOBase, start_response: StartResponse): - start_response(h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')])) - yield h11.Data(data=b'Hello, World!\n') + def simple_app(c: ClientConn, r: h11.Request, rb: RawIOBase): + return h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')]), [h11.Data(b'Hello, World!\n')] with start_http_server(ServerConfig()) as server: for client_conn in server: From f6913cde1dcda4bec730e891ba97d1a7c57d0d17 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 7 Feb 2026 18:11:11 +0000 Subject: [PATCH 015/286] fix: WSGI middleware --- localpost/http/server.py | 16 ++++++---- localpost/http/server_manager.py | 11 ------- localpost/http/server_wsgi.py | 55 +++++++++++++++++++------------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index 3a0d855..974d505 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -11,7 +11,7 @@ import logging import socket from collections.abc import Iterator, Callable, Iterable -from contextlib import contextmanager, suppress, closing +from contextlib import contextmanager, suppress, AbstractContextManager, nullcontext from dataclasses import dataclass from io import DEFAULT_BUFFER_SIZE, RawIOBase from typing import final @@ -177,10 +177,10 @@ def _handle_request(self, h: RequestHandler, request: h11.Request) -> None: conn, sock = self.conn, self.socket body = RequestBodyStream(conn, sock) response, response_chunks = h(self, request, body) - with closing(response_chunks) if hasattr(response_chunks, 'close') else suppress(): # noqa + with response_chunks as chunks: sock.sendall(conn.send(response)) check_cancelled() - for chunk in response_chunks: + for chunk in chunks: check_cancelled() sock.sendall(conn.send(chunk)) sock.sendall(conn.send(h11.EndOfMessage())) @@ -205,7 +205,7 @@ def readable(self): def readall(self): chunks = bytearray() with suppress(EOFError): - while True: + while not self.finished: chunks.extend(self._receive(DEFAULT_BUFFER_SIZE)) return chunks @@ -244,14 +244,18 @@ def drain(self) -> None: self._receive(DEFAULT_BUFFER_SIZE) -RequestHandler = Callable[[ClientConn, h11.Request, RawIOBase], tuple[h11.Response, Iterable[h11.Data]]] +RequestHandler = Callable[ + [ClientConn, h11.Request, RawIOBase], + tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]] +] def _main(): logging.basicConfig(level=logging.DEBUG) def simple_app(c: ClientConn, r: h11.Request, rb: RawIOBase): - return h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')]), [h11.Data(b'Hello, World!\n')] + return (h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')]), + nullcontext([h11.Data(b'Hello, World!\n')])) with start_http_server(ServerConfig()) as server: for client_conn in server: diff --git a/localpost/http/server_manager.py b/localpost/http/server_manager.py index 916b0cb..c41cf6e 100644 --- a/localpost/http/server_manager.py +++ b/localpost/http/server_manager.py @@ -19,17 +19,6 @@ __all__ = ['Worker', 'serve'] -class AbstractSyncWorker[T]: - def __init__(self, server: Iterable[T], max_concurrency: int) -> None: - self.server = server - self.max_concurrency = max_concurrency - - def shutdown(self) -> None: - """Graceful shutdown (stop handling new connections, wait for in-flight requests).""" - self.server.shutdown() - - - @asynccontextmanager async def serve(app: WSGIApplication, config: WorkerConfig, /): """Run multiple servers (workers).""" diff --git a/localpost/http/server_wsgi.py b/localpost/http/server_wsgi.py index 85673b1..525ee09 100644 --- a/localpost/http/server_wsgi.py +++ b/localpost/http/server_wsgi.py @@ -2,55 +2,56 @@ import logging import sys -from collections.abc import Callable +from collections.abc import Callable, Iterable +from contextlib import closing, AbstractContextManager, suppress from io import BufferedReader, RawIOBase from typing import Any from wsgiref.types import WSGIApplication import h11 +from flask import stream_with_context -from localpost.http.server import ClientConn, RequestHandler, StartResponse +from localpost.http.server import ClientConn, RequestHandler def wrap_wsgi(app: WSGIApplication) -> RequestHandler: """Wrap a WSGI application as a RequestHandler.""" - def handler(client: ClientConn, request: h11.Request, body: RawIOBase, start_response: StartResponse): + def handler( + client: ClientConn, request: h11.Request, body: RawIOBase + ) -> tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]]: environ = _build_environ(client, request, body) + response: h11.Response | None = None def wsgi_start_response( status: str, headers: list[tuple[str, str]], exc_info: Any = None, ) -> Callable[[bytes], None]: + nonlocal response if exc_info: try: raise exc_info[1].with_traceback(exc_info[2]) finally: - exc_info = None - - # Parse status code from "200 OK" format - status_code = int(status.split(' ', 1)[0]) - - # Convert headers to h11 format (bytes) - h11_headers = [ + exc_info = None # Avoid circular reference + status_code = int(status.split(' ', 1)[0]) # Parse from "200 OK" format + response = h11.Response(status_code=status_code, headers=[ (name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) for name, value in headers - ] - - response = h11.Response(status_code=status_code, headers=h11_headers) - start_response(response) - + ]) return _wsgi_response_write - response_body = app(environ, wsgi_start_response) - try: - for chunk in response_body: - if chunk: - yield h11.Data(data=chunk) - finally: - if hasattr(response_body, 'close'): - response_body.close() + def wsgi_run(): + chunks = app(environ, wsgi_start_response) + yield # Skip the first iteration, to call the app and set the response object + with closing(chunks) if hasattr(chunks, 'close') else suppress(): # type: ignore + for chunk in chunks: + yield h11.Data(chunk) + + response_body = wsgi_run() + next(response_body) + assert response, "WSGI app did not call start_response" + return response, closing(response_body) return handler @@ -115,6 +116,14 @@ def hello(name): user_agent = flask_request.headers.get('User-Agent', 'Unknown') return f'Hello, {name}! Your User-Agent is: {user_agent}\n' + + @app.route('/hello-stream/') + @stream_with_context + def hello_stream(name): + user_agent = flask_request.headers.get('User-Agent', 'Unknown') + yield f'Hello, {name}! ' + yield f'Your User-Agent is: {user_agent}\n' + handler = wrap_wsgi(app) with start_http_server(ServerConfig()) as server: for client_conn in server: From 1facdfdf2c1282d6a208433d61573647cf5d272b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 7 Feb 2026 20:31:37 +0000 Subject: [PATCH 016/286] WIP --- localpost/_sync_utils.py | 3 --- localpost/http/server_manager.py | 27 +++++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/localpost/_sync_utils.py b/localpost/_sync_utils.py index 888eee6..324c05d 100644 --- a/localpost/_sync_utils.py +++ b/localpost/_sync_utils.py @@ -1,10 +1,7 @@ from __future__ import annotations -import socket import threading -from collections.abc import Callable from contextlib import suppress -from dataclasses import dataclass import anyio from anyio import from_thread diff --git a/localpost/http/server_manager.py b/localpost/http/server_manager.py index c41cf6e..ac0f178 100644 --- a/localpost/http/server_manager.py +++ b/localpost/http/server_manager.py @@ -3,35 +3,38 @@ import logging import signal import threading -from collections.abc import Awaitable, Iterable +from collections.abc import Awaitable from contextlib import asynccontextmanager from dataclasses import dataclass from typing import final from wsgiref.types import WSGIApplication import anyio -from anyio import to_thread, CancelScope, from_thread, create_task_group +from anyio import CancelScope, create_task_group, from_thread, to_thread from localpost._sync_utils import _acquire + from .config import WorkerConfig -from .server import start_http_server, ClientConn, Server +from .server import Server, start_http_server +from .server_wsgi import wrap_wsgi -__all__ = ['Worker', 'serve'] +__all__ = ["Worker", "serve"] @asynccontextmanager async def serve(app: WSGIApplication, config: WorkerConfig, /): """Run multiple servers (workers).""" + handler = wrap_wsgi(app) threads_limiter = anyio.CapacityLimiter(config.max_connections) conn_sem = threading.BoundedSemaphore(config.max_connections) - def handle_client(c: ClientConn) -> None: + def handle_client(c) -> None: try: - c() + c(handler) finally: conn_sem.release() - def handle_client_thread(c: ClientConn) -> Awaitable[None]: + def handle_client_thread(c) -> Awaitable[None]: return to_thread.run_sync(handle_client, c, limiter=threads_limiter) def handle_clients_thread() -> Awaitable[None]: @@ -40,12 +43,12 @@ def handle_clients_thread() -> Awaitable[None]: def handle_clients() -> None: _acquire(conn_sem) for client_conn in server: - # Process the client in a separate thread, to support multiple concurrent connections + # Handle each client connection in a separate thread from_thread.run_sync(tg.start_soon, handle_client_thread, client_conn) _acquire(conn_sem) async with create_task_group() as tg: - with start_http_server(app, config.server) as server: + with start_http_server(config.server) as server: tg.start_soon(handle_clients_thread) yield Worker(server, config, tg.cancel_scope) @@ -66,8 +69,8 @@ def _sample_usage(): logging.basicConfig(level=logging.DEBUG) def simple_app(_, start_response): - start_response('200 OK', [('Content-Type', 'text/plain')]) - return [f'Hello from worker thread {threading.get_ident()}!\n'.encode('utf-8')] + start_response("200 OK", [("Content-Type", "text/plain")]) + return [f"Hello from worker thread {threading.get_ident()}!\n".encode()] async def _run(): async with serve(simple_app, WorkerConfig()) as w: @@ -80,5 +83,5 @@ async def _run(): anyio.run(_run) -if __name__ == '__main__': +if __name__ == "__main__": _sample_usage() From 2de6131ae4b77f64833746856b75132714919f80 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 8 Feb 2026 14:35:53 +0000 Subject: [PATCH 017/286] WIP --- localpost/http/{main.py => cli.py} | 0 .../http/{server_manager.py => worker.py} | 6 +- localpost/http/{server_wsgi.py => wsgi.py} | 0 pyproject.toml | 2 + uv.lock | 257 +++++++++++------- 5 files changed, 165 insertions(+), 100 deletions(-) rename localpost/http/{main.py => cli.py} (100%) rename localpost/http/{server_manager.py => worker.py} (94%) rename localpost/http/{server_wsgi.py => wsgi.py} (100%) diff --git a/localpost/http/main.py b/localpost/http/cli.py similarity index 100% rename from localpost/http/main.py rename to localpost/http/cli.py diff --git a/localpost/http/server_manager.py b/localpost/http/worker.py similarity index 94% rename from localpost/http/server_manager.py rename to localpost/http/worker.py index ac0f178..56d8360 100644 --- a/localpost/http/server_manager.py +++ b/localpost/http/worker.py @@ -13,10 +13,9 @@ from anyio import CancelScope, create_task_group, from_thread, to_thread from localpost._sync_utils import _acquire - from .config import WorkerConfig from .server import Server, start_http_server -from .server_wsgi import wrap_wsgi +from .wsgi import wrap_wsgi __all__ = ["Worker", "serve"] @@ -25,8 +24,9 @@ async def serve(app: WSGIApplication, config: WorkerConfig, /): """Run multiple servers (workers).""" handler = wrap_wsgi(app) + # handler = _limit_requests(handler, threading.BoundedSemaphore(config.max_requests)) threads_limiter = anyio.CapacityLimiter(config.max_connections) - conn_sem = threading.BoundedSemaphore(config.max_connections) + conn_sem = threading.BoundedSemaphore(config.max_connections) # TODO AnyIO one, if the server is async def handle_client(c) -> None: try: diff --git a/localpost/http/server_wsgi.py b/localpost/http/wsgi.py similarity index 100% rename from localpost/http/server_wsgi.py rename to localpost/http/wsgi.py diff --git a/pyproject.toml b/pyproject.toml index 1c72557..ab674c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ azure-servicebus = [ dev = [ "icecream ~=2.1", "structlog ~=25.0", + "trio ~=0.32", ] dev-hosting-http = [ "uvicorn ~=0.30", @@ -103,6 +104,7 @@ dev-consumers = [ ] dev-http = [ "flask ~=3.1", + "a2wsgi", ] dev-sentry = [ "sentry-sdk ~=2.51", diff --git a/uv.lock b/uv.lock index fcd795a..81e6880 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "a2wsgi" +version = "1.10.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/cb/822c56fbea97e9eee201a2e434a80437f6750ebcb1ed307ee3a0a7505b14/a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45", size = 18799, upload-time = "2025-06-18T09:00:10.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/d5/349aba3dc421e73cbd4958c0ce0a4f1aa3a738bc0d7de75d2f40ed43a535/a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d", size = 17389, upload-time = "2025-06-18T09:00:09.676Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -58,14 +67,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]] @@ -150,30 +159,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.42" +version = "1.42.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/ac/9ef2cc431e00ecbf1ac197938ddea120e75b516b4c57028a1fd517d3e882/boto3-1.42.42.tar.gz", hash = "sha256:8c2537156f5ccd72bbbfe4fc27a8a80bf3e4f80523f306417f3fb6023d13edda", size = 112831, upload-time = "2026-02-04T20:28:45.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/88/de5c2a0ce069973345f9fac81200de5b58f503e231dbd566357a5b8c9109/boto3-1.42.44.tar.gz", hash = "sha256:d5601ea520d30674c1d15791a1f98b5c055e973c775e1d9952ccc09ee5913c4e", size = 112865, upload-time = "2026-02-06T20:28:05.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/0a/3966f239e1d9da93cb755dc0213835ce4e9ed93645192878d0a055ecdc31/boto3-1.42.42-py3-none-any.whl", hash = "sha256:8c78169ef47dc29863ebb11ba99134b1b418d3dfdd836419830f22552f8afe43", size = 140599, upload-time = "2026-02-04T20:28:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/0341da1482f7fa256d257cfba89383f6692570b741598d4e26d879b26c57/boto3-1.42.44-py3-none-any.whl", hash = "sha256:32e995b0d56e19422cff22f586f698e8924c792eb00943de9c517ff4607e4e18", size = 140604, upload-time = "2026-02-06T20:28:03.598Z" }, ] [[package]] name = "botocore" -version = "1.42.42" +version = "1.42.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/96/4eca9755ca444402c46c73cc8ff252c8eb73ab0ccf35ca76d89e7b7820ac/botocore-1.42.42.tar.gz", hash = "sha256:cb75639f5ba7bf73b83ac18bcd87f07b7f484f302748da974dad2801a83a1d60", size = 14926585, upload-time = "2026-02-04T20:28:33.66Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/ff/54cef2c5ff4e1c77fabc0ed68781e48eb36f33433f82bba3605e9c0e45ce/botocore-1.42.44.tar.gz", hash = "sha256:47ba27360f2afd2c2721545d8909217f7be05fdee16dd8fc0b09589535a0701c", size = 14936071, upload-time = "2026-02-06T20:27:53.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/51/aac7e419521d5519e13087a7198623655648c939822bd7f4bdc9ccbe07f9/botocore-1.42.42-py3-none-any.whl", hash = "sha256:1c9df5fc31e9073a9aa956271c4007d72f5d342cafca5f4154ea099bc6f83085", size = 14600186, upload-time = "2026-02-04T20:28:29.268Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/b45c54abfbb902ff174444a48558f97f9917143bc2e996729220f2631db1/botocore-1.42.44-py3-none-any.whl", hash = "sha256:ba406b9243a20591ee87d53abdb883d46416705cebccb639a7f1c923f9dd82df", size = 14611152, upload-time = "2026-02-06T20:27:49.565Z" }, ] [[package]] @@ -573,17 +582,18 @@ wheels = [ [[package]] name = "fastapi-slim" -version = "0.128.1" +version = "0.128.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/4c/44ef7d6b52eea55056ce7e531ea486872c1d3bbc78adae7fa1aeca3525d9/fastapi_slim-0.128.1.tar.gz", hash = "sha256:f03db3328129cc7ea966bd1f38f55e5df29e0c97798e2824acac5efd711b433f", size = 374376, upload-time = "2026-02-04T17:35:14.288Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/da33b1981428d678d148dd8580b3111ad5b19496796e80a13e947fab8961/fastapi_slim-0.128.5.tar.gz", hash = "sha256:8ff38b5229f830cbf1cf8f91ed379bc4de23f4e9381da2d21aea58c70f79ca8a", size = 374868, upload-time = "2026-02-08T10:22:06.713Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/d7/619a9176e10fc455d1c732523606961937248cd3142ee23934ebc15578fd/fastapi_slim-0.128.1-py3-none-any.whl", hash = "sha256:483aa027976cd0bf9591482e7f0fd6b5a460a233b66f23436dc5f8fa43d3e69a", size = 103866, upload-time = "2026-02-04T17:35:15.595Z" }, + { url = "https://files.pythonhosted.org/packages/42/b5/282bfb38ccd4cb5530b5f7a79ab2f576ebbc72d9e69e7e87b00f55b3b1a5/fastapi_slim-0.128.5-py3-none-any.whl", hash = "sha256:107927348db4dfd383f3fac10e00204ad1a014f8d296ab534d7095903c621b1f", size = 103728, upload-time = "2026-02-08T10:22:05.668Z" }, ] [[package]] @@ -671,7 +681,7 @@ wheels = [ [[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"] }, @@ -684,9 +694,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]] @@ -722,100 +732,100 @@ 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/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" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +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/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" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, ] [[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]] name = "grpcio-tools" -version = "1.76.0" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/77/17d60d636ccd86a0db0eccc24d02967bbc3eea86b9db7324b04507ebaa40/grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88", size = 5390807, upload-time = "2025-10-21T16:26:55.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/ca/a931c1439cabfe305c9afd07e233150cd0565aa062c20d1ee412ed188852/grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a", size = 2546852, upload-time = "2025-10-21T16:25:15.024Z" }, - { url = "https://files.pythonhosted.org/packages/4c/07/935cfbb7dccd602723482a86d43fbd992f91e9867bca0056a1e9f348473e/grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1", size = 5841777, upload-time = "2025-10-21T16:25:17.425Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/8fcb5acebdccb647e0fa3f002576480459f6cf81e79692d7b3c4d6e29605/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b", size = 2594004, upload-time = "2025-10-21T16:25:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ea/64838e8113b7bfd4842b15c815a7354cb63242fdce9d6648d894b5d50897/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3dafa34c2626a6691d103877e8a145f54c34cf6530975f695b396ed2fc5c98f8", size = 2905563, upload-time = "2025-10-21T16:25:21.889Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d6/53798827d821098219e58518b6db52161ce4985620850aa74ce3795da8a7/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30f1d2dda6ece285b3d9084e94f66fa721ebdba14ae76b2bc4c581c8a166535c", size = 2656936, upload-time = "2025-10-21T16:25:24.369Z" }, - { url = "https://files.pythonhosted.org/packages/89/a3/d9c1cefc46a790eec520fe4e70e87279abb01a58b1a3b74cf93f62b824a2/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a889af059dc6dbb82d7b417aa581601316e364fe12eb54c1b8d95311ea50916d", size = 3109811, upload-time = "2025-10-21T16:25:26.711Z" }, - { url = "https://files.pythonhosted.org/packages/50/75/5997752644b73b5d59377d333a51c8a916606df077f5a487853e37dca289/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3f2c3c44c56eb5d479ab178f0174595d0a974c37dade442f05bb73dfec02f31", size = 3658786, upload-time = "2025-10-21T16:25:28.819Z" }, - { url = "https://files.pythonhosted.org/packages/84/47/dcf8380df4bd7931ffba32fc6adc2de635b6569ca27fdec7121733797062/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:479ce02dff684046f909a487d452a83a96b4231f7c70a3b218a075d54e951f56", size = 3325144, upload-time = "2025-10-21T16:25:30.863Z" }, - { url = "https://files.pythonhosted.org/packages/04/88/ea3e5fdb874d8c2d04488e4b9d05056537fba70915593f0c283ac77df188/grpcio_tools-1.76.0-cp312-cp312-win32.whl", hash = "sha256:9ba4bb539936642a44418b38ee6c3e8823c037699e2cb282bd8a44d76a4be833", size = 993523, upload-time = "2025-10-21T16:25:32.594Z" }, - { url = "https://files.pythonhosted.org/packages/de/b1/ce7d59d147675ec191a55816be46bc47a343b5ff07279eef5817c09cc53e/grpcio_tools-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd489016766b05f9ed8a6b6596004b62c57d323f49593eac84add032a6d43f7", size = 1158493, upload-time = "2025-10-21T16:25:34.5Z" }, - { url = "https://files.pythonhosted.org/packages/13/01/b16fe73f129df49811d886dc99d3813a33cf4d1c6e101252b81c895e929f/grpcio_tools-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ff48969f81858397ef33a36b326f2dbe2053a48b254593785707845db73c8f44", size = 2546312, upload-time = "2025-10-21T16:25:37.138Z" }, - { url = "https://files.pythonhosted.org/packages/25/17/2594c5feb76bb0b25bfbf91ec1075b276e1b2325e4bc7ea649a7b5dbf353/grpcio_tools-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa2f030fd0ef17926026ee8e2b700e388d3439155d145c568fa6b32693277613", size = 5839627, upload-time = "2025-10-21T16:25:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/c6/097b1aa26fbf72fb3cdb30138a2788529e4f10d8759de730a83f5c06726e/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bacbf3c54f88c38de8e28f8d9b97c90b76b105fb9ddef05d2c50df01b32b92af", size = 2592817, upload-time = "2025-10-21T16:25:42.301Z" }, - { url = "https://files.pythonhosted.org/packages/03/78/d1d985b48592a674509a85438c1a3d4c36304ddfc99d1b05d27233b51062/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d4e4afe9a0e3c24fad2f1af45f98cf8700b2bfc4d790795756ba035d2ea7bdc", size = 2905186, upload-time = "2025-10-21T16:25:44.395Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/770afbb47f0b5f594b93a7b46a95b892abda5eebe60efb511e96cee52170/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbbd4e1fc5af98001ceef5e780e8c10921d94941c3809238081e73818ef707f1", size = 2656188, upload-time = "2025-10-21T16:25:46.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2b/017c2fcf4c5d3cf00cf7d5ce21eb88521de0d89bdcf26538ad2862ec6d07/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b05efe5a59883ab8292d596657273a60e0c3e4f5a9723c32feb9fc3a06f2f3ef", size = 3109141, upload-time = "2025-10-21T16:25:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5f/2495f88e3d50c6f2c2da2752bad4fa3a30c52ece6c9d8b0c636cd8b1430b/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:be483b90e62b7892eb71fa1fc49750bee5b2ee35b5ec99dd2b32bed4bedb5d71", size = 3657892, upload-time = "2025-10-21T16:25:52.362Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1d/c4f39d31b19d9baf35d900bf3f969ce1c842f63a8560c8003ed2e5474760/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:630cd7fd3e8a63e20703a7ad816979073c2253e591b5422583c27cae2570de73", size = 3324778, upload-time = "2025-10-21T16:25:54.629Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b6/35ee3a6e4af85a93da28428f81f4b29bcb36f6986b486ad71910fcc02e25/grpcio_tools-1.76.0-cp313-cp313-win32.whl", hash = "sha256:eb2567280f9f6da5444043f0e84d8408c7a10df9ba3201026b30e40ef3814736", size = 993084, upload-time = "2025-10-21T16:25:56.52Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7a/5bd72344d86ee860e5920c9a7553cfe3bc7b1fce79f18c00ac2497f5799f/grpcio_tools-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:0071b1c0bd0f5f9d292dca4efab32c92725d418e57f9c60acdc33c0172af8b53", size = 1158151, upload-time = "2025-10-21T16:25:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c0/aa20eebe8f3553b7851643e9c88d237c3a6ca30ade646897e25dbb27be99/grpcio_tools-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:c53c5719ef2a435997755abde3826ba4087174bd432aa721d8fac781fcea79e4", size = 2546297, upload-time = "2025-10-21T16:26:01.258Z" }, - { url = "https://files.pythonhosted.org/packages/d9/98/6af702804934443c1d0d4d27d21b990d92d22ddd1b6bec6b056558cbbffa/grpcio_tools-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e3db1300d7282264639eeee7243f5de7e6a7c0283f8bf05d66c0315b7b0f0b36", size = 5839804, upload-time = "2025-10-21T16:26:05.495Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8d/7725fa7b134ef8405ffe0a37c96eeb626e5af15d70e1bdac4f8f1abf842e/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b018a4b7455a7e8c16d0fdb3655a6ba6c9536da6de6c5d4f11b6bb73378165b", size = 2593922, upload-time = "2025-10-21T16:26:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/de/ff/5b6b5012c79fa72f9107dc13f7226d9ce7e059ea639fd8c779e0dd284386/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ec6e4de3866e47cfde56607b1fae83ecc5aa546e06dec53de11f88063f4b5275", size = 2905327, upload-time = "2025-10-21T16:26:09.668Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/2691d369ea462cd6b6c92544122885ca01f7fa5ac75dee023e975e675858/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8da4d828883913f1852bdd67383713ae5c11842f6c70f93f31893eab530aead", size = 2656214, upload-time = "2025-10-21T16:26:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e7/3f8856e6ec3dd492336a91572993344966f237b0e3819fbe96437b19d313/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c120c2cf4443121800e7f9bcfe2e94519fa25f3bb0b9882359dd3b252c78a7b", size = 3109889, upload-time = "2025-10-21T16:26:15.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ce5248072e47db276dc7e069e93978dcde490c959788ce7cce8081d0bfdc/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8b7df5591d699cd9076065f1f15049e9c3597e0771bea51c8c97790caf5e4197", size = 3657939, upload-time = "2025-10-21T16:26:17.34Z" }, - { url = "https://files.pythonhosted.org/packages/f6/df/81ff88af93c52135e425cd5ec9fe8b186169c7d5f9e0409bdf2bbedc3919/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a25048c5f984d33e3f5b6ad7618e98736542461213ade1bd6f2fcfe8ce804e3d", size = 3324752, upload-time = "2025-10-21T16:26:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/35/3d/f6b83044afbf6522254a3b509515a00fed16a819c87731a478dbdd1d35c1/grpcio_tools-1.76.0-cp314-cp314-win32.whl", hash = "sha256:4b77ce6b6c17869858cfe14681ad09ed3a8a80e960e96035de1fd87f78158740", size = 1015578, upload-time = "2025-10-21T16:26:22.517Z" }, - { url = "https://files.pythonhosted.org/packages/95/4d/31236cddb7ffb09ba4a49f4f56d2608fec3bbb21c7a0a975d93bca7cd22e/grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc", size = 1190242, upload-time = "2025-10-21T16:26:25.296Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/8b/d1/cbefe328653f746fd319c4377836a25ba64226e41c6a1d7d5cdbc87a459f/grpcio_tools-1.78.0.tar.gz", hash = "sha256:4b0dd86560274316e155d925158276f8564508193088bc43e20d3f5dff956b2b", size = 5393026, upload-time = "2026-02-06T09:59:59.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ae/5b1fa5dd8d560a6925aa52de0de8731d319f121c276e35b9b2af7cc220a2/grpcio_tools-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:9eb122da57d4cad7d339fc75483116f0113af99e8d2c67f3ef9cae7501d806e4", size = 2546823, upload-time = "2026-02-06T09:58:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ed/d33ccf7fa701512efea7e7e23333b748848a123e9d3bbafde4e126784546/grpcio_tools-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d0c501b8249940b886420e6935045c44cb818fa6f265f4c2b97d5cff9cb5e796", size = 5706776, upload-time = "2026-02-06T09:58:20.944Z" }, + { url = "https://files.pythonhosted.org/packages/c6/69/4285583f40b37af28277fc6b867d636e3b10e1b6a7ebd29391a856e1279b/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:77e5aa2d2a7268d55b1b113f958264681ef1994c970f69d48db7d4683d040f57", size = 2593972, upload-time = "2026-02-06T09:58:23.29Z" }, + { url = "https://files.pythonhosted.org/packages/d7/eb/ecc1885bd6b3147f0a1b7dff5565cab72f01c8f8aa458f682a1c77a9fb08/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e3c0b0e6ba5275322ba29a97bf890565a55f129f99a21b121145e9e93a22525", size = 2905531, upload-time = "2026-02-06T09:58:25.406Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a9/511d0040ced66960ca10ba0f082d6b2d2ee6dd61837b1709636fdd8e23b4/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975d4cb48694e20ebd78e1643e5f1cd94cdb6a3d38e677a8e84ae43665aa4790", size = 2656909, upload-time = "2026-02-06T09:58:28.022Z" }, + { url = "https://files.pythonhosted.org/packages/06/a3/3d2c707e7dee8df842c96fbb24feb2747e506e39f4a81b661def7fed107c/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:553ff18c5d52807dedecf25045ae70bad7a3dbba0b27a9a3cdd9bcf0a1b7baec", size = 3109778, upload-time = "2026-02-06T09:58:30.091Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/646811ba241bf05da1f0dc6f25764f1c837f78f75b4485a4210c84b79eae/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8c7f5e4af5a84d2e96c862b1a65e958a538237e268d5f8203a3a784340975b51", size = 3658763, upload-time = "2026-02-06T09:58:32.875Z" }, + { url = "https://files.pythonhosted.org/packages/45/de/0a5ef3b3e79d1011375f5580dfee3a9c1ccb96c5f5d1c74c8cee777a2483/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96183e2b44afc3f9a761e9d0f985c3b44e03e8bb98e626241a6cbfb3b6f7e88f", size = 3325116, upload-time = "2026-02-06T09:58:34.894Z" }, + { url = "https://files.pythonhosted.org/packages/95/d2/6391b241ad571bc3e71d63f957c0b1860f0c47932d03c7f300028880f9b8/grpcio_tools-1.78.0-cp312-cp312-win32.whl", hash = "sha256:2250e8424c565a88573f7dc10659a0b92802e68c2a1d57e41872c9b88ccea7a6", size = 993493, upload-time = "2026-02-06T09:58:37.242Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/7d0d3a39ecad76ccc136be28274daa660569b244fa7d7d0bbb24d68e5ece/grpcio_tools-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:217d1fa29de14d9c567d616ead7cb0fef33cde36010edff5a9390b00d52e5094", size = 1158423, upload-time = "2026-02-06T09:58:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/53/ce/17311fb77530420e2f441e916b347515133e83d21cd6cc77be04ce093d5b/grpcio_tools-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2d6de1cc23bdc1baafc23e201b1e48c617b8c1418b4d8e34cebf72141676e5fb", size = 2546284, upload-time = "2026-02-06T09:58:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/79e101483115f0e78223397daef71751b75eba7e92a32060c10aae11ca64/grpcio_tools-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2afeaad88040894c76656202ff832cb151bceb05c0e6907e539d129188b1e456", size = 5705653, upload-time = "2026-02-06T09:58:45.533Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a7/52fa3ccb39ceeee6adc010056eadfbca8198651c113e418dafebbdf2b306/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33cc593735c93c03d63efe7a8ba25f3c66f16c52f0651910712490244facad72", size = 2592788, upload-time = "2026-02-06T09:58:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/682ff6bb548225513d73dc9403742d8975439d7469c673bc534b9bbc83a7/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2921d7989c4d83b71f03130ab415fa4d66e6693b8b8a1fcbb7a1c67cff19b812", size = 2905157, upload-time = "2026-02-06T09:58:51.478Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/264f3836a96423b7018e5ada79d62576a6401f6da4e1f4975b18b2be1265/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6a0df438e82c804c7b95e3f311c97c2f876dcc36376488d5b736b7bcf5a9b45", size = 2656166, upload-time = "2026-02-06T09:58:54.117Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/f108276611522e03e98386b668cc7e575eff6952f2db9caa15b2a3b3e883/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9c6070a9500798225191ef25d0055a15d2c01c9c8f2ee7b681fffa99c98c822", size = 3109110, upload-time = "2026-02-06T09:58:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/cf048dbcd64b3396b3c860a2ffbcc67a8f8c87e736aaa74c2e505a7eee4c/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:394e8b57d85370a62e5b0a4d64c96fcf7568345c345d8590c821814d227ecf1d", size = 3657863, upload-time = "2026-02-06T09:58:59.176Z" }, + { url = "https://files.pythonhosted.org/packages/b6/37/e2736912c8fda57e2e57a66ea5e0bc8eb9a5fb7ded00e866ad22d50afb08/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3ef700293ab375e111a2909d87434ed0a0b086adf0ce67a8d9cf12ea7765e63", size = 3324748, upload-time = "2026-02-06T09:59:01.242Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/726abc75bb5bfc2841e88ea05896e42f51ca7c30cb56da5c5b63058b3867/grpcio_tools-1.78.0-cp313-cp313-win32.whl", hash = "sha256:6993b960fec43a8d840ee5dc20247ef206c1a19587ea49fe5e6cc3d2a09c1585", size = 993074, upload-time = "2026-02-06T09:59:03.085Z" }, + { url = "https://files.pythonhosted.org/packages/c5/68/91b400bb360faf9b177ffb5540ec1c4d06ca923691ddf0f79e2c9683f4da/grpcio_tools-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:275ce3c2978842a8cf9dd88dce954e836e590cf7029649ad5d1145b779039ed5", size = 1158185, upload-time = "2026-02-06T09:59:05.036Z" }, + { url = "https://files.pythonhosted.org/packages/cf/5e/278f3831c8d56bae02e3acc570465648eccf0a6bbedcb1733789ac966803/grpcio_tools-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b080d0d072e6032708a3a91731b808074d7ab02ca8fb9847b6a011fdce64cd9", size = 2546270, upload-time = "2026-02-06T09:59:07.426Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d9/68582f2952b914b60dddc18a2e3f9c6f09af9372b6f6120d6cf3ec7f8b4e/grpcio_tools-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8c0ad8f8f133145cd7008b49cb611a5c6a9d89ab276c28afa17050516e801f79", size = 5705731, upload-time = "2026-02-06T09:59:09.856Z" }, + { url = "https://files.pythonhosted.org/packages/70/68/feb0f9a48818ee1df1e8b644069379a1e6ef5447b9b347c24e96fd258e5d/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f8ea092a7de74c6359335d36f0674d939a3c7e1a550f4c2c9e80e0226de8fe4", size = 2593896, upload-time = "2026-02-06T09:59:12.23Z" }, + { url = "https://files.pythonhosted.org/packages/1f/08/a430d8d06e1b8d33f3e48d3f0cc28236723af2f35e37bd5c8db05df6c3aa/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:da422985e0cac822b41822f43429c19ecb27c81ffe3126d0b74e77edec452608", size = 2905298, upload-time = "2026-02-06T09:59:14.458Z" }, + { url = "https://files.pythonhosted.org/packages/71/0a/348c36a3eae101ca0c090c9c3bc96f2179adf59ee0c9262d11cdc7bfe7db/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fab1faa3fbcb246263e68da7a8177d73772283f9db063fb8008517480888d26", size = 2656186, upload-time = "2026-02-06T09:59:16.949Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3f/18219f331536fad4af6207ade04142292faa77b5cb4f4463787988963df8/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dd9c094f73f734becae3f20f27d4944d3cd8fb68db7338ee6c58e62fc5c3d99f", size = 3109859, upload-time = "2026-02-06T09:59:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d9/341ea20a44c8e5a3a18acc820b65014c2e3ea5b4f32a53d14864bcd236bc/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2ed51ce6b833068f6c580b73193fc2ec16468e6bc18354bc2f83a58721195a58", size = 3657915, upload-time = "2026-02-06T09:59:21.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/5978b0f91611a64371424c109dd0027b247e5b39260abad2eaee66b6aa37/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05803a5cdafe77c8bdf36aa660ad7a6a1d9e49bc59ce45c1bade2a4698826599", size = 3324724, upload-time = "2026-02-06T09:59:24.402Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/96a324dba99cfbd20e291baf0b0ae719dbb62b76178c5ce6c788e7331cb1/grpcio_tools-1.78.0-cp314-cp314-win32.whl", hash = "sha256:f7c722e9ce6f11149ac5bddd5056e70aaccfd8168e74e9d34d8b8b588c3f5c7c", size = 1015505, upload-time = "2026-02-06T09:59:26.3Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/909e6a05bfd44d46327dc4b8a78beb2bae4fb245ffab2772e350081aaf7e/grpcio_tools-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d58ade518b546120ec8f0a8e006fc8076ae5df151250ebd7e82e9b5e152c229", size = 1190196, upload-time = "2026-02-06T09:59:28.359Z" }, ] [[package]] @@ -1036,6 +1046,7 @@ sqs = [ dev = [ { name = "icecream" }, { name = "structlog" }, + { name = "trio" }, ] dev-consumers = [ { name = "aws-lambda-powertools" }, @@ -1051,6 +1062,7 @@ dev-hosting-http = [ { name = "uvicorn" }, ] dev-http = [ + { name = "a2wsgi" }, { name = "flask" }, ] dev-otel = [ @@ -1110,6 +1122,7 @@ provides-extras = ["cron", "scheduler", "sqs", "kafka", "nats", "pubsub", "azure dev = [ { name = "icecream", specifier = "~=2.1" }, { name = "structlog", specifier = "~=25.0" }, + { name = "trio", specifier = "~=0.32" }, ] dev-consumers = [ { name = "aws-lambda-powertools", specifier = "~=3.10" }, @@ -1122,7 +1135,10 @@ dev-hosting-http = [ { name = "hypercorn", specifier = "~=0.17" }, { name = "uvicorn", specifier = "~=0.30" }, ] -dev-http = [{ name = "flask", specifier = "~=3.1" }] +dev-http = [ + { name = "a2wsgi" }, + { name = "flask", specifier = "~=3.1" }, +] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-instrumentation-botocore", specifier = ">=0.60b1" }, @@ -1247,11 +1263,11 @@ wheels = [ [[package]] name = "nats-py" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/67/990309c99a64bc5dfe811c34ecf14782ea0338945781fcaabf3e22c4c88f/nats_py-2.13.0.tar.gz", hash = "sha256:3dfe941306926a687eb7cce242ab33c0eaf23413e55ddba1fd8dc835f127c285", size = 116480, upload-time = "2026-02-04T11:19:22.762Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/70/e08fa8c2c15c9cc458bdf121019df1b9101e32602a408d94953fe1246300/nats_py-2.13.1.tar.gz", hash = "sha256:68a0afe018bdaa12740e0ae8c5a94e1937126d4901ae656afc382e2b3ab40005", size = 116881, upload-time = "2026-02-05T17:26:09.595Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/25/1460e86915cc626a361e3c3528a3db92e0a244419eba64c4df007cbc27a4/nats_py-2.13.0-py3-none-any.whl", hash = "sha256:0513dbf63ea0abf2131e273cd02004a7a9b0c93670cdea46722b43f7495b7705", size = 80914, upload-time = "2026-02-04T11:19:21.746Z" }, + { url = "https://files.pythonhosted.org/packages/45/0d/38d36347de5088f6cc8c028895043ee264933d00ff729c0f208643fa2ad9/nats_py-2.13.1-py3-none-any.whl", hash = "sha256:838590cd4d3fae141fb3332c16c9861e0831b053a29cd844d51d1104dfc5b4d9", size = 80947, upload-time = "2026-02-05T17:26:08.493Z" }, ] [[package]] @@ -1423,6 +1439,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -1911,11 +1939,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.10.2" +version = "81.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] @@ -1927,17 +1955,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "starlette" -version = "0.50.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] @@ -1977,6 +2023,23 @@ nats = [ { name = "nats-py" }, ] +[[package]] +name = "trio" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, +] + [[package]] name = "types-awscrt" version = "0.31.1" @@ -1988,15 +2051,15 @@ wheels = [ [[package]] name = "types-boto3-lite" -version = "1.42.42" +version = "1.42.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/3f/f9cf32c60f1acee031821dad94d3178047406de9151745cbed7ebbe93ed7/types_boto3_lite-1.42.42.tar.gz", hash = "sha256:0052bb5299c3602b236564d592ca69076575f14534bc31454f4bd5ad05b0b575", size = 73071, upload-time = "2026-02-04T20:53:24.09Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/94/eaa5a7e48f14a7b86a8383d730f7b404128346bd1ed2480098d5b3d17aae/types_boto3_lite-1.42.44.tar.gz", hash = "sha256:a5046c57ac169193eedec49a26ca23d1a2aa2d730f5533638f36de0fb574c489", size = 73035, upload-time = "2026-02-06T20:40:14.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/13/e7a31dc809f1dbebda73c2738706edd153f11339eb4841cf1f10ddb3c995/types_boto3_lite-1.42.42-py3-none-any.whl", hash = "sha256:46a5f044e92adb1af6d6f2897634d0990d4a8ec818f57db2621b6620813ce189", size = 42692, upload-time = "2026-02-04T20:53:20.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/6f399a6214595438ed3c918adda94c085e79d8b5ac9ddcbb72fe297b10bb/types_boto3_lite-1.42.44-py3-none-any.whl", hash = "sha256:c7fa35b85bde6e5f5b2f4feb6004d06c75fd07bd03dc397338ab24cc3e4ffabf", size = 42694, upload-time = "2026-02-06T20:40:07.092Z" }, ] [package.optional-dependencies] From 13c89870fab15f97b3b9aa4e1fa9861db4b4546d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 8 Feb 2026 21:05:52 +0000 Subject: [PATCH 018/286] WIP (in the middle, not working) --- localpost/http/server.py | 169 ++++++++++++++++++++++++++------------- localpost/http/worker.py | 2 +- localpost/http/wsgi.py | 6 +- 3 files changed, 117 insertions(+), 60 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index 974d505..fbd486c 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -9,11 +9,13 @@ from __future__ import annotations import logging +import selectors import socket +import threading from collections.abc import Iterator, Callable, Iterable -from contextlib import contextmanager, suppress, AbstractContextManager, nullcontext -from dataclasses import dataclass -from io import DEFAULT_BUFFER_SIZE, RawIOBase +from contextlib import contextmanager, suppress, AbstractContextManager, nullcontext, closing +from dataclasses import dataclass, field +from io import DEFAULT_BUFFER_SIZE, RawIOBase, IOBase from typing import final import h11 @@ -24,23 +26,52 @@ __all__ = ['Server', 'ClientConn', 'RequestHandler', 'start_http_server'] -@contextmanager -def start_http_server(config: ServerConfig) -> Iterator[Server]: - _socket = socket.create_server( +def start_http_server(config: ServerConfig) -> AbstractContextManager[Server]: + server_sock = socket.create_server( (config.host, config.port), backlog=config.backlog, reuse_port=True, ) - _socket.settimeout(CHECK_TIMEOUT) - logger = logging.getLogger(LOGGER_NAME) - server = Server(_socket, config, logger) + server = Server(server_sock, config, logger) logger.info(f"Serving on {config.host}:{server.port}") - try: - yield server - finally: - server.shutdown() + return closing(server) + + +@dataclass(slots=True) +class Connections: + server: Server + keep_alive_conns: list[ClientConn] = field(default_factory=list) + _selector: selectors.BaseSelector = field(default_factory=selectors.DefaultSelector) + _lock: threading.Lock = field(default_factory=threading.Lock) + + def register(self, conn: ClientConn) -> None: + sock = conn.sock.sock + sock.settimeout(0) + with self._lock: + self._selector.register(sock, selectors.EVENT_READ, data=conn) + + # Add self-pipe wakeup trick and queue + register_back = register + + def unregister(self, conn: ClientConn) -> None: + sock = conn.sock.sock + with self._lock: + self._selector.unregister(sock) + sock.settimeout(CHECK_TIMEOUT) + + def select(self) -> Iterator[selectors.SelectorKey]: + selector, server_sock = self._selector, self.server.sock + server_sock.settimeout(0) + selector.register(server_sock, selectors.EVENT_READ) + try: + while self.server.running: + check_cancelled() + for key, _ in selector.select(timeout=CHECK_TIMEOUT): + yield key + finally: + selector.unregister(server_sock) @final @@ -49,10 +80,13 @@ def __init__( self, server_sock: socket.socket, config: ServerConfig, + handler: ConnHandler, logger: logging.Logger, ) -> None: - self._socket = server_sock + self.selector = Selector(self) + self.sock = server_sock self.config = config + self.handler = handler self.port = server_sock.getsockname()[1] """ Actual port the server is listening on. @@ -66,55 +100,34 @@ def __init__( def running(self) -> bool: return self._running - def shutdown(self) -> None: + def close(self) -> None: """Stop accepting new connections and close the server socket.""" if not self._running: return - self._socket.close() # Safe to call if from another thread, will cause accept() to raise OSError + self.sock.close() # Safe to call if from another thread, will cause accept() to raise OSError - def __iter__(self) -> Iterator[ClientConn]: + def serve_forever(self) -> None: + selector, conn_handler = self.selector, self.handler self._running = True - while True: - try: - check_cancelled() - client_sock, client_addr = self._socket.accept() - cs = ClientSocket(self, client_sock, client_addr, self.config.rw_timeout) - yield ClientConn(self, cs, self._logger) - except TimeoutError: - pass - except OSError: - if self._running: - self._running = False - return # Socket was closed, exit gracefully - raise # Unexpected error + for key in selector: + if key.fileobj is self.sock: + client_sock, client_addr = self.sock.accept() + cs = ClientSocket(client_sock, client_addr, self.config.rw_timeout) + selector.register(ClientConn(self, cs, self._logger)) + elif (conn := key.data) and isinstance(conn, ClientConn): + if (event := conn.poke()) is not h11.NEED_DATA: + selector.unregister(conn) + conn_handler(conn, event) @dataclass(slots=True) class ClientSocket: - server: Server sock: socket.socket addr: tuple[str, int] timeout: float """Timeout for receive/send operations.""" - _recv_buf: bytes | None = None - - def __post_init__(self): - self.sock.settimeout(CHECK_TIMEOUT) - - def wait_for_req(self, timeout: float) -> bool: - for _ in range(int(timeout / CHECK_TIMEOUT)): - check_cancelled() - if not self.server.running: - return False - with suppress(TimeoutError): - self._recv_buf = self.sock.recv(DEFAULT_BUFFER_SIZE) - return True - return False def recv(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - if (buf := self._recv_buf) is not None: - self._recv_buf = None - return buf for _ in range(int(self.timeout / CHECK_TIMEOUT)): check_cancelled() with suppress(TimeoutError): @@ -129,6 +142,7 @@ def sendall(self, buf, /) -> None: raise TimeoutError("send timeout") def close(self) -> None: + # TODO Unregister from selector self.sock.close() @@ -142,12 +156,25 @@ def __init__( ) -> None: self.server = server self.config = server.config - self.socket = client_sock + self.sock = client_sock self.conn = h11.Connection(h11.SERVER) self._logger = logger - def __call__(self, h: RequestHandler) -> None: - conn, sock = self.conn, self.socket + def poke(self) -> h11.Event | type[h11.NEED_DATA]: + conn, sock = self.conn, self.sock + while True: + try: + conn.receive_data(sock.recv()) + if (event := conn.next_event()) is not h11.NEED_DATA: + return event + except BlockingIOError: + return h11.NEED_DATA + + def register_back(self) -> None: + self.server.selector.register_back(self) + + def __old__call__(self, h: RequestHandler) -> None: + conn, sock = self.conn, self.sock try: while conn.our_state is not h11.MUST_CLOSE: if (prev_state := conn.our_state) is h11.DONE: @@ -173,10 +200,37 @@ def __call__(self, h: RequestHandler) -> None: finally: sock.close() - def _handle_request(self, h: RequestHandler, request: h11.Request) -> None: - conn, sock = self.conn, self.socket +@final +@dataclass(frozen=True, slots=True) +class DefaultConnHandler: + req_handler: RequestHandler + _logger: logging.Logger + + def __call__(self, client_conn: ClientConn, event) -> None: + conn, sock = client_conn.conn, client_conn.sock + while True: + if isinstance(event, h11.Request): + self._handle_request(client_conn, event) + if conn.our_state is h11.MUST_CLOSE: + sock.close() + return + if conn.our_state is h11.DONE: + conn.start_next_cycle() + elif event is h11.NEED_DATA: + client_conn.register_back() + return + elif isinstance(event, h11.ConnectionClosed): + self._logger.debug("Client closed connection") + sock.close() + return + else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) + raise RuntimeError(f"Unexpected {event!r} in the connection loop") + event = conn.next_event() + + def _handle_request(self, client_conn: ClientConn, request: h11.Request) -> None: + conn, sock, h = client_conn.conn, client_conn.sock, self.req_handler body = RequestBodyStream(conn, sock) - response, response_chunks = h(self, request, body) + response, response_chunks = h(client_conn, request, body) with response_chunks as chunks: sock.sendall(conn.send(response)) check_cancelled() @@ -244,8 +298,11 @@ def drain(self) -> None: self._receive(DEFAULT_BUFFER_SIZE) +ConnHandler = Callable[[ClientConn], None] + + RequestHandler = Callable[ - [ClientConn, h11.Request, RawIOBase], + [ClientConn, h11.Request, IOBase], tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]] ] @@ -253,7 +310,7 @@ def drain(self) -> None: def _main(): logging.basicConfig(level=logging.DEBUG) - def simple_app(c: ClientConn, r: h11.Request, rb: RawIOBase): + def simple_app(c: ClientConn, r: h11.Request, rb: IOBase): return (h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')]), nullcontext([h11.Data(b'Hello, World!\n')])) diff --git a/localpost/http/worker.py b/localpost/http/worker.py index 56d8360..9dbdeb0 100644 --- a/localpost/http/worker.py +++ b/localpost/http/worker.py @@ -62,7 +62,7 @@ class Worker: def shutdown(self) -> None: """Graceful shutdown (stop handling new connections, wait for in-flight requests).""" - self.server.shutdown() + self.server.close() def _sample_usage(): diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 525ee09..ae2f3cd 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -4,7 +4,7 @@ import sys from collections.abc import Callable, Iterable from contextlib import closing, AbstractContextManager, suppress -from io import BufferedReader, RawIOBase +from io import BufferedReader, RawIOBase, IOBase from typing import Any from wsgiref.types import WSGIApplication @@ -18,7 +18,7 @@ def wrap_wsgi(app: WSGIApplication) -> RequestHandler: """Wrap a WSGI application as a RequestHandler.""" def handler( - client: ClientConn, request: h11.Request, body: RawIOBase + client: ClientConn, request: h11.Request, body: IOBase ) -> tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]]: environ = _build_environ(client, request, body) response: h11.Response | None = None @@ -60,7 +60,7 @@ def _wsgi_response_write(_: bytes) -> None: raise NotImplementedError("write() is deprecated and not supported") -def _build_environ(client: ClientConn, request: h11.Request, body: RawIOBase) -> dict[str, Any]: +def _build_environ(client: ClientConn, request: h11.Request, body: IOBase) -> dict[str, Any]: # Decode path and parse query string path = request.target.decode('ISO-8859-1') if '?' in path: From 164d4f37894e234ea08bfc02662243b6cf74f3c1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 8 Feb 2026 21:51:39 +0000 Subject: [PATCH 019/286] WIP (working with selectors!) --- localpost/http/server.py | 126 ++++++++++++--------------------------- 1 file changed, 38 insertions(+), 88 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index fbd486c..c3a6e41 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -46,15 +46,13 @@ class Connections: _selector: selectors.BaseSelector = field(default_factory=selectors.DefaultSelector) _lock: threading.Lock = field(default_factory=threading.Lock) + # Add self-pipe wakeup trick and queue def register(self, conn: ClientConn) -> None: sock = conn.sock.sock sock.settimeout(0) with self._lock: self._selector.register(sock, selectors.EVENT_READ, data=conn) - # Add self-pipe wakeup trick and queue - register_back = register - def unregister(self, conn: ClientConn) -> None: sock = conn.sock.sock with self._lock: @@ -80,19 +78,17 @@ def __init__( self, server_sock: socket.socket, config: ServerConfig, - handler: ConnHandler, logger: logging.Logger, ) -> None: - self.selector = Selector(self) self.sock = server_sock - self.config = config - self.handler = handler self.port = server_sock.getsockname()[1] """ Actual port the server is listening on. Can be useful when port 0 is specified to auto-assign a free port. """ + self.config = config + self.conns = Connections(self) self._logger = logger self._running = False @@ -106,21 +102,25 @@ def close(self) -> None: return self.sock.close() # Safe to call if from another thread, will cause accept() to raise OSError - def serve_forever(self) -> None: - selector, conn_handler = self.selector, self.handler + def keep_alive(self, conn: ClientConn) -> None: + self.conns.register(conn) + + def accept(self) -> Iterable[ClientConn]: + conns, server_sock = self.conns, self.sock self._running = True - for key in selector: - if key.fileobj is self.sock: - client_sock, client_addr = self.sock.accept() + for key in conns.select(): + if key.fileobj is server_sock: + client_sock, client_addr = server_sock.accept() cs = ClientSocket(client_sock, client_addr, self.config.rw_timeout) - selector.register(ClientConn(self, cs, self._logger)) + yield ClientConn(self, self.config, cs, self._logger) elif (conn := key.data) and isinstance(conn, ClientConn): - if (event := conn.poke()) is not h11.NEED_DATA: - selector.unregister(conn) - conn_handler(conn, event) + conns.unregister(conn) + yield conn + else: + raise RuntimeError(f"Unexpected selector key: {key!r}") -@dataclass(slots=True) +@dataclass(frozen=True, slots=True) class ClientSocket: sock: socket.socket addr: tuple[str, int] @@ -142,95 +142,48 @@ def sendall(self, buf, /) -> None: raise TimeoutError("send timeout") def close(self) -> None: - # TODO Unregister from selector self.sock.close() @final +@dataclass(slots=True) class ClientConn: - def __init__( - self, - server: Server, - client_sock: ClientSocket, - logger: logging.Logger, - ) -> None: - self.server = server - self.config = server.config - self.sock = client_sock - self.conn = h11.Connection(h11.SERVER) - self._logger = logger - - def poke(self) -> h11.Event | type[h11.NEED_DATA]: - conn, sock = self.conn, self.sock - while True: - try: - conn.receive_data(sock.recv()) - if (event := conn.next_event()) is not h11.NEED_DATA: - return event - except BlockingIOError: - return h11.NEED_DATA - - def register_back(self) -> None: - self.server.selector.register_back(self) + server: Server + config: ServerConfig + sock: ClientSocket + _logger: logging.Logger + _conn: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) - def __old__call__(self, h: RequestHandler) -> None: - conn, sock = self.conn, self.sock + def __call__(self, h: RequestHandler) -> None: + conn, sock = self._conn, self.sock try: - while conn.our_state is not h11.MUST_CLOSE: + while True: if (prev_state := conn.our_state) is h11.DONE: conn.start_next_cycle() event = conn.next_event() - if event is h11.NEED_DATA: - if prev_state is h11.DONE: - if not sock.wait_for_req(self.config.keep_alive_timeout): - if self.server.running: - self._logger.debug("Closing idle client connection (keep-alive timeout: %s seconds)", - self.config.keep_alive_timeout) - return + if event is h11.NEED_DATA and prev_state is h11.DONE: + self.server.keep_alive(self) + return + elif event is h11.NEED_DATA: conn.receive_data(sock.recv()) elif isinstance(event, h11.Request): self._handle_request(h, event) + if conn.our_state is h11.MUST_CLOSE: + sock.close() + return elif isinstance(event, h11.ConnectionClosed): self._logger.debug("Client closed connection") + sock.close() return else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) raise RuntimeError(f"Unexpected {event!r} in the connection loop") except TimeoutError: self._logger.debug("Client connection timed out", exc_info=True) - finally: - sock.close() - -@final -@dataclass(frozen=True, slots=True) -class DefaultConnHandler: - req_handler: RequestHandler - _logger: logging.Logger - def __call__(self, client_conn: ClientConn, event) -> None: - conn, sock = client_conn.conn, client_conn.sock - while True: - if isinstance(event, h11.Request): - self._handle_request(client_conn, event) - if conn.our_state is h11.MUST_CLOSE: - sock.close() - return - if conn.our_state is h11.DONE: - conn.start_next_cycle() - elif event is h11.NEED_DATA: - client_conn.register_back() - return - elif isinstance(event, h11.ConnectionClosed): - self._logger.debug("Client closed connection") - sock.close() - return - else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) - raise RuntimeError(f"Unexpected {event!r} in the connection loop") - event = conn.next_event() - - def _handle_request(self, client_conn: ClientConn, request: h11.Request) -> None: - conn, sock, h = client_conn.conn, client_conn.sock, self.req_handler + def _handle_request(self, h: RequestHandler, request: h11.Request) -> None: + conn, sock = self._conn, self.sock body = RequestBodyStream(conn, sock) - response, response_chunks = h(client_conn, request, body) + response, response_chunks = h(self, request, body) with response_chunks as chunks: sock.sendall(conn.send(response)) check_cancelled() @@ -298,9 +251,6 @@ def drain(self) -> None: self._receive(DEFAULT_BUFFER_SIZE) -ConnHandler = Callable[[ClientConn], None] - - RequestHandler = Callable[ [ClientConn, h11.Request, IOBase], tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]] @@ -315,7 +265,7 @@ def simple_app(c: ClientConn, r: h11.Request, rb: IOBase): nullcontext([h11.Data(b'Hello, World!\n')])) with start_http_server(ServerConfig()) as server: - for client_conn in server: + for client_conn in server.accept(): client_conn(simple_app) From c15e79a8554b8ed69d36c43bc4753699f3d5d547 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Feb 2026 15:22:00 +0000 Subject: [PATCH 020/286] WIP (working) --- localpost/http/config.py | 2 +- localpost/http/server.py | 55 +++++++++++++++++++++++++--------------- localpost/http/worker.py | 38 ++++++++++++++------------- localpost/http/wsgi.py | 2 +- 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/localpost/http/config.py b/localpost/http/config.py index 9d2ca24..64d5d4b 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -19,7 +19,7 @@ class ServerConfig: host: str = "0.0.0.0" port: int = 8000 - backlog: int = 16 + backlog: int = 1024 """Maximum number of queued connections.""" rw_timeout: float = 3.0 """Timeout (seconds) for receive/send operations on a client connection.""" diff --git a/localpost/http/server.py b/localpost/http/server.py index c3a6e41..d8d1525 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -12,18 +12,19 @@ import selectors import socket import threading -from collections.abc import Iterator, Callable, Iterable -from contextlib import contextmanager, suppress, AbstractContextManager, nullcontext, closing +import time +from collections.abc import Callable, Iterable, Iterator +from contextlib import AbstractContextManager, closing, nullcontext, suppress from dataclasses import dataclass, field -from io import DEFAULT_BUFFER_SIZE, RawIOBase, IOBase +from io import DEFAULT_BUFFER_SIZE, IOBase, RawIOBase from typing import final import h11 from localpost._sync_utils import CHECK_TIMEOUT, check_cancelled -from localpost.http.config import ServerConfig, LOGGER_NAME +from localpost.http.config import LOGGER_NAME, ServerConfig -__all__ = ['Server', 'ClientConn', 'RequestHandler', 'start_http_server'] +__all__ = ["Server", "ClientConn", "RequestHandler", "start_http_server"] def start_http_server(config: ServerConfig) -> AbstractContextManager[Server]: @@ -42,23 +43,35 @@ def start_http_server(config: ServerConfig) -> AbstractContextManager[Server]: @dataclass(slots=True) class Connections: server: Server - keep_alive_conns: list[ClientConn] = field(default_factory=list) _selector: selectors.BaseSelector = field(default_factory=selectors.DefaultSelector) _lock: threading.Lock = field(default_factory=threading.Lock) # Add self-pipe wakeup trick and queue def register(self, conn: ClientConn) -> None: - sock = conn.sock.sock + conn.idle_since, sock = time.monotonic(), conn.sock.raw_sock sock.settimeout(0) with self._lock: self._selector.register(sock, selectors.EVENT_READ, data=conn) def unregister(self, conn: ClientConn) -> None: - sock = conn.sock.sock + sock = conn.sock.raw_sock with self._lock: self._selector.unregister(sock) sock.settimeout(CHECK_TIMEOUT) + def _find_stale(self): + now, timeout = time.monotonic(), self.server.config.keep_alive_timeout + for key in self._selector.get_map().values(): + if (conn := key.data) and isinstance(conn, ClientConn): + if now - conn.idle_since > timeout: + yield conn + + def _cleanup_stale(self): + with self._lock: + for conn in list(self._find_stale()): + self._selector.unregister(conn.sock.raw_sock) + conn.sock.close() + def select(self) -> Iterator[selectors.SelectorKey]: selector, server_sock = self._selector, self.server.sock server_sock.settimeout(0) @@ -66,6 +79,7 @@ def select(self) -> Iterator[selectors.SelectorKey]: try: while self.server.running: check_cancelled() + self._cleanup_stale() for key, _ in selector.select(timeout=CHECK_TIMEOUT): yield key finally: @@ -88,7 +102,7 @@ def __init__( Can be useful when port 0 is specified to auto-assign a free port. """ self.config = config - self.conns = Connections(self) + self._conns = Connections(self) self._logger = logger self._running = False @@ -103,10 +117,10 @@ def close(self) -> None: self.sock.close() # Safe to call if from another thread, will cause accept() to raise OSError def keep_alive(self, conn: ClientConn) -> None: - self.conns.register(conn) + self._conns.register(conn) def accept(self) -> Iterable[ClientConn]: - conns, server_sock = self.conns, self.sock + conns, server_sock = self._conns, self.sock self._running = True for key in conns.select(): if key.fileobj is server_sock: @@ -122,7 +136,7 @@ def accept(self) -> Iterable[ClientConn]: @dataclass(frozen=True, slots=True) class ClientSocket: - sock: socket.socket + raw_sock: socket.socket addr: tuple[str, int] timeout: float """Timeout for receive/send operations.""" @@ -131,18 +145,18 @@ def recv(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: for _ in range(int(self.timeout / CHECK_TIMEOUT)): check_cancelled() with suppress(TimeoutError): - return self.sock.recv(size) + return self.raw_sock.recv(size) raise TimeoutError("receive timeout") def sendall(self, buf, /) -> None: for _ in range(int(self.timeout / CHECK_TIMEOUT)): check_cancelled() with suppress(TimeoutError): - return self.sock.sendall(buf) + return self.raw_sock.sendall(buf) raise TimeoutError("send timeout") def close(self) -> None: - self.sock.close() + self.raw_sock.close() @final @@ -153,6 +167,7 @@ class ClientConn: sock: ClientSocket _logger: logging.Logger _conn: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) + idle_since: float = 0.0 def __call__(self, h: RequestHandler) -> None: conn, sock = self._conn, self.sock @@ -191,7 +206,7 @@ def _handle_request(self, h: RequestHandler, request: h11.Request) -> None: check_cancelled() sock.sendall(conn.send(chunk)) sock.sendall(conn.send(h11.EndOfMessage())) - body.drain() + body.drain() # TODO Finally?.. @dataclass(slots=True) @@ -245,7 +260,7 @@ def _receive(self, size: int, /) -> bytes: raise RuntimeError(f"Unexpected h11 event: {event!r}") def drain(self) -> None: - """Consume any remaining body data. Required before starting next request cycle.""" + """Consume any remaining body data (required before starting next request cycle).""" with suppress(EOFError): while not self.finished: self._receive(DEFAULT_BUFFER_SIZE) @@ -261,13 +276,13 @@ def _main(): logging.basicConfig(level=logging.DEBUG) def simple_app(c: ClientConn, r: h11.Request, rb: IOBase): - return (h11.Response(status_code=200, headers=[('Content-Type', 'text/plain')]), - nullcontext([h11.Data(b'Hello, World!\n')])) + return (h11.Response(status_code=200, headers=[("Content-Type", "text/plain")]), + nullcontext([h11.Data(b"Hello, World!\n")])) with start_http_server(ServerConfig()) as server: for client_conn in server.accept(): client_conn(simple_app) -if __name__ == '__main__': +if __name__ == "__main__": _main() diff --git a/localpost/http/worker.py b/localpost/http/worker.py index 9dbdeb0..0b8a6de 100644 --- a/localpost/http/worker.py +++ b/localpost/http/worker.py @@ -3,7 +3,7 @@ import logging import signal import threading -from collections.abc import Awaitable +from collections.abc import Awaitable, AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass from typing import final @@ -13,40 +13,42 @@ from anyio import CancelScope, create_task_group, from_thread, to_thread from localpost._sync_utils import _acquire -from .config import WorkerConfig -from .server import Server, start_http_server -from .wsgi import wrap_wsgi +from localpost.http.config import WorkerConfig +from localpost.http.server import Server, start_http_server +from localpost.http.wsgi import wrap_wsgi __all__ = ["Worker", "serve"] @asynccontextmanager -async def serve(app: WSGIApplication, config: WorkerConfig, /): +async def serve(app: WSGIApplication, config: WorkerConfig, /) -> AsyncIterator[Worker]: """Run multiple servers (workers).""" handler = wrap_wsgi(app) - # handler = _limit_requests(handler, threading.BoundedSemaphore(config.max_requests)) - threads_limiter = anyio.CapacityLimiter(config.max_connections) - conn_sem = threading.BoundedSemaphore(config.max_connections) # TODO AnyIO one, if the server is async + req_threads = anyio.CapacityLimiter(config.max_requests) + req_sem = threading.BoundedSemaphore(config.max_requests) - def handle_client(c) -> None: + def handle_client(c): try: c(handler) finally: - conn_sem.release() + req_sem.release() def handle_client_thread(c) -> Awaitable[None]: - return to_thread.run_sync(handle_client, c, limiter=threads_limiter) + return to_thread.run_sync(handle_client, c, limiter=req_threads) + + def schedule_client_handler(c): + # Handle each client connection in a separate thread + from_thread.run_sync(tg.start_soon, handle_client_thread, c) + + def handle_clients(): + _acquire(req_sem) + for c in server.accept(): + schedule_client_handler(c) + _acquire(req_sem) def handle_clients_thread() -> Awaitable[None]: return to_thread.run_sync(handle_clients, limiter=anyio.CapacityLimiter(1)) - def handle_clients() -> None: - _acquire(conn_sem) - for client_conn in server: - # Handle each client connection in a separate thread - from_thread.run_sync(tg.start_soon, handle_client_thread, client_conn) - _acquire(conn_sem) - async with create_task_group() as tg: with start_http_server(config.server) as server: tg.start_soon(handle_clients_thread) diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index ae2f3cd..4883012 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -126,7 +126,7 @@ def hello_stream(name): handler = wrap_wsgi(app) with start_http_server(ServerConfig()) as server: - for client_conn in server: + for client_conn in server.accept(): client_conn(handler) From c2fa960ac9c200a89e17bf098c07c4fd48c9abbf Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Feb 2026 18:36:51 +0000 Subject: [PATCH 021/286] WIP (consumers) --- localpost/_sync_utils.py | 17 +- localpost/_utils.py | 28 +-- localpost/consumers/__init__.py | 5 + localpost/consumers/stream.py | 110 ++++----- localpost/flow/__init__.py | 49 ----- localpost/flow/_flow.py | 294 ------------------------- localpost/flow/_ops.py | 291 ------------------------ localpost/flow/_queue.py | 95 -------- localpost/flow/_stream.py | 99 --------- localpost/http/README.md | 4 +- localpost/http/{cli.py => __main__.py} | 0 localpost/http/server.py | 3 + localpost/http/worker.py | 19 +- 13 files changed, 102 insertions(+), 912 deletions(-) delete mode 100644 localpost/flow/__init__.py delete mode 100644 localpost/flow/_flow.py delete mode 100644 localpost/flow/_ops.py delete mode 100644 localpost/flow/_queue.py delete mode 100644 localpost/flow/_stream.py rename localpost/http/{cli.py => __main__.py} (100%) diff --git a/localpost/_sync_utils.py b/localpost/_sync_utils.py index 324c05d..a4acd28 100644 --- a/localpost/_sync_utils.py +++ b/localpost/_sync_utils.py @@ -1,5 +1,4 @@ -from __future__ import annotations - +import queue import threading from contextlib import suppress @@ -15,8 +14,16 @@ def check_cancelled() -> None: from_thread.check_cancelled() -def _acquire(sem: threading.Semaphore): +def acquire_sem(sem: threading.Semaphore) -> None: + while not sem.acquire(timeout=CHECK_TIMEOUT): + check_cancelled() + return + + +def pull_queue[T](q: queue.Queue[T] | queue.SimpleQueue[T]) -> T: while True: check_cancelled() - if sem.acquire(timeout=CHECK_TIMEOUT): - return + try: + return q.get(timeout=CHECK_TIMEOUT) + except queue.Empty: + continue diff --git a/localpost/_utils.py b/localpost/_utils.py index 8052003..f35da8b 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -27,17 +27,13 @@ ) import anyio -from anyio import CancelScope, CapacityLimiter, WouldBlock, create_task_group, from_thread, to_thread +from anyio import CancelScope, CapacityLimiter, WouldBlock, create_task_group, from_thread, to_thread, \ + create_memory_object_stream from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, _MemoryObjectStreamState from typing_extensions import NotRequired, Self, TypeVar -if sys.version_info >= (3, 11): - from builtins import ExceptionGroup -else: - from exceptiongroup import ExceptionGroup - T = TypeVar("T", default=Any) P = ParamSpec("P") R = TypeVar("R") @@ -317,16 +313,11 @@ async def send_or_drop(self, item: T) -> None: pass +# For better typing in PyCharm class MemoryStream(Generic[T]): @staticmethod def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjectReceiveStream[T]]: - if max_buffer_size != math.inf and not isinstance(max_buffer_size, int): - raise ValueError("max_buffer_size must be either an integer or math.inf") - if max_buffer_size < 0: - raise ValueError("max_buffer_size cannot be negative") - - state: _MemoryObjectStreamState[T] = _MemoryObjectStreamState(max_buffer_size) - return MemorySendStream(state), MemoryObjectReceiveStream(state) + return create_memory_object_stream(max_buffer_size) # type: ignore[return-value] class AsyncBackendConfig(TypedDict): @@ -436,3 +427,14 @@ async def wait_any(*targets: EventView | Callable[[], Awaitable[Any]]) -> None: except ExceptionGroup as exc_group: exc = unwrap_exc(exc_group) raise exc from exc.__cause__ + + +class NullSemaphore(AbstractContextManager): + def __exit__(self, exc_type, exc_value, traceback, /): + pass + + async def acquire(self) -> None: + pass + + def release(self) -> None: + pass diff --git a/localpost/consumers/__init__.py b/localpost/consumers/__init__.py index e69de29..02fbdc1 100644 --- a/localpost/consumers/__init__.py +++ b/localpost/consumers/__init__.py @@ -0,0 +1,5 @@ +from ._utils import ensure_async_handler + +__all__ = [ + "ensure_async_handler", +] diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py index db8a0ec..eec005e 100644 --- a/localpost/consumers/stream.py +++ b/localpost/consumers/stream.py @@ -1,63 +1,69 @@ import math -from collections.abc import Callable -from typing import Generic, TypeVar +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +import anyio +from anyio import create_task_group, Semaphore, ClosedResourceError, CancelScope from anyio.abc import ObjectReceiveStream -from localpost._utils import wait_any -from localpost.flow import AnyHandlerManager, ensure_async_handler -from localpost.flow._stream import create_stream_consumer -from localpost.hosting import ServiceLifetimeManager - -T = TypeVar("T") +from localpost._utils import NullSemaphore, ensure_int_or_inf +from localpost.consumers import ensure_async_handler +from localpost.consumers._utils import AsyncHandler __all__ = ["stream_consumer"] -class StreamConsumerService(Generic[T]): - def __init__( - self, - target: ObjectReceiveStream[T], - handler: AnyHandlerManager[T], - /, - *, - concurrency: int, - process_leftovers: bool, - ): - if not (math.isinf(concurrency) or (isinstance(concurrency, int) and concurrency >= 1)): - raise ValueError("Number of consumers must be at least 1") - - self.target = target - self.handler_m = handler - self.concurrency = concurrency - self.process_leftovers = process_leftovers - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - receiver = self.target - async with ( - receiver, - self.handler_m as handler, - create_stream_consumer( - receiver, - ensure_async_handler(handler, max_threads=self.concurrency), - concurrency=self.concurrency, - ) as consumer_state, - ): - service_lifetime.set_started() - if not self.process_leftovers: - await wait_any(service_lifetime.shutting_down, consumer_state.closed) - await receiver.aclose() - # Otherwise just wait for the stream to be exhausted and closed - - -def stream_consumer( - target: ObjectReceiveStream[T], +@asynccontextmanager +async def stream_consumer[T]( + stream: ObjectReceiveStream[T], + h: AsyncHandler[T], /, *, - concurrency: int = 1, + max_concurrency: int | float = math.inf, process_leftovers: bool = True, -) -> Callable[[AnyHandlerManager[T]], StreamConsumerService[T]]: - """Decorator to create a stream consumer hosted service.""" - return lambda handler_m: StreamConsumerService( - target, handler_m, concurrency=concurrency, process_leftovers=process_leftovers - ) +) -> AsyncIterator[None]: + max_concurrency = ensure_int_or_inf(max_concurrency, min_value=1) + req_sem = Semaphore(max_concurrency) if max_concurrency != math.inf else NullSemaphore() + handler = ensure_async_handler(h) + + async def handle_item(item): + try: + await handler(item) + finally: + req_sem.release() + + async def handle_items(): + with suppress(ClosedResourceError): # Receiver has been closed (according to process_leftovers setting) + await req_sem.acquire() + async for item in stream: + tg.start_soon(handle_item, item) + await req_sem.acquire() + + async with stream, create_task_group() as tg: + tg.start_soon(handle_items) + yield + if not process_leftovers: + # Immediately stop consuming (close the receiver) and ignore the remaining items + await stream.aclose() + # Otherwise process all the remaining items (until the source stream is completed) + + +def _sample_usage(): + import logging + import anyio + + logging.basicConfig(level=logging.DEBUG) + + async def _run(): + async with serve(simple_app, WorkerConfig()) as w: + with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: + async for _ in signals: + w.shutdown() + break + + # noinspection PyTypeChecker + anyio.run(_run) + + +if __name__ == "__main__": + _sample_usage() diff --git a/localpost/flow/__init__.py b/localpost/flow/__init__.py deleted file mode 100644 index cf8e900..0000000 --- a/localpost/flow/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from ._flow import ( - AnyHandler, - AnyHandlerManager, - AsyncHandler, - AsyncHandlerManager, - FlowHandler, - FlowHandlerManager, - HandlerDecorator, - HandlerMiddleware, - SyncHandler, - SyncHandlerManager, - ensure_async_handler, - ensure_async_handler_manager, - ensure_sync_handler, - ensure_sync_handler_manager, - handler, - handler_manager, - handler_middleware, -) -from ._ops import batch, buffer, delay, log_errors, skip_first - -__all__ = [ - "AnyHandler", - "AnyHandlerManager", - "handler", - "handler_manager", - # async - "AsyncHandler", - "AsyncHandlerManager", - "ensure_async_handler", - "ensure_async_handler_manager", - # sync - "SyncHandler", - "SyncHandlerManager", - "ensure_sync_handler", - "ensure_sync_handler_manager", - # combinators - "HandlerMiddleware", - "HandlerDecorator", - "FlowHandler", - "FlowHandlerManager", - "handler_middleware", - # ops - "delay", - "log_errors", - "skip_first", - "buffer", - "batch", -] diff --git a/localpost/flow/_flow.py b/localpost/flow/_flow.py deleted file mode 100644 index 6994c61..0000000 --- a/localpost/flow/_flow.py +++ /dev/null @@ -1,294 +0,0 @@ -from __future__ import annotations - -import dataclasses as dc -import inspect -import logging -from collections.abc import AsyncGenerator, Awaitable, Callable -from contextlib import ( - AbstractAsyncContextManager, - AsyncExitStack, - asynccontextmanager, - nullcontext, -) -from typing import Any, Generic, ParamSpec, TypeAlias, cast, final - -from anyio import CapacityLimiter -from typing_extensions import TypeVar - -from localpost._utils import ensure_async_callable, ensure_sync_callable, is_async_callable - -T = TypeVar("T", default=Any) # Default to Any to allow for more flexible typing -T2 = TypeVar("T2", default=Any) -P = ParamSpec("P") -R = TypeVar("R", Awaitable[None], None) -R2 = TypeVar("R2", Awaitable[None], None) - -AsyncHandler: TypeAlias = Callable[[T], Awaitable[None]] -AsyncHandlerManager: TypeAlias = AbstractAsyncContextManager[ - Callable[[T], Awaitable[None]] # Handler[T] -] -SyncHandler: TypeAlias = Callable[[T], None] -SyncHandlerManager: TypeAlias = AbstractAsyncContextManager[ - Callable[[T], None] # SyncHandler[T] -] - -AnyHandler: TypeAlias = Callable[[T], Awaitable[None] | None] -# AnyHandlerSource: TypeAlias = Callable[[], AsyncGenerator[Handler[T]] | Generator[Handler[T]]] -# AnyHandlerManager: TypeAlias = Union[ -# AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], # HandlerManager[T] -# AbstractAsyncContextManager[Callable[[T], None]], # SyncHandlerManager[T] -# ] -AnyHandlerManager: TypeAlias = AbstractAsyncContextManager[ - Callable[[T], Awaitable[None] | None] # Any (sync or async) handler -] - -HandlerMiddleware: TypeAlias = Callable[ - ["FlowHandler[T]"], # Next handler, as FlowHandler[T] - AsyncGenerator[ - Callable[[T2], Awaitable[None] | None] | tuple[Callable[[T2], Awaitable[None]], Callable[[T2], None]], None - ], # FlowHandler[T2] or a handler func -] -_HandlerMiddleware: TypeAlias = Callable[ - ["FlowHandler[T]"], # Next handler, as FlowHandler[T] - AbstractAsyncContextManager[Callable[[T2], Awaitable[None] | None]], # FlowHandler[T2] or a handler func -] - -HandlerDecorator: TypeAlias = Callable[ - ["FlowHandlerManager[T]"], # Next (wrapped) handler - "FlowHandlerManager[T2]", # Resulting handler -] - -logger = logging.getLogger("localpost.flow") - - -# Too complex case, maybe for the future -# def handler_manager_factory( -# func: Union[ -# Callable[P, HandlerManager[T]], -# Callable[P, AbstractContextManager[Handler[T]]], -# Callable[P, AsyncGenerator[Handler[T]]], -# Callable[P, Generator[Handler[T]]], -# ], -# ) -> Callable[P, HandlerManager[T]]: -# ... - - -def handler(func: AnyHandler[T]) -> FlowHandlerManager[T]: - """ - Wrap a function, so it can be used as a flow handler. - """ - assert callable(func) - assert not inspect.isgeneratorfunction(func) - assert not inspect.isasyncgenfunction(func) - return FlowHandlerManager(lambda: nullcontext(FlowHandler.ensure(func))) - - -def handler_manager( - func: Callable[[], AsyncGenerator[AnyHandler[T]]], -) -> FlowHandlerManager[T]: - """ - Wrap a generator, so it can be used as a flow handler manager. - """ - assert inspect.isasyncgenfunction(func) - return FlowHandlerManager(asynccontextmanager(func)) - - -def _ensure_handler_manager_factory( - source: Callable[P, AbstractAsyncContextManager[AnyHandler[T]]], -) -> Callable[P, AbstractAsyncContextManager[FlowHandler[T]]]: - @asynccontextmanager - async def _handler_manager(*args, **kwargs): - async with source(*args, **kwargs) as h: - yield FlowHandler.ensure(h) - - return _handler_manager - - -def ensure_async_handler( - h: AnyHandler[T], - /, - *, - max_threads: int | float | CapacityLimiter | None = None, -) -> AsyncHandler[T]: - if isinstance(h, FlowHandler): - handle_async = h.async_h if h.is_async else ensure_async_callable(h.sync_h, max_threads=max_threads) - else: - handle_async = ensure_async_callable(h, max_threads=max_threads) - return handle_async - - -def ensure_async_handler_manager(source: AnyHandlerManager[T]) -> AsyncHandlerManager[T]: - @asynccontextmanager - async def _async_handler_manager(): - async with source as h: - yield h.async_h if isinstance(h, FlowHandler) else ensure_async_callable(h) - - return _async_handler_manager() # type: ignore[return-value] - - -def ensure_sync_handler(source: AnyHandler[T]) -> SyncHandler[T]: - if isinstance(source, FlowHandler): - return source.sync_h - return ensure_sync_callable(source) - - -def ensure_sync_handler_manager(source: AnyHandlerManager[T]) -> SyncHandlerManager[T]: - @asynccontextmanager - async def _sync_handler_manager(): - async with source as h: - yield h.sync_h if isinstance(h, FlowHandler) else ensure_sync_callable(h) - - return _sync_handler_manager() # type: ignore[return-value] - - -@dc.dataclass(frozen=True, slots=True) -class _HandlerMiddlewareDecorator(Generic[T, T2]): - middleware: _HandlerMiddleware[T, T2] - - def __call__(self, next_hm: FlowHandlerManager[T]) -> FlowHandlerManager[T2]: - return next_hm << self - - -def handler_middleware(m: HandlerMiddleware[T, T2], /) -> HandlerDecorator[T, T2]: - return _HandlerMiddlewareDecorator(_handler_middleware(m)) - - -def _handler_middleware(m: HandlerMiddleware[T, T2]) -> _HandlerMiddleware[T, T2]: - assert inspect.isasyncgenfunction(m) - source = asynccontextmanager(m) - - @asynccontextmanager - async def _handler_manager(next_h: FlowHandler[T]): - async with source(next_h) as h: - if isinstance(h, tuple): - assert len(h) == 2 - async_h, sync_h = h # If the handler is a tuple, it should be (async_h, sync_h) - yield next_h.create(async_h=async_h, sync_h=sync_h) - else: - yield FlowHandler.ensure(h) - - return _handler_manager - - -@final -@dc.dataclass(eq=False, kw_only=True) -class FlowHandler(Generic[T]): - is_async: bool - async_h: Callable[[T], Awaitable[None]] - sync_h: Callable[[T], None] - - def __call__(self, item: T, /) -> Awaitable[None] | None: - return self.async_h(item) if self.is_async else self.sync_h(item) # type: ignore[func-returns-value] - - def __post_init__(self): - self.__call__ = self.async_h if self.is_async else self.sync_h # type: ignore[assignment] - - @classmethod - def ensure(cls, h: AnyHandler[T]) -> FlowHandler[T]: - assert callable(h) - if isinstance(h, FlowHandler): - return h - if is_async_callable(h): - return cls.create_async(async_h=cast(AsyncHandler[T], h)) - else: - return cls.create_sync(sync_h=cast(SyncHandler[T], h)) - - @classmethod - def create_async( - cls, *, async_h: AsyncHandler[T] | None = None, sync_h: SyncHandler[T] | None = None - ) -> FlowHandler[T]: - if async_h is None and sync_h is None: - raise ValueError("At least one of async_h or sync_h must be provided") - return cls( - is_async=True, - async_h=async_h if async_h else ensure_async_handler(sync_h), # type: ignore - sync_h=sync_h if sync_h else ensure_sync_handler(async_h), # type: ignore - ) - - @classmethod - def create_sync( - cls, *, async_h: AsyncHandler[T] | None = None, sync_h: SyncHandler[T] | None = None - ) -> FlowHandler[T]: - if async_h is None and sync_h is None: - raise ValueError("At least one of async_h or sync_h must be provided") - return cls( - is_async=False, - async_h=async_h if async_h else ensure_async_handler(sync_h), # type: ignore - sync_h=sync_h if sync_h else ensure_sync_handler(async_h), # type: ignore - ) - - def create( - self, - *, - async_h: Callable[[T2], Awaitable[None]] | None = None, - sync_h: Callable[[T2], None] | None = None, - ) -> FlowHandler[T2]: - if self.is_async: - return FlowHandler.create_async(async_h=async_h, sync_h=sync_h) - else: - return FlowHandler.create_sync(async_h=async_h, sync_h=sync_h) - - -@final -# class _HandlerManagerFactory( -class FlowHandlerManager( # AnyHandlerManager[T] - Generic[T], - AbstractAsyncContextManager[FlowHandler[T]], -): - _source: Callable[..., AbstractAsyncContextManager[Callable[[T], Awaitable[None] | None]]] - _middlewares: tuple[_HandlerMiddleware, ...] - - _resolved_hm: AbstractAsyncContextManager[FlowHandler[T]] | None - - def __init__( - self, factory: Callable[..., AbstractAsyncContextManager[Callable[[T], Awaitable[None] | None]]] - ) -> None: - self._source = factory # Maybe accept static arguments later - self._resolved_hm = None - self._middlewares = () - - def __call__(self, *args, **kwargs) -> AbstractAsyncContextManager[FlowHandler[T]]: - if not self._middlewares: - return _ensure_handler_manager_factory(self._source)(*args, **kwargs) - - @asynccontextmanager - async def _composite_handler_manager(): - hm = self._source(*args, **kwargs) - async with AsyncExitStack() as es: - for middleware in self._middlewares: - h = await es.enter_async_context(hm) - hm = middleware(FlowHandler.ensure(h)) - h = await es.enter_async_context(hm) - yield FlowHandler.ensure(h) - - return _composite_handler_manager() - - def _use(self, m: _HandlerMiddleware[T, T2]) -> FlowHandlerManager[T2]: - f = FlowHandlerManager(self._source) - f._middlewares = self._middlewares + (m,) - return cast(FlowHandlerManager[T2], f) - - def use(self, m: HandlerMiddleware[T, T2], /) -> FlowHandlerManager[T2]: - return self._use(_handler_middleware(m)) - - # handler << handler_decorator - def __lshift__(self, t: HandlerDecorator[T, T2]) -> FlowHandlerManager[T2]: - if isinstance(t, _HandlerMiddlewareDecorator): - return self._use(t.middleware) - return t(self) - - # handler | service_template - def __xor__(self, service_template: Callable[[AnyHandlerManager[T]], T2]) -> T2: - return service_template(self) - - async def __aenter__(self): - assert not self._resolved_hm # Protect against re-entering - self._resolved_hm = self() - return await self._resolved_hm.__aenter__() - - async def __aexit__(self, exc_type, exc_value, traceback): - assert self._resolved_hm - try: - return await self._resolved_hm.__aexit__(exc_type, exc_value, traceback) - finally: - self._resolved_hm = None diff --git a/localpost/flow/_ops.py b/localpost/flow/_ops.py deleted file mode 100644 index 4f3b9f4..0000000 --- a/localpost/flow/_ops.py +++ /dev/null @@ -1,291 +0,0 @@ -from __future__ import annotations - -import math -import time -from collections.abc import Awaitable, Callable, Collection, Iterable, Sequence -from typing import Any, Literal, overload - -from anyio import fail_after -from typing_extensions import TypeVar - -from localpost._utils import ( - DelayFactory, - MemoryStream, - ensure_async_callable, - ensure_delay_factory, - ensure_int_or_inf, - ensure_sync_callable, - sleep, -) - -from ._flow import ( - FlowHandler, - HandlerDecorator, - ensure_async_handler, - handler_middleware, - logger, -) -from ._stream import BatchReceiver, create_stream_consumer - -T = TypeVar("T", default=Any) -T2 = TypeVar("T2", default=Any) -TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) -R = TypeVar("R", Awaitable[None], None) - - -def delay(value: DelayFactory, /) -> HandlerDecorator[Any, Any]: - jitter_f = ensure_delay_factory(value) - - @handler_middleware - async def middleware(next_h: FlowHandler): - async def _handle_async(item): - item_jitter = jitter_f() - await sleep(item_jitter) - await next_h.async_h(item) - - def _handle_sync(item): - item_jitter = jitter_f() - time.sleep(item_jitter.total_seconds()) - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware - - -def log_errors(custom_logger=None, /) -> HandlerDecorator[Any, Any]: - h_logger = custom_logger or logger - - @handler_middleware - async def middleware(next_h: FlowHandler): - async def _handle_async(item): - try: - await next_h.async_h(item) - except Exception: - h_logger.exception("Error while processing a message") - - def _handle_sync(item): - try: - next_h.sync_h(item) - except Exception: - h_logger.exception("Error while processing a message") - - yield _handle_async, _handle_sync - - return middleware - - -# Does NOT work, as we cannot _stop_ the source (events) from the handler -# def take_first(n: int, /): ... - - -def skip_first(n: int, /) -> HandlerDecorator[Any, Any]: - if n < 1: - raise ValueError("n must be greater than or equal to 1") - - @handler_middleware - async def middleware(next_h: FlowHandler): - iter_n = 0 - - async def _handle_async(item): - nonlocal iter_n - if iter_n < n: - iter_n += 1 - else: - await next_h.async_h(item) - - def _handle_sync(item): - nonlocal iter_n - if iter_n < n: - iter_n += 1 - else: - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware - - -def buffer( - capacity: int | float, - /, - *, - concurrency: int | float = 1, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Any, Any]: - """Buffer items in an async in-memory stream.""" - - @handler_middleware - async def middleware(next_h: FlowHandler): - buffer_writer, buffer_reader = MemoryStream.create(capacity) - stream_h = ensure_async_handler(next_h, max_threads=concurrency) - # As usual, order matters - async with create_stream_consumer(buffer_reader, stream_h, concurrency=concurrency): - async with buffer_writer: - if math.isinf(capacity) or full_mode == "drop": - yield buffer_writer.send_or_drop, buffer_writer.send_or_drop_from_thread - else: - yield buffer_writer.send - if not process_leftovers: - buffer_reader.close() - - ensure_int_or_inf(capacity, min_value=0, name="Buffer capacity") - ensure_int_or_inf(concurrency, min_value=1, name="Concurrency") - return middleware - - -# Maybe implement later -# def sync_buffer( -# capacity: float, -# /, -# *, -# consumers: int = 1, -# process_leftovers: bool = True, -# full_mode: Literal["wait", "drop"] = "wait", -# ) -> HandlerDecorator[Any, Any]: -# pass - - -@overload -def batch( - batch_size: int, - batch_window: int | float, # Seconds - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Sequence[Any], Any]: ... - - -@overload -def batch( - batch_size: int, - batch_window: int | float, # Seconds - items_f: Callable[[Sequence[T]], TC], - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[TC, T]: ... - - -def batch( - batch_size: int, - batch_window: int | float, # Seconds - items_f: Callable[[list[T]], TC] | None = None, - /, - *, - capacity: int | float = 0, - process_leftovers: bool = True, - full_mode: Literal["wait", "drop"] = "wait", -) -> HandlerDecorator[Any, T]: - """ - Collect items into batches. - - A new batch is produced when `batch_size` is reached or `batch_window` expires. - """ - if batch_size < 1: - raise ValueError("Batch size must be greater than or equal to 1") - if batch_window <= 0: - raise ValueError("Batch window must be greater than 0") - ensure_int_or_inf(capacity, min_value=0, name="Buffer capacity") - - @handler_middleware - async def _middleware(next_h: FlowHandler[Collection[object]]): - buffer_writer, buffer_reader = MemoryStream[T].create(capacity) - buffer_batch_reader = BatchReceiver[T, TC]( - buffer_reader, batch_size=batch_size, batch_window=batch_window, items_f=items_f - ) - stream_h = ensure_async_handler(next_h) - async with create_stream_consumer(buffer_batch_reader, stream_h, concurrency=1): - async with buffer_writer: - if math.isinf(capacity) or full_mode == "drop": - yield buffer_writer.send_or_drop, buffer_writer.send_or_drop_from_thread - else: - yield buffer_writer.send - if not process_leftovers: - buffer_reader.close() - - return _middleware - - -def timeout(duration: float, /) -> HandlerDecorator[T, T]: - """Async timeout middleware.""" - - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async def _handle_async(item: T): - with fail_after(duration): - await next_h.async_h(item) - - yield _handle_async - - return middleware - - -def filter( # noqa - func: Callable[[T], Awaitable[bool]] | Callable[[T], bool], -) -> HandlerDecorator[T, T]: - async_filter = ensure_async_callable(func) - sync_filter = ensure_sync_callable(func) - - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async def _handle_async(item: T): - if await async_filter(item): - await next_h.async_h(item) - - def _handle_sync(item: T): - if sync_filter(item): - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware - - -def map( # noqa - func: Callable[[T2], Awaitable[T]] | Callable[[T2], T], -) -> HandlerDecorator[T, T2]: - async_mapper = ensure_async_callable(func) - sync_mapper = ensure_sync_callable(func) - - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async def _handle_async(item: T2): - mapped = await async_mapper(item) - await next_h.async_h(mapped) - - def _handle_sync(item: T2): - mapped = sync_mapper(item) - next_h.sync_h(mapped) - - yield _handle_async, _handle_sync - - return middleware - - -def flatmap( - func: Callable[[T2], Awaitable[Iterable[T]]] | Callable[[T2], Iterable[T]], -) -> HandlerDecorator[T, T2]: - @handler_middleware - async def middleware(next_h: FlowHandler[T]): - async_mapper: Callable[[T2], Awaitable[Iterable[T]]] = ensure_async_callable(func) - sync_mapper: Callable[[T2], Iterable[T]] = ensure_sync_callable(func) - - async def _handle_async(item: T2) -> None: - mapped = await async_mapper(item) - for mapped_item in mapped: - await next_h.async_h(mapped_item) - - def _handle_sync(item: T2) -> None: - mapped = sync_mapper(item) - for mapped_item in mapped: - next_h.sync_h(mapped_item) - - yield _handle_async, _handle_sync - - return middleware diff --git a/localpost/flow/_queue.py b/localpost/flow/_queue.py deleted file mode 100644 index 8e3741a..0000000 --- a/localpost/flow/_queue.py +++ /dev/null @@ -1,95 +0,0 @@ -import dataclasses as dc -import queue -import sys -import time -from collections.abc import Callable, Collection -from queue import Queue, SimpleQueue -from typing import Generic, Protocol, TypeVar - -from anyio import ClosedResourceError, from_thread - -if sys.version_info >= (3, 13): - from queue import ShutDown -else: - - class ShutDown(Exception): - pass - - -T = TypeVar("T", covariant=True) -TC = TypeVar("TC", bound=Collection[object]) - -CHECK_INTERVAL = 0.1 # How often to check for cancellation or shutdown, seconds - - -class ReceiveStream(Protocol[T]): - def receive(self) -> T: ... - - def close(self): ... - - -@dc.dataclass(eq=False, slots=True) -class Receiver(Generic[T]): - queue: Queue[T] | SimpleQueue[T] - closed: bool = False - - def receive(self) -> T: - def checkpoint(): - from_thread.check_cancelled() - if self.closed: - raise ClosedResourceError() - - while True: - checkpoint() - try: - return self.queue.get(timeout=CHECK_INTERVAL) - except queue.Empty: - continue - - def close(self): - self.closed = True - - -@dc.dataclass(eq=False, slots=True) -class BatchReceiver(Generic[T, TC]): - queue: Queue[T] | SimpleQueue[T] - batch_size: int - batch_window: float - items_f: Callable[[list[T]], TC] - closed: bool = False - - def receive(self) -> TC: - def checkpoint(): - from_thread.check_cancelled() - if self.closed: - raise ClosedResourceError() - - def read_batch() -> list[T]: - batch: list[T] = [] - batch_started_at = time.monotonic() - batch_age = 0.0 - while (len(batch) < self.batch_size) and (batch_age < self.batch_window): - checkpoint() - batch_age = time.monotonic() - batch_started_at - try: - message = self.queue.get(timeout=CHECK_INTERVAL) - batch.append(message) - except queue.Empty: - continue - return batch - - empty_batch: list[T] = [] - while True: - items = empty_batch - try: - items = read_batch() - if not items: - continue - return self.items_f(items) - except ShutDown: - if items: - return self.items_f(items) # Return the last batch first - raise - - def close(self): - self.closed = True diff --git a/localpost/flow/_stream.py b/localpost/flow/_stream.py deleted file mode 100644 index 39fcff6..0000000 --- a/localpost/flow/_stream.py +++ /dev/null @@ -1,99 +0,0 @@ -import dataclasses as dc -import math -from collections.abc import AsyncGenerator, Callable, Collection -from contextlib import asynccontextmanager -from typing import Any, Generic, cast, final - -from anyio import ClosedResourceError, EndOfStream, create_task_group, move_on_after -from anyio.abc import ObjectReceiveStream -from typing_extensions import TypeVar - -from localpost._utils import Event, EventView, start_task_soon - -from ._flow import AsyncHandler, logger - -T = TypeVar("T", default=Any) -TC = TypeVar("TC", bound=Collection[object], default=Collection[object]) - - -@final -@dc.dataclass(frozen=True, eq=False, slots=True) -class StreamConsumerState: - closed: EventView - - -@asynccontextmanager -async def create_stream_consumer( - stream: ObjectReceiveStream[T], - h: AsyncHandler[T], - /, - *, - concurrency: int | float = 1, -) -> AsyncGenerator[StreamConsumerState, None]: - async def schedule_handler(item: T) -> None: - # Infinite concurrency, just spawn a new task for each item - tg.start_soon(h, item) - - handle = schedule_handler if math.isinf(concurrency) else h - - async def source_items(): - try: - while True: - yield await stream.receive() - except EndOfStream: - logger.debug("Source stream has been completed, no more items to process") - except ClosedResourceError: - logger.debug("Receiver has been closed (according to consumer's process_leftovers setting)") - finally: - closed.set() - - async def consume(): - async for item in source_items(): - await handle(item) - - closed = Event() - async with stream, create_task_group() as tg: - for _ in range(1 if math.isinf(concurrency) else cast(int, concurrency)): - start_task_soon(tg, consume) - yield StreamConsumerState(closed) - - -@final -class BatchReceiver(Generic[T, TC], ObjectReceiveStream[TC]): - def __init__( - self, - source: ObjectReceiveStream[T], - /, - *, - batch_size: int, - batch_window: float, - items_f: Callable[[list[T]], TC] | None = None, - ): - if batch_size < 1: - raise ValueError("Batch size must be at least 1") - if batch_window <= 0: - raise ValueError("Batch window must be greater than 0 seconds") - - self.source = source - self.batch_size = batch_size - self.batch_window = batch_window - self.items_f: Callable[[list[T]], TC] = items_f if items_f is not None else lambda x: cast(TC, x) - - async def receive(self) -> TC: - while True: - items: list[T] = [] - try: - with move_on_after(self.batch_window): - while len(items) < self.batch_size: - message = await self.source.receive() - items.append(message) - if not items: - continue - return self.items_f(items) - except EndOfStream: - if items: - return self.items_f(items) # Return the last batch first - raise - - async def aclose(self) -> None: - await self.source.aclose() diff --git a/localpost/http/README.md b/localpost/http/README.md index 72d3e62..68ba270 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -1,3 +1 @@ -# Custom HTTP/WSGI server - -A combination of a simple HTTP/WSGI server per thread, each thread is managed from the main thread (AnyIO event loop). +# Simple HTTP server for modern environments diff --git a/localpost/http/cli.py b/localpost/http/__main__.py similarity index 100% rename from localpost/http/cli.py rename to localpost/http/__main__.py diff --git a/localpost/http/server.py b/localpost/http/server.py index d8d1525..f9c04e5 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -248,6 +248,9 @@ def _receive(self, size: int, /) -> bytes: while True: event = conn.next_event() if event is h11.NEED_DATA: + if conn.they_are_waiting_for_100_continue: + sock.sendall(conn.send( + h11.InformationalResponse(status_code=100, headers=[], reason="Continue"))) conn.receive_data(sock.recv(size)) elif isinstance(event, h11.Data): return event.data diff --git a/localpost/http/worker.py b/localpost/http/worker.py index 0b8a6de..38811da 100644 --- a/localpost/http/worker.py +++ b/localpost/http/worker.py @@ -12,7 +12,7 @@ import anyio from anyio import CancelScope, create_task_group, from_thread, to_thread -from localpost._sync_utils import _acquire +from localpost._sync_utils import acquire_sem from localpost.http.config import WorkerConfig from localpost.http.server import Server, start_http_server from localpost.http.wsgi import wrap_wsgi @@ -36,15 +36,12 @@ def handle_client(c): def handle_client_thread(c) -> Awaitable[None]: return to_thread.run_sync(handle_client, c, limiter=req_threads) - def schedule_client_handler(c): - # Handle each client connection in a separate thread - from_thread.run_sync(tg.start_soon, handle_client_thread, c) - def handle_clients(): - _acquire(req_sem) + acquire_sem(req_sem) for c in server.accept(): - schedule_client_handler(c) - _acquire(req_sem) + # Handle each client connection in a separate thread + from_thread.run_sync(tg.start_soon, handle_client_thread, c) + acquire_sem(req_sem) def handle_clients_thread() -> Awaitable[None]: return to_thread.run_sync(handle_clients, limiter=anyio.CapacityLimiter(1)) @@ -60,9 +57,9 @@ def handle_clients_thread() -> Awaitable[None]: class Worker: server: Server config: WorkerConfig - _cancel_scope: CancelScope + _stop_scope: CancelScope - def shutdown(self) -> None: + def close(self) -> None: """Graceful shutdown (stop handling new connections, wait for in-flight requests).""" self.server.close() @@ -78,7 +75,7 @@ async def _run(): async with serve(simple_app, WorkerConfig()) as w: with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: async for _ in signals: - w.shutdown() + w.close() break # noinspection PyTypeChecker From b82d783f7ba6ea7e015fc84270a6f961ad2f20db Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Feb 2026 19:52:37 +0000 Subject: [PATCH 022/286] WIP (just working consumers) --- localpost/consumers/_sqs_types.py | 78 ----- localpost/consumers/_utils.py | 21 ++ localpost/consumers/kafka.py | 282 ------------------ localpost/consumers/kafka_otel.py | 81 ------ localpost/consumers/kafka_protobuf.py | 47 --- localpost/consumers/queue.py | 87 ++++++ localpost/consumers/sqs.py | 396 -------------------------- localpost/consumers/sqs_otel.py | 76 ----- localpost/consumers/stream_otel.py | 96 ------- localpost/http/config.py | 4 +- 10 files changed, 110 insertions(+), 1058 deletions(-) delete mode 100644 localpost/consumers/_sqs_types.py create mode 100644 localpost/consumers/_utils.py delete mode 100644 localpost/consumers/kafka.py delete mode 100644 localpost/consumers/kafka_otel.py delete mode 100644 localpost/consumers/kafka_protobuf.py create mode 100644 localpost/consumers/queue.py delete mode 100644 localpost/consumers/sqs.py delete mode 100644 localpost/consumers/sqs_otel.py delete mode 100644 localpost/consumers/stream_otel.py diff --git a/localpost/consumers/_sqs_types.py b/localpost/consumers/_sqs_types.py deleted file mode 100644 index 3cb25c9..0000000 --- a/localpost/consumers/_sqs_types.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import TypedDict - -try: - from types_boto3_sqs import SQSClient as BotoSqsClient -except ImportError: - # BotoSqsClient: TypeAlias = Any - # Same trick as in types_boto3_sqs stubs - from typing import Any as BotoSqsClient # type: ignore[assignment] # noqa - -try: - from types_boto3_sqs.literals import MessageSystemAttributeNameType - from types_boto3_sqs.type_defs import ( - MessageAttributeValueOutputTypeDef, - MessageTypeDef, - ReceiveMessageRequestTypeDef, - ) -except ImportError: - # Same trick as in types_boto3_sqs stubs - from typing import Any as MessageSystemAttributeNameType # type: ignore[assignment] # noqa - from typing import Any as MessageAttributeValueOutputTypeDef # type: ignore[assignment] # noqa - from typing import Any as MessageTypeDef # type: ignore[assignment] # noqa - from typing import Any as ReceiveMessageRequestTypeDef # type: ignore[assignment] # noqa - - -class LambdaEventRecordMessageAttributeValue(TypedDict): - dataType: str - stringValue: str - binaryValue: bytes - stringListValues: Sequence[str] - binaryListValues: Sequence[bytes] - - -class LambdaEventRecord(TypedDict): - """ - One record in the Lambda event. - - Example: - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185" - }, - "messageAttributes": { - "myAttribute": { - "stringValue": "myValue", - "stringListValues": [], - "binaryListValues": [], - "dataType": "String" - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2" - } - """ - - messageId: str - receiptHandle: str - body: str - attributes: Mapping[MessageSystemAttributeNameType, str] - messageAttributes: Mapping[str, LambdaEventRecordMessageAttributeValue] - md5OfBody: str - eventSource: str - eventSourceARN: str - awsRegion: str - - -class LambdaEvent(TypedDict): - Records: Sequence[LambdaEventRecord] diff --git a/localpost/consumers/_utils.py b/localpost/consumers/_utils.py new file mode 100644 index 0000000..516c9f4 --- /dev/null +++ b/localpost/consumers/_utils.py @@ -0,0 +1,21 @@ +from collections.abc import Callable, Awaitable + +from anyio import CapacityLimiter, to_thread, from_thread + +from localpost._utils import is_async_callable + +type SyncHandler[T] = Callable[[T], None] +type AsyncHandler[T] = Callable[[T], Awaitable[None]] +type AnyHandler[T] = SyncHandler[T] | AsyncHandler[T] + + +def ensure_async_handler(handler, limiter: CapacityLimiter = None) -> AsyncHandler: + if not is_async_callable(handler): + return lambda x: to_thread.run_sync(handler, x, limiter=limiter) + return handler + + +def ensure_sync_handler(handler) -> SyncHandler: + if is_async_callable(handler): + return lambda x: from_thread.run(handler, x) + return handler diff --git a/localpost/consumers/kafka.py b/localpost/consumers/kafka.py deleted file mode 100644 index e1f1a7b..0000000 --- a/localpost/consumers/kafka.py +++ /dev/null @@ -1,282 +0,0 @@ -import dataclasses as dc -import logging -import os -from collections.abc import Callable, Collection, Mapping, Sequence -from contextlib import AbstractAsyncContextManager, AbstractContextManager, AsyncExitStack, asynccontextmanager -from functools import partial -from typing import Any, Final, TypeAlias, cast, final, overload - -import confluent_kafka -from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread -from confluent_kafka import TIMESTAMP_NOT_AVAILABLE - -from localpost.flow import AnyHandlerManager, SyncHandler, ensure_sync_handler -from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager - -__all__ = [ - "KafkaMessage", - "KafkaMessages", - "ConsumerClient", - "kafka_config_from_env", - "kafka_consumer", -] - -logger = logging.getLogger(__name__) - -CHECK_INTERVAL: Final = 0.5 # seconds - - -@final -class ConsumerClient: - @classmethod - @asynccontextmanager - async def create(cls, *topics: str, **config): - rdkafka_config = {k.lower().replace("_", "."): v for k, v in config.items()} - # https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#consumer - transport = confluent_kafka.Consumer(rdkafka_config, logger=logger) # type: ignore[call-arg] - client = cls(transport, rdkafka_config, topics) - try: - await to_thread.run_sync(transport.subscribe, client.topics) # type: ignore[call-arg] - yield client - finally: - await to_thread.run_sync(transport.close) - - def __init__(self, transport: confluent_kafka.Consumer, config: Mapping[str, Any], topics: Collection[str]) -> None: - if not topics: - raise ValueError("At least one topic must be specified") - - self.config: Mapping[str, Any] = dict(config) - self.topics: Sequence[str] = list(topics) - self._client = transport - - def poll(self) -> confluent_kafka.Message | None: - return self._client.poll(CHECK_INTERVAL) # Interrupt periodically, so we can respect the cancellation - - def store_offset(self, message: confluent_kafka.Message) -> None: - """ - Store the offset of the message, so it won't be redelivered (but only when `enable.auto.offset.store` is - actually disabled). - """ - if not self.config.get("enable.auto.offset.store", True): - self._client.store_offsets(message) - - -ClientFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[ConsumerClient]] - - -@final -@dc.dataclass(frozen=True, eq=False, slots=True) -class KafkaMessage(Sequence["KafkaMessage"], AbstractContextManager[bytes, None]): - payload: confluent_kafka.Message - _client: ConsumerClient - - def __repr__(self): - return f"{self.__class__.__name__}(topic={self.payload.topic()!r})" - - def __len__(self): - return 1 - - def __getitem__(self, i): - if i == 0: - return self - raise IndexError() - - def __enter__(self) -> bytes: - return self.value - - def __exit__(self, exc_type, _, __) -> None: - if exc_type is None: - self.try_ack() - - @property - def key(self) -> bytes | None: - return self.payload.key() - - @property - def timestamp(self) -> int | None: - ts_type, ts = self.payload.timestamp() - return None if ts_type == TIMESTAMP_NOT_AVAILABLE else ts - - @property - def value(self) -> bytes: - return self.payload.value() - - def try_ack(self) -> None: - """ - Store the offset of the message, so it won't be redelivered (but only when `enable.auto.offset.store` is - actually disabled). - """ - if not self._client.config.get("enable.auto.offset.store", True): - self.ack() - - def ack(self) -> None: - """ - Store the offset of the message, so it won't be redelivered. - - Works only if 'enable.auto.offset.store' is set to False! - """ - self._client.store_offset(self.payload) # Actual commit is done in the background - - -@final -@dc.dataclass(frozen=True, slots=True) -class KafkaMessages(Sequence[KafkaMessage], AbstractContextManager[Sequence[bytes], None]): - """ - Non-empty batch of Kafka messages. - """ - - source: Sequence[KafkaMessage] - - def __init__(self, source: Sequence[KafkaMessage]) -> None: - if isinstance(source, KafkaMessages): - source = source.source - if len(source) == 0: - raise ValueError(f"{self.__class__.__name__} must not be empty") - object.__setattr__(self, "source", source) - - def __repr__(self): - return f"{self.__class__.__name__}(len={len(self)})" - - def __len__(self): - return len(self.payload) - - def __getitem__(self, i) -> KafkaMessage: - return self.source[i] # type: ignore[return-value] - - def __enter__(self) -> Sequence[bytes]: - return [msg.value for msg in self.source] - - def __exit__(self, exc_type, _, __) -> None: - if exc_type is None: - self.try_ack() - - @property - def payload(self) -> Sequence[confluent_kafka.Message]: - return [msg.payload for msg in self.source] - - def try_ack(self) -> None: - for message in self.payload: - message.try_ack() - - def ack(self) -> None: - for message in self.payload: - message.ack() - - -async def _consume_sync( - client: ConsumerClient, message_handler: SyncHandler[KafkaMessage], shutdown_scope: CancelScope -) -> None: - def consume(): - while True: - from_thread.check_cancelled() - poll_res = client.poll() - if poll_res is None: - continue - if error := poll_res.error(): - if error.retriable(): - logger.warning("Kafka (non-fatal) error: [%s] %s", error.code(), error.str()) - continue - if error.fatal(): - raise RuntimeError(error.str()) - message = KafkaMessage(poll_res, client) - message_handler(message) - - # In Trio shutdown_scope.cancel_called can only be checked in async context - with shutdown_scope: - await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) - - -@final -class KafkaConsumerService(ExposedServiceBase): - def __init__( - self, - cf: ClientFactory, - handler_m: AnyHandlerManager[KafkaMessage], - /, - *, - num_consumers: int, - ): - super().__init__() - - if num_consumers < 1: - raise ValueError("Number of consumers must be at least 1") - - self.client_factory = cf - self.handler_m = handler_m - self.num_consumers = num_consumers - - async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: - assert self.num_consumers > 0 - self._lifetime = service_lifetime # Expose service lifetime events - - # Graceful shutdown scopes, one per consumer task (thread) - consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] - - async with AsyncExitStack() as client_stack, self.handler_m as handler: - logger.debug("Creating Kafka clients (1 per consumer)...") - # Although Confluent Kafka's Consumer is thread-safe, it's not intended to be used concurrently: - # - by default, a message received from poll() is automatically acknowledged - # - OTEL instrumentation stores the current span in an object field, so concurrent calls to poll() will - # mess up the traces - clients = [await client_stack.enter_async_context(self.client_factory()) for _ in range(self.num_consumers)] - message_handler = ensure_sync_handler(handler) - service_lifetime.set_started() - # Start pulling messages only after the whole app is started - await service_lifetime.host.started - async with create_task_group() as tg: - for c, cs in zip(clients, consumer_scopes, strict=False): - tg.start_soon(_consume_sync, c, message_handler, cs) - await service_lifetime.shutting_down - for cs in consumer_scopes: - cs.cancel() - - -def kafka_client_factory(*topics: str, **config) -> ClientFactory: - return partial(ConsumerClient.create, *topics, **config) - - -def kafka_config_from_env(**overrides) -> dict[str, Any]: - """ - Construct a configuration dictionary from KAFKA_* environment variables. - - When translating Kafka's properties, use upper case and replace "." with "_": - - bootstrap.servers -> KAFKA_BOOTSTRAP_SERVERS - - max.in.flight.requests.per.connection -> KAFKA_MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION - - ... - - Properties reference: https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md - See also: https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/index.html#pythonclient-configuration - """ - - def _read_env_vars(): - for var_name, var_val in os.environ.items(): - if var_name.startswith("KAFKA_"): - yield var_name[6:].lower(), var_val - - conf_from_env = dict(_read_env_vars()) - return conf_from_env | overrides - - -@overload -def kafka_consumer( - cf: ClientFactory, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: - """Decorator to create a Kafka consumer hosted service.""" - - -@overload -def kafka_consumer( - *topics: str, num_consumers: int = 1, **config: Any -) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: - """Decorator to create a Kafka consumer hosted service.""" - - -# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function -def kafka_consumer( - *topics_or_cf: Any, num_consumers: int = 1, **config: Any -) -> Callable[[AnyHandlerManager[KafkaMessage]], KafkaConsumerService]: - if len(topics_or_cf) == 1 and callable(topics_or_cf[0]): - cf = cast(ClientFactory, topics_or_cf[0]) - else: - cf = kafka_client_factory(*topics_or_cf, **config) - return lambda handler_m: KafkaConsumerService(cf, handler_m, num_consumers=num_consumers) diff --git a/localpost/consumers/kafka_otel.py b/localpost/consumers/kafka_otel.py deleted file mode 100644 index d9fb932..0000000 --- a/localpost/consumers/kafka_otel.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Sequence -from contextlib import AbstractContextManager, contextmanager -from typing import TypeVar - -from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( - create_messaging_client_consumed_messages, - create_messaging_client_operation_duration, -) -from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider -from opentelemetry.util.types import AttributeValue - -from localpost import __version__ -from localpost._otel_utils import rec_duration -from localpost.consumers.kafka import KafkaMessage -from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware - -T = TypeVar("T", KafkaMessage, Sequence[KafkaMessage]) - -__all__ = ["trace"] - - -def create_message_tracer( - tp: TracerProvider | None, - mp: MeterProvider | None, -) -> Callable[[KafkaMessage | Sequence[KafkaMessage]], AbstractContextManager[None]]: - tracer = (tp or get_tracer_provider()).get_tracer(__name__, __version__) - meter = (mp or get_meter_provider()).get_meter(__name__, __version__) - - # Based on Semantic Conventions 1.30.0, see - # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ - - m_process_duration = create_messaging_client_operation_duration(meter) - messages_consumed = create_messaging_client_consumed_messages(meter) - - @contextmanager - def call_tracer(messages: Sequence[KafkaMessage]): - assert len(messages) > 0, "Message batch must not be empty" - is_batch = isinstance(messages, KafkaMessage) - message = messages[0] - topic = message.payload.topic() - # https://opentelemetry.io/docs/specs/semconv/messaging/kafka/#apache-kafka-with-quarkus-or-spring-boot-example - attrs: dict[str, AttributeValue] = { - "messaging.system": "kafka", - "messaging.operation.name": "process", - "messaging.operation.type": "process", - "messaging.destination.name": topic, - "messaging.consumer.group.name": message._client.config.get("group.id", "unknown"), - } - if is_batch: - attrs["messaging.batch.message_count"] = len(messages) - else: - attrs["messaging.kafka.offset"] = message.payload.offset() - attrs["messaging.kafka.partition"] = message.payload.partition() - - messages_consumed.add(len(messages), attrs) - with tracer.start_as_current_span(f"process {topic}", kind=SpanKind.CONSUMER, attributes=attrs): - with rec_duration(m_process_duration, attrs): - yield - - return call_tracer - - -def trace(tp: TracerProvider | None = None, mp: MeterProvider | None = None, /) -> HandlerDecorator[T, T]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware diff --git a/localpost/consumers/kafka_protobuf.py b/localpost/consumers/kafka_protobuf.py deleted file mode 100644 index 08cbdd0..0000000 --- a/localpost/consumers/kafka_protobuf.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Kafka Protobuf deserializer. - -Mainly to show the approach on how to create a custom deserializer. Not intended to be a generic solution. -""" - -from __future__ import annotations - -import warnings -from collections.abc import Callable -from typing import ParamSpec, TypeAlias, TypeVar - -from confluent_kafka.schema_registry.protobuf import ProtobufDeserializer -from confluent_kafka.serialization import MessageField, SerializationContext -from google.protobuf.message import Message as PbMessage - -from localpost.consumers.kafka import KafkaMessage - -T = TypeVar("T", bound=PbMessage) -P = ParamSpec("P") - -__all__ = [ - "protobuf_deserializer_for", -] - -Deserializer: TypeAlias = Callable[[KafkaMessage | bytes], T] - - -# See also https://github.com/confluentinc/confluent-kafka-python/blob/master/examples/protobuf_consumer.py -def protobuf_deserializer_for(message_type: type[T]) -> Deserializer[T]: - # Confluent SDK uses some deprecated Protobuf methods, just skip it - # (Already fixed in confluent-kafka 2.6+?..) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - # "MessageFactory class is deprecated. Please use GetMessageClass() instead of MessageFactory.GetPrototype." - deserializer = ProtobufDeserializer( - message_type, # type: ignore[arg-type] - {"use.deprecated.format": False}, - ) - - def deserialize(m: KafkaMessage | bytes) -> T: - if isinstance(m, KafkaMessage): - context = SerializationContext(m.payload.topic(), MessageField.VALUE) - return deserializer(m.value, context) # type: ignore[return-value] - return deserializer(m) # type: ignore[return-value] - - return deserialize diff --git a/localpost/consumers/queue.py b/localpost/consumers/queue.py new file mode 100644 index 0000000..bde3aa5 --- /dev/null +++ b/localpost/consumers/queue.py @@ -0,0 +1,87 @@ +import sys +import threading +from collections.abc import AsyncIterator, Awaitable +from contextlib import asynccontextmanager +from queue import Queue, SimpleQueue + +import anyio +from anyio import create_task_group, CancelScope, to_thread, CapacityLimiter, from_thread + +from localpost._sync_utils import acquire_sem, pull_queue +from localpost.consumers._utils import AsyncHandler, ensure_sync_handler + +if sys.version_info >= (3, 13): + from queue import ShutDown +else: + class ShutDown(Exception): + pass + + +__all__ = ["queue_consumer"] + + +@asynccontextmanager +async def queue_consumer[T]( + stream: Queue[T] | SimpleQueue[T], + h: AsyncHandler[T], + /, + *, + max_concurrency: int, + process_leftovers: bool = True, + shutdown_timeout: float = 5.0, +) -> AsyncIterator[None]: + req_threads = CapacityLimiter(max_concurrency) + req_sem = threading.BoundedSemaphore(max_concurrency) + handler = ensure_sync_handler(h) + + def handle_item(item): + try: + handler(item) + finally: + req_sem.release() + + def handle_item_thread(i) -> Awaitable[None]: + return to_thread.run_sync(handle_item, i, limiter=req_threads) + + def handle_items(): + acquire_sem(req_sem) + while True: + from_thread.run_sync(tg.start_soon, handle_item_thread, pull_queue(stream)) + acquire_sem(req_sem) + + async def handle_items_thread(): + with shutdown_scope: + await to_thread.run_sync(handle_items, limiter=CapacityLimiter(1)) + + async with create_task_group() as tg: + shutdown_scope = CancelScope() + tg.start_soon(handle_items_thread) + yield + if not process_leftovers: + # Immediately stop consuming and ignore the remaining items + shutdown_scope.cancel() + else: + # Wait for the remaining items to be processed (until the source queue is closed) or + # until the shutdown timeout is reached + shutdown_scope.deadline = anyio.current_time() + shutdown_timeout + + +def _sample_usage(): + import logging + import anyio + + logging.basicConfig(level=logging.DEBUG) + + async def _run(): + async with serve(simple_app, WorkerConfig()) as w: + with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: + async for _ in signals: + w.shutdown() + break + + # noinspection PyTypeChecker + anyio.run(_run) + + +if __name__ == "__main__": + _sample_usage() diff --git a/localpost/consumers/sqs.py b/localpost/consumers/sqs.py deleted file mode 100644 index c985b50..0000000 --- a/localpost/consumers/sqs.py +++ /dev/null @@ -1,396 +0,0 @@ -from __future__ import annotations - -import dataclasses as dc -import logging -import math -from collections.abc import Callable, Collection, Iterable, Sequence -from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager -from functools import partial -from typing import Final, cast, final, overload -from urllib.parse import urlparse - -from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread - -from localpost import flow -from localpost._utils import MemoryStream, is_async_callable -from ._sqs_types import ( - BotoSqsClient, - LambdaEvent, - LambdaEventRecord, - LambdaEventRecordMessageAttributeValue, - MessageTypeDef, - ReceiveMessageRequestTypeDef, -) - -__all__ = [ - "SqsMessage", - "SqsMessages", - "ConsumerClient", - "lambda_handler", - "sqs_queue_consumer", -] - -logger = logging.getLogger(__name__) - -# Timeout for a sync pull request, to check the application state (and exit gracefully on shutdown) -CHECK_INTERVAL: Final = 3.0 # seconds - -_EMPTY_RECEIVE: Final[Sequence[MessageTypeDef]] = () - - -@final -class ConsumerClient: - @classmethod - @asynccontextmanager - async def create( - cls, - queue_name_or_url: str, - client: BotoSqsClient | None = None, - /, - *, - req_template: ReceiveMessageRequestTypeDef | None = None, - ): - def _queue_url_from_name(n): - return transport.get_queue_url(QueueName=n)["QueueUrl"] - - def get_client() -> BotoSqsClient: - if client is None: - try: - import boto3 - - return boto3.client("sqs") - except ImportError: - from botocore.session import get_session - - return get_session().create_client("sqs") # type: ignore[return-value] - return client - - transport = get_client() - if "/" in queue_name_or_url: - queue_url = queue_name_or_url - queue_name = _queue_name_from_url(queue_url) - else: - queue_name = queue_name_or_url - queue_url = await to_thread.run_sync(_queue_url_from_name, queue_name) - yield cls( - transport, - queue_name, - queue_url, - req_template - or { - "QueueUrl": queue_name, - "MessageAttributeNames": ["All"], - "MaxNumberOfMessages": 10, - "WaitTimeSeconds": int(CHECK_INTERVAL), # Long polling - }, - ) - - def __init__( - self, - client: BotoSqsClient, - queue_name: str, - queue_url: str, - receive_req: ReceiveMessageRequestTypeDef, - /, - ) -> None: - self._client = client - self.queue_name = queue_name - self.queue_url = queue_url - self.receive_req: ReceiveMessageRequestTypeDef = receive_req | {"QueueUrl": queue_url} - - def receive(self) -> Sequence[MessageTypeDef]: - # TODO Check HTTP status and retry on errors (exponential backoff) - pull_resp = self._client.receive_message(**self.receive_req) - return pull_resp.get("Messages", _EMPTY_RECEIVE) - - def delete(self, workload: Iterable[SqsMessage], /) -> None: - if isinstance(workload, SqsMessage): - self._client.delete_message( - QueueUrl=self.queue_url, - ReceiptHandle=workload.receipt_handle, - ) - else: - self._client.delete_message_batch( - QueueUrl=self.queue_url, - Entries=[ # type: ignore - {"Id": str(i), "ReceiptHandle": message.receipt_handle} for i, message in enumerate(workload) - ], - ) - - -@final -@dc.dataclass(frozen=True, slots=True) -class SqsMessage(Sequence["SqsMessage"], AbstractContextManager[str, None]): - payload: MessageTypeDef - """ - Raw message data from the SQS queue. - - See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_Message.html. - """ - - client: ConsumerClient | AsyncConsumerClient - ack_queue: ThreadSafeSendStream[SqsMessage] - - def __repr__(self): - return f"{self.__class__.__name__}(queue_name={self.client.queue_name!r})" - - def __len__(self): - return 1 - - def __getitem__(self, i): - if i == 0: - return self - raise IndexError() - - def __enter__(self) -> str: - return self.body - - def __exit__(self, exc_type, _, __) -> None: - if exc_type is None: - self.ack() - - def ack(self): - self.ack_queue.send_nowait(self) - - @property - def receipt_handle(self): - assert "ReceiptHandle" in self.payload - return self.payload["ReceiptHandle"] - - @property - def body(self) -> str: - assert "Body" in self.payload - return self.payload["Body"] - - # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-message-metadata.html#message-attribute-data-types - @property - def attributes(self) -> dict[str, str | bool | int | float | bytes]: - def extract_attrs(): - for attr_name, msg_attr in self.payload.get("MessageAttributes", {}).items(): - attr_type = msg_attr.get("DataType", "").lower() - str_val = msg_attr.get("StringValue") - if attr_type.startswith("string.bool"): - assert str_val - yield attr_name, str_val.lower() in ("true", "1", "yes") - elif attr_type.startswith("string"): - assert str_val - yield attr_name, str_val - elif attr_type.startswith("number"): - assert str_val - try: - yield attr_name, int(str_val) - except ValueError: - yield attr_name, float(str_val) - elif attr_type.startswith("binary"): - yield attr_name, msg_attr["BinaryValue"] # type: ignore - - return dict(extract_attrs()) - - -@final -@dc.dataclass(frozen=True, slots=True) -class SqsMessages(Sequence[SqsMessage], AbstractContextManager[Sequence[str], None]): - """Non-empty batch of SQS messages.""" - - source: Sequence[SqsMessage] - - def __init__(self, source: Sequence[SqsMessage]): - if isinstance(source, SqsMessages): - source = source.source - if len(source) == 0: - raise ValueError(f"{self.__class__.__name__} must not be empty") - object.__setattr__(self, "source", source) - - def __repr__(self): - queue_name = self.source[0].client.queue_name - return f"{self.__class__.__name__}(len={len(self)}, queue_name={queue_name!r})" - - def __len__(self): - return len(self.source) - - def __getitem__(self, i) -> SqsMessage: - return self.source[i] # type: ignore[return-value] - - def __enter__(self) -> Sequence[str]: - return [msg.body for msg in self.source] - - def __exit__(self, exc_type, _, __) -> None: - if exc_type is None: - for message in self.source: - message.ack() - - @property - def payload(self) -> Sequence[MessageTypeDef]: - return [msg.payload for msg in self.source] - - -def _queue_name_from_url(url: str) -> str: - parse_result = urlparse(url) - return parse_result.path.split("/")[-1] - - -def client_factory(queue_name_or_url: str, /) -> Callable[[], AbstractAsyncContextManager[ConsumerClient]]: - """Default SQS client factory.""" - return partial(ConsumerClient.create, queue_name_or_url) - - -def async_client_factory(queue_name_or_url: str, /) -> Callable[[], AbstractAsyncContextManager[AsyncConsumerClient]]: - """Default SQS async (aioboto3) client factory.""" - return partial(AsyncConsumerClient.create, queue_name_or_url) - - -async def _consume_sync( - client: ConsumerClient, - message_handler: SyncHandler[SqsMessage], - ack_queue: ThreadSafeSendStream[SqsMessage], - shutdown_scope: CancelScope, -): - def consume(): - while True: - from_thread.check_cancelled() - messages = client.receive() - if not messages: - continue # No messages received (empty queue or shutdown) - for m in messages: - message_handler(SqsMessage(m, client, ack_queue)) - - # In Trio shutdown_scope.cancel_called can only be checked in async context - with shutdown_scope: - await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) - - -@final -class SqsConsumerService(ExposedServiceBase): - def __init__( - self, - cf: AnyClientFactory, - handler_m: AnyHandlerManager[SqsMessage], - /, - *, - num_consumers: int, - ): - super().__init__() - - if num_consumers < 1: - raise ValueError("Number of consumers must be at least 1") - - self.client_factory = cf - self.handler_m = handler_m - self.num_consumers = num_consumers - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - assert self.num_consumers > 0 - self._lifetime = service_lifetime # Expose service lifetime events - - ack_queue_writer, ack_queue_reader = MemoryStream.create(math.inf) - batched_ack_queue_reader = BatchReceiver[SqsMessage, list[SqsMessage]]( - ack_queue_reader, batch_size=10, batch_window=1.0 - ) - robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) - - async def acknowledge_messages(messages: Collection[SqsMessage]) -> None: - if isinstance(client, ConsumerClient): - await to_thread.run_sync(client.delete, messages) - else: - await client.delete(messages) - - # Graceful shutdown scopes, one per consumer task (thread) - consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] - - async with ( - self.client_factory() as client, - create_stream_consumer(batched_ack_queue_reader, acknowledge_messages, concurrency=math.inf), - ack_queue_writer, - self.handler_m as handler, - create_task_group() as tg, - ): - service_lifetime.set_started() - # Start pulling messages only after the whole app is started - await service_lifetime.host.started - if isinstance(client, ConsumerClient): - sync_m_handler = ensure_sync_handler(handler) - for cs in consumer_scopes: - tg.start_soon(_consume_sync, client, sync_m_handler, robust_ack_queue, cs) - else: - async_m_handler = ensure_async_handler(handler) - for cs in consumer_scopes: - tg.start_soon(_consume_async, client, async_m_handler, robust_ack_queue, cs) - await service_lifetime.shutting_down - for cs in consumer_scopes: - cs.cancel() - - -@overload -def sqs_queue_consumer( - queue_name_or_url: str, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: - """Decorator to create an SQS queue consumer (boto3) hosted service.""" - - -@overload -def sqs_queue_consumer( - cf: AnyClientFactory, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: - """Decorator to create an SQS queue consumer hosted service.""" - - -# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function -def sqs_queue_consumer( - cf_or_q: AnyClientFactory | str, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[SqsMessage]], SqsConsumerService]: - cf = client_factory(cf_or_q) if isinstance(cf_or_q, str) else cf_or_q - return lambda handler_m: SqsConsumerService(cf, handler_m, num_consumers=num_consumers) - - -def _message2lambda(m: SqsMessage, /) -> LambdaEventRecord: - # See https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html & - # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html#API_ReceiveMessage_ResponseSyntax - return { - "messageId": m.payload["MessageId"], # type: ignore - "receiptHandle": m.payload["ReceiptHandle"], # type: ignore - "body": m.payload["Body"], # type: ignore - "attributes": m.payload.get("Attributes", {}), - "messageAttributes": { - ma_name: cast( - LambdaEventRecordMessageAttributeValue, - {ma_k[0].lower() + ma_k[1:]: ma_v for ma_k, ma_v in ma_values.items()}, - ) - for ma_name, ma_values in m.payload.get("MessageAttributes", {}).items() - }, - "md5OfBody": m.payload["MD5OfBody"], # type: ignore - "eventSource": "aws:sqs", - "eventSourceARN": "TODO", - "awsRegion": "TODO", - } - - -@final -class LambdaInvocationContext: - def __init__(self) -> None: - # See https://docs.aws.amazon.com/lambda/latest/dg/python-context.html - self.function_name = "N/A" - self.function_version = "N/A" - self.invoked_function_arn = "N/A" - self.memory_limit_in_mb = 1024 - self.aws_request_id = "N/A" # Use OTEL trace ID if available?.. - self.log_group_name = "N/A" - self.log_stream_name = "N/A" - self.identity = None - self.client_context = None - - -def lambda_handler( - lambda_h: Callable[[LambdaEvent, LambdaInvocationContext], object], / -) -> FlowHandlerManager[Sequence[SqsMessage]]: - assert not is_async_callable(lambda_h) - lambda_inv_context = LambdaInvocationContext() - - @flow.handler - def _handler(workload: Sequence[SqsMessage]) -> None: - lambda_event = cast(LambdaEvent, {"Records": []}) - messages = SqsMessages(workload) - lambda_event["Records"] = [_message2lambda(m) for m in messages] - with messages: - lambda_h(lambda_event, lambda_inv_context) - - return _handler diff --git a/localpost/consumers/sqs_otel.py b/localpost/consumers/sqs_otel.py deleted file mode 100644 index edf1268..0000000 --- a/localpost/consumers/sqs_otel.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Sequence -from contextlib import AbstractContextManager, contextmanager -from typing import TypeVar - -from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( - create_messaging_client_consumed_messages, - create_messaging_client_operation_duration, -) -from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider -from opentelemetry.util.types import AttributeValue - -from localpost import __version__ -from localpost._otel_utils import rec_duration -from localpost.consumers.sqs import SqsMessage -from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware - -T = TypeVar("T", SqsMessage, Sequence[SqsMessage]) - -__all__ = ["trace"] - - -def create_message_tracer( - tp: TracerProvider | None, - mp: MeterProvider | None, -) -> Callable[[SqsMessage | Sequence[SqsMessage]], AbstractContextManager[None]]: - tracer = (tp or get_tracer_provider()).get_tracer(__name__, __version__) - meter = (mp or get_meter_provider()).get_meter(__name__, __version__) - - # Based on Semantic Conventions 1.30.0, see - # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ - - m_process_duration = create_messaging_client_operation_duration(meter) - messages_consumed = create_messaging_client_consumed_messages(meter) - - @contextmanager - def call_tracer(messages: Sequence[SqsMessage]): - assert len(messages) > 0, "Message batch must not be empty" - is_batch = not isinstance(messages, SqsMessage) - message = messages[0] - queue_name = message.client.queue_name - attrs: dict[str, AttributeValue] = { - "messaging.system": "aws_sqs", - "messaging.operation.name": "process", - "messaging.operation.type": "process", - "messaging.destination.name": queue_name, - } - if is_batch: - attrs["messaging.batch.message_count"] = len(messages) - - messages_consumed.add(len(messages), attrs) - with tracer.start_as_current_span(f"process {queue_name}", kind=SpanKind.CONSUMER, attributes=attrs): - with rec_duration(m_process_duration, attrs): - yield - - return call_tracer - - -def trace(tp: TracerProvider | None = None, mp: MeterProvider | None = None, /) -> HandlerDecorator[T, T]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = create_message_tracer(tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware diff --git a/localpost/consumers/stream_otel.py b/localpost/consumers/stream_otel.py deleted file mode 100644 index a5f66d2..0000000 --- a/localpost/consumers/stream_otel.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Collection -from contextlib import AbstractContextManager, contextmanager -from typing import TypeVar - -from opentelemetry.metrics import MeterProvider, get_meter_provider -from opentelemetry.semconv._incubating.metrics.messaging_metrics import ( - create_messaging_client_consumed_messages, - create_messaging_client_operation_duration, -) -from opentelemetry.trace import SpanKind, TracerProvider, get_tracer_provider -from opentelemetry.util.types import AttributeValue - -from localpost import __version__ -from localpost._otel_utils import rec_duration -from localpost.flow import FlowHandler, HandlerDecorator, handler_middleware - -T = TypeVar("T") -TC = TypeVar("TC", bound=Collection[object]) - -__all__ = ["trace", "trace_batch"] - - -def _create_message_tracer( - queue_name: str, - batched: bool, - tp: TracerProvider | None, - mp: MeterProvider | None, -) -> Callable[[T], AbstractContextManager[None]]: - tracer = (tp or get_tracer_provider()).get_tracer(__name__, __version__) - meter = (mp or get_meter_provider()).get_meter(__name__, __version__) - - # Based on Semantic Conventions 1.30.0, see - # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ - - m_process_duration = create_messaging_client_operation_duration(meter) - messages_consumed = create_messaging_client_consumed_messages(meter) - - @contextmanager - def call_tracer(message): - attrs: dict[str, AttributeValue] = { - "messaging.operation.name": "process", - "messaging.operation.type": "process", - "messaging.system": "localpost_streams", - "messaging.destination.name": queue_name, - } - if batched: - attrs["messaging.batch.message_count"] = len(message) - - messages_consumed.add(len(message) if batched else 1, attrs) - with tracer.start_as_current_span(f"process {queue_name}", kind=SpanKind.CONSUMER, attributes=attrs): - with rec_duration(m_process_duration, attrs): - yield - - return call_tracer - - -def trace( - stream_name: str, tp: TracerProvider | None = None, mp: MeterProvider | None = None, / -) -> HandlerDecorator[T, T]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = _create_message_tracer(stream_name, False, tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware - - -def trace_batch( - stream_name: str, tp: TracerProvider | None = None, mp: MeterProvider | None = None, / -) -> HandlerDecorator[TC, TC]: - @handler_middleware - async def middleware(next_h: FlowHandler): - call_tracer = _create_message_tracer(stream_name, True, tp, mp) - - async def _handle_async(item): - with call_tracer(item): - await next_h.async_h(item) - - def _handle_sync(item): - with call_tracer(item): - next_h.sync_h(item) - - yield _handle_async, _handle_sync - - return middleware diff --git a/localpost/http/config.py b/localpost/http/config.py index 64d5d4b..4ad7696 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -27,13 +27,13 @@ class ServerConfig: """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB """Maximum request body size (bytes).""" + max_connections: int = 100 + """Max open connections (including idle).""" @final @dataclass(frozen=True, slots=True) class WorkerConfig: server: ServerConfig = field(default_factory=ServerConfig) - max_connections: int = 100 - """Max open connections (including idle).""" max_requests: int = 5 """Max parallel requests.""" From 57e1b062fe08b46bfe20938cc09b8d01b43576e9 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 10 Feb 2026 19:33:25 +0000 Subject: [PATCH 023/286] WIP (threadtools) --- localpost/threadtools.py | 284 +++++++++++++++++++++++++++++++++++++ tests/threadtools.py | 296 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 localpost/threadtools.py create mode 100644 tests/threadtools.py diff --git a/localpost/threadtools.py b/localpost/threadtools.py new file mode 100644 index 0000000..91e1ec8 --- /dev/null +++ b/localpost/threadtools.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import dataclasses as dc +import threading +from collections import deque +from collections.abc import Iterator, Callable +from contextlib import contextmanager +from functools import partial +from typing import Any, Self, Protocol, final + +import anyio +from anyio import EndOfStream, ClosedResourceError, from_thread, CapacityLimiter, to_thread +from anyio.abc import TaskGroup as AnyioTaskGroup + +__all__ = [ + "Channel", + "SendChannel", + "ReceiveChannel", +] + +from localpost._utils import is_async_callable + +CHECK_TIMEOUT: float = 1.0 +"""Timeout (seconds) for cancellation checks (e.g. in the server loop).""" + +# Alias, so it's possible to override if needed +check_cancelled = from_thread.check_cancelled + + +### +# Synchronization primitives +### + +@final +class CancellableLock: + """Same interface as threading.Lock, but acquire() is cancellation aware.""" + + __slots__ = ("source", "release", "__exit__", "locked", "_release_save", "_acquire_restore", "_is_owned") + + # noinspection PyProtectedMember + def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None: + lock = lock or threading.Lock() + self.source = lock + self.release = lock.release + self.__exit__ = lock.__exit__ + if hasattr(self.source, "locked"): + self.locked = lock.locked + if hasattr(lock, '_release_save'): + self._release_save = lock._release_save + if hasattr(lock, '_acquire_restore'): + self._acquire_restore = lock._acquire_restore + if hasattr(lock, '_is_owned'): + self._is_owned = lock._is_owned + + def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: + if not blocking: + return self.source.acquire(blocking=False) + while not self.source.acquire(timeout=CHECK_TIMEOUT): + check_cancelled() + return True + + __enter__ = acquire + + +def cancellable_condition(lock: CancellableLock | None = None) -> threading.Condition: + return threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore + + +def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: + source = threading.BoundedSemaphore(value) + source._cond = cancellable_condition() + return source + + +### +# Task groups +### + + +class TaskGroup: + def __init__(self, tg: AnyioTaskGroup) -> None: + self._tg = tg + + def start_soon(self, func: Callable[..., object], *args: Any, limiter: CapacityLimiter | None = None) -> None: + target = func + if is_async_callable(func): + target = partial(to_thread.run_sync, target, limiter=limiter) + from_thread.run_sync(self._tg.start_soon, target, *args) + + +@contextmanager +def create_task_group() -> Iterator[TaskGroup]: + atg = anyio.create_task_group() + try: + yield TaskGroup(from_thread.run(atg.__aenter__)) + except Exception as e: + if not from_thread.run(atg.__aexit__, type(e), e, e.__traceback__): + raise e + else: + from_thread.run(atg.__aexit__, None, None, None) + + +### +# Channels +### + + +@final +class Channel[T]: + @staticmethod + def create(capacity: int | None = None) -> tuple[SendChannel[T], ReceiveChannel[T]]: + """Create a channel sender/receiver pair. + + Args: + capacity: Buffer size. None means unbounded, 0 means rendezvous + (put blocks until a receiver consumes the item), N>0 means bounded. + """ + with ChannelState(capacity) as state: + state.open_send_channels += 1 + tx = SendChannel(state) + state.open_receive_channels += 1 + rx = ReceiveChannel(state) + return tx, rx + + +class BaseReceiveChannel[T](Protocol): + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def __iter__(self) -> Iterator[T]: + while True: + try: + yield self.get() + except EndOfStream: + break + + def clone(self) -> ReceiveChannel[T]: ... + + # Raises: + # EndOfChannel – if the sender has been closed cleanly, and no more objects are coming. This is not an error condition. + # ClosedResourceError – if you previously closed this ReceiveChannel object. + # BrokenResourceError – if something has gone wrong, and the channel is broken. + def get(self) -> T: ... + + def close(self): ... + + +class BaseSendChannel[T](Protocol): + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def clone(self) -> SendChannel[T]: ... + + # Raises: + # BrokenResourceError – if something has gone wrong, and the channel is broken. For example, you may get this if the receiver has already been closed. + # ClosedResourceError – if you previously closed this SendChannel object, or if another task closes it while send() is running. + def put(self, item: T, /) -> None: ... + + def close(self) -> None: ... + + +@dc.dataclass(slots=True) +class ChannelState[T]: + buffer: deque[T] + capacity: int | None + open_send_channels: int + open_receive_channels: int + _lock: CancellableLock + not_empty: threading.Condition + not_full: threading.Condition + + def __init__(self, capacity: int | None = None): + self.buffer = deque() + self.capacity = capacity + """ + None: unbounded + 0: rendezvous (at most 1 item in-flight, put() blocks until a receiver consumes the item) + Positive int: bounded (put blocks until len(buffer) < capacity)""" + self.open_send_channels = 0 + self.open_receive_channels = 0 + self._lock = CancellableLock(threading.RLock()) + self.not_empty = cancellable_condition(self._lock) + self.not_full = cancellable_condition(self._lock) + + @property + def can_put(self) -> bool: + if self.open_receive_channels == 0: + raise ClosedResourceError("no more receivers") + return self.capacity is None or len(self.buffer) < max(self.capacity, 1) + + def __enter__(self) -> Self: + self._lock.acquire() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + if self.open_send_channels == 0 or self.open_receive_channels == 0: + self.not_empty.notify_all() + self.not_full.notify_all() + self._lock.release() + + +class SendChannel[T](BaseSendChannel[T]): + def __init__(self, state: ChannelState[T]) -> None: + self._state = state + self._closed = False + + def clone(self): + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel is already closed") + state.open_send_channels += 1 + return SendChannel(state) + + def put(self, item: T, /) -> None: + # Phase 1: wait for space in the buffer + while True: + check_cancelled() + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if state.can_put: + state.buffer.append(item) + state.not_empty.notify() + break + state.not_full.wait() + + # Phase 2 (rendezvous only): wait until the item is consumed + if self._state.capacity == 0: + while True: + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if len(state.buffer) == 0: + return # Consumed + if state.open_receive_channels == 0: + return # No receivers left + state.not_full.wait() + check_cancelled() + + def close(self) -> None: + with self._state as state: + if not self._closed: + self._closed = True + state.open_send_channels -= 1 + state.not_full.notify() # Wake up threads waiting in put() immediately + + +class ReceiveChannel[T](BaseReceiveChannel[T]): + def __init__(self, state: ChannelState[T]) -> None: + self._state = state + self._closed = False + + def clone(self): + with self._state as state: + if self._closed: + raise ClosedResourceError("receive channel is already closed") + state.open_receive_channels += 1 + return ReceiveChannel(state) + + def get(self) -> T: + check_cancelled() + while not self._closed: + with self._state as state: + if len(state.buffer) > 0: + item = state.buffer.popleft() + state.not_full.notify() + return item + if state.open_send_channels == 0: + raise EndOfStream("no more senders") + state.not_empty.wait() + raise ClosedResourceError("receive channel has been closed") + + def close(self) -> None: + with self._state as state: + if not self._closed: + self._closed = True + state.open_receive_channels -= 1 + state.not_empty.notify() # Wake up threads waiting in get() immediately diff --git a/tests/threadtools.py b/tests/threadtools.py new file mode 100644 index 0000000..c68b0cd --- /dev/null +++ b/tests/threadtools.py @@ -0,0 +1,296 @@ +import threading +import time +import pytest +from anyio import EndOfStream + +from localpost.threadtools import Channel, SendChannel + +def no_anyio + +def test_basic_send_receive(): + """Test basic send and receive operations.""" + sender, receiver = Channel.create() + + # Send and receive a single item + sender.put(42) + assert receiver.get() == 42 + + # Send and receive multiple items + for i in range(5): + sender.put(i) + + for i in range(5): + assert receiver.get() == i + + sender.close() + receiver.close() + + +def test_multiple_senders_single_receiver(): + """Test multiple senders with a single receiver.""" + sender, receiver = Channel.create() + results = [] + num_senders = 3 + num_messages_per_sender = 4 + senders = [sender] + [sender.clone() for _ in range(num_senders - 1)] + + def send_messages(thread_sender: SendChannel[str], sender_id: int): + for i in range(3): + thread_sender.put(f"sender{sender_id}-msg{i}") + time.sleep(0.01) # Small delay to mix messages + sender.close() + + # Start multiple sender threads + threads = [] + for i, ts in enumerate(senders): + t = threading.Thread(target=send_messages, args=(ts, i)) + t.start() + threads.append(t) + + # Receive all messages + try: + while True: + results.append(receiver.get()) + except EndOfStream: + pass + + # Wait for all threads to complete + for t in threads: + t.join() + + receiver.close() + + # Should have received all messages + assert len(results) == num_senders * num_messages_per_sender + # Check that we got messages from all senders + for i in range(num_senders): + sender_msgs = [msg for msg in results if msg.startswith(f"sender{i}")] + assert len(sender_msgs) == num_messages_per_sender + + +def test_single_sender_multiple_receivers(): + """Test single sender with multiple receivers.""" + channel = Channel[int]() + sender = channel.open_sender() + results = {i: [] for i in range(3)} + + def receive_messages(receiver_id: int): + receiver = channel.open_receiver() + try: + while True: + value = receiver.get() + results[receiver_id].append(value) + except EndOfStream: + pass + receiver.close() + + # Start multiple receiver threads + threads = [] + for i in range(3): + t = threading.Thread(target=receive_messages, args=(i,)) + t.start() + threads.append(t) + + # Send messages + for i in range(30): + sender.put(i) + time.sleep(0.001) # Small delay to allow receivers to compete + + sender.close() + + # Wait for all threads to complete + for t in threads: + t.join() + + # Check that all messages were received (distributed among receivers) + all_received = [] + for receiver_results in results.values(): + all_received.extend(receiver_results) + + assert sorted(all_received) == list(range(30)) + # Each receiver should have gotten some messages + for receiver_results in results.values(): + assert len(receiver_results) > 0 + + +def test_no_receivers_error(): + """Test that sending without receivers does not raise an error.""" + channel = Channel[int]() + sender = channel.open_sender() + + # Should not raise an error anymore - items go to buffer + sender.put(42) + sender.put(43) + + # Now open a receiver and verify it gets the buffered items + receiver = channel.open_receiver() + assert receiver.get() == 42 + assert receiver.get() == 43 + + sender.close() + receiver.close() + + +def test_end_of_channel(): + """Test that receivers get EndOfStream when all senders close.""" + channel = Channel[int]() + sender1 = channel.open_sender() + sender2 = channel.open_sender() + receiver = channel.open_receiver() + + sender1.put(1) + sender2.put(2) + + assert receiver.get() == 1 + assert receiver.get() == 2 + + # Close one sender - receiver should still work + sender1.close() + sender2.put(3) + assert receiver.get() == 3 + + # Close last sender - receiver should get EndOfStream + sender2.close() + with pytest.raises(EndOfStream): + receiver.get() + + receiver.close() + + +def test_closed_channel_errors(): + """Test operations on closed channels raise errors.""" + channel = Channel[int]() + sender = channel.open_sender() + receiver = channel.open_receiver() + + # Close sender and try to use it + sender.close() + with pytest.raises(ClosedResourceError): + sender.put(42) + + # Close receiver and try to use it + receiver.close() + with pytest.raises(ClosedResourceError): + receiver.get() + + +def test_blocking_receive(): + """Test that receive blocks until item is available.""" + channel = Channel[str]() + sender = channel.open_sender() + receiver = channel.open_receiver() + result = [] + + def delayed_send(): + time.sleep(0.1) + sender.put("delayed message") + sender.close() + + def receive(): + try: + result.append(receiver.get()) + except EndOfStream: + pass + receiver.close() + + # Start receiver first (will block) + receiver_thread = threading.Thread(target=receive) + receiver_thread.start() + + # Start sender after delay + sender_thread = threading.Thread(target=delayed_send) + sender_thread.start() + + # Wait for both threads + receiver_thread.join(timeout=1.0) + sender_thread.join(timeout=1.0) + + assert result == ["delayed message"] + + +def test_concurrent_stress(): + """Stress test with many concurrent senders and receivers.""" + channel = Channel[int]() + num_senders = 5 + num_receivers = 3 + messages_per_sender = 100 + + received = [] + received_lock = threading.Lock() + + def sender_work(sender_id: int): + sender = channel.open_sender() + try: + for i in range(messages_per_sender): + sender.put(sender_id * 1000 + i) + finally: + sender.close() + + def receiver_work(): + receiver = channel.open_receiver() + local_received = [] + try: + while True: + local_received.append(receiver.get()) + except EndOfStream: + pass + finally: + receiver.close() + + with received_lock: + received.extend(local_received) + + # Start all threads + threads = [] + + # Start senders first to ensure at least one is open when receivers start + sender_threads = [] + for i in range(num_senders): + t = threading.Thread(target=sender_work, args=(i,)) + t.start() + sender_threads.append(t) + threads.append(t) + + # Give senders time to start + time.sleep(0.01) + + # Start receivers + for _ in range(num_receivers): + t = threading.Thread(target=receiver_work) + t.start() + threads.append(t) + + # Wait for all threads + for t in threads: + t.join(timeout=5.0) + assert not t.is_alive(), "Thread did not complete in time" + + # Verify all messages were received + assert len(received) == num_senders * messages_per_sender + + # Verify all messages are unique and accounted for + expected = [] + for sender_id in range(num_senders): + for i in range(messages_per_sender): + expected.append(sender_id * 1000 + i) + + assert sorted(received) == sorted(expected) + + +def test_channel_cleanup(): + """Test that channels clean up properly when references are dropped.""" + channel = Channel[int]() + + # Open and immediately close channels + for _ in range(10): + sender = channel.open_sender() + receiver = channel.open_receiver() + sender.put(42) + assert receiver.get() == 42 + sender.close() + receiver.close() + + # Verify state is clean + assert channel._state.open_send_channels == 0 + assert channel._state.open_receive_channels == 0 + assert len(channel._state.buffer) == 0 From 41d829a29bfc0fc379fa33e7947f4eb896660ca8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 16 Feb 2026 18:59:10 +0000 Subject: [PATCH 024/286] WIP (another idea) --- localpost/.gitignore | 2 - localpost/_run.py | 51 --- localpost/_sync_utils.py | 29 -- localpost/_utils.py | 2 +- localpost/consumers/__init__.py | 5 - .../consumers/{queue.py => stdlib_queue.py} | 80 ++--- localpost/consumers/stream.py | 29 +- localpost/hosting/__init__.py | 5 + localpost/hosting/_host.py | 307 ++++++++++++------ localpost/hosting/middlewares.py | 7 - localpost/hosting/services/grpc.py | 30 -- localpost/hosting/services/hypercorn.py | 24 -- localpost/hosting/services/uvicorn.py | 22 +- localpost/http/config.py | 3 +- localpost/http/worker.py | 14 +- localpost/scheduler/_scheduler.py | 2 +- localpost/threadtools.py | 80 ++++- .../channels.py} | 134 ++++---- 18 files changed, 396 insertions(+), 430 deletions(-) delete mode 100644 localpost/.gitignore delete mode 100644 localpost/_run.py delete mode 100644 localpost/_sync_utils.py rename localpost/consumers/{queue.py => stdlib_queue.py} (53%) delete mode 100644 localpost/hosting/middlewares.py delete mode 100644 localpost/hosting/services/grpc.py delete mode 100644 localpost/hosting/services/hypercorn.py rename tests/{threadtools.py => threadtools/channels.py} (67%) diff --git a/localpost/.gitignore b/localpost/.gitignore deleted file mode 100644 index 34695c8..0000000 --- a/localpost/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Autogenerated on build -__meta__.py diff --git a/localpost/_run.py b/localpost/_run.py deleted file mode 100644 index 2b99391..0000000 --- a/localpost/_run.py +++ /dev/null @@ -1,51 +0,0 @@ -import logging -import threading - -import anyio -from anyio import open_signal_receiver - -from ._utils import HANDLED_SIGNALS, cancellable_from, choose_anyio_backend -from .hosting import AbstractHost, Host, HostedServiceFunc - -logger = logging.getLogger("localpost") - - -def _ensure_host(target: AbstractHost | HostedServiceFunc) -> AbstractHost: - if isinstance(target, AbstractHost): - return target - return Host(target) - - -def run(target: AbstractHost | HostedServiceFunc) -> int: - """ - Run the target host (or service) until it stops or is interrupted by a signal. - """ - return anyio.run(arun, target, **choose_anyio_backend()) - - -async def arun(target: AbstractHost | HostedServiceFunc) -> int: - """ - Run the target host (or service) until it stops or is interrupted by a signal. - """ - if threading.current_thread() is not threading.main_thread(): - raise RuntimeError("Signals can only be installed on the main thread") - - host = _ensure_host(target) - - @cancellable_from(host.stopped) - async def handle_signals(): - with open_signal_receiver(*HANDLED_SIGNALS) as signals: - async for _ in signals: - if not host.shutting_down: # First Ctrl+C (or other termination method) - logger.info("Shutting down...") - host.shutdown() - continue - # Ctrl+C again - logger.warning("Forced shutdown") - host.stop() - break - - async with host.aserve(): - await handle_signals() - - return host.exit_code diff --git a/localpost/_sync_utils.py b/localpost/_sync_utils.py deleted file mode 100644 index a4acd28..0000000 --- a/localpost/_sync_utils.py +++ /dev/null @@ -1,29 +0,0 @@ -import queue -import threading -from contextlib import suppress - -import anyio -from anyio import from_thread - -CHECK_TIMEOUT: float = 1.0 -"""Timeout (seconds) for cancellation checks (e.g. in the server loop).""" - - -def check_cancelled() -> None: - with suppress(anyio.NoEventLoopError): - from_thread.check_cancelled() - - -def acquire_sem(sem: threading.Semaphore) -> None: - while not sem.acquire(timeout=CHECK_TIMEOUT): - check_cancelled() - return - - -def pull_queue[T](q: queue.Queue[T] | queue.SimpleQueue[T]) -> T: - while True: - check_cancelled() - try: - return q.get(timeout=CHECK_TIMEOUT) - except queue.Empty: - continue diff --git a/localpost/_utils.py b/localpost/_utils.py index f35da8b..810ef7f 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -400,7 +400,7 @@ def cancellable_from(*events: AnyEventView): def _decorator(func: Callable[P, Awaitable[Any]]) -> Callable[P, Awaitable[None]]: @wraps(func) async def _wrapper(*args, **kwargs): - # await wait_any(lambda: func(*args, **kwargs), *[e.wait for e in events]) + # Short version: await wait_any(lambda: func(*args, **kwargs), *[e.wait for e in events]) async with create_task_group() as exec_tg: exec_scope = exec_tg.cancel_scope for e in events: diff --git a/localpost/consumers/__init__.py b/localpost/consumers/__init__.py index 02fbdc1..e69de29 100644 --- a/localpost/consumers/__init__.py +++ b/localpost/consumers/__init__.py @@ -1,5 +0,0 @@ -from ._utils import ensure_async_handler - -__all__ = [ - "ensure_async_handler", -] diff --git a/localpost/consumers/queue.py b/localpost/consumers/stdlib_queue.py similarity index 53% rename from localpost/consumers/queue.py rename to localpost/consumers/stdlib_queue.py index bde3aa5..e760f50 100644 --- a/localpost/consumers/queue.py +++ b/localpost/consumers/stdlib_queue.py @@ -1,14 +1,15 @@ +import queue import sys -import threading from collections.abc import AsyncIterator, Awaitable -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from queue import Queue, SimpleQueue import anyio from anyio import create_task_group, CancelScope, to_thread, CapacityLimiter, from_thread -from localpost._sync_utils import acquire_sem, pull_queue -from localpost.consumers._utils import AsyncHandler, ensure_sync_handler +from localpost import threadtools +from localpost._utils import is_async_callable +from localpost.consumers._utils import AsyncHandler, ensure_sync_handler, AnyHandler if sys.version_info >= (3, 13): from queue import ShutDown @@ -20,34 +21,54 @@ class ShutDown(Exception): __all__ = ["queue_consumer"] +def _pull_queue[T](q: Queue[T] | SimpleQueue[T]) -> T: + while True: + threadtools.check_cancelled() + try: + return q.get(timeout=threadtools.CHECK_TIMEOUT) + except queue.Empty: + continue + + +# noinspection DuplicatedCode @asynccontextmanager async def queue_consumer[T]( stream: Queue[T] | SimpleQueue[T], - h: AsyncHandler[T], + h: AnyHandler[T], /, *, max_concurrency: int, process_leftovers: bool = True, shutdown_timeout: float = 5.0, ) -> AsyncIterator[None]: - req_threads = CapacityLimiter(max_concurrency) - req_sem = threading.BoundedSemaphore(max_concurrency) - handler = ensure_sync_handler(h) + req_sem = threadtools.cancellable_semaphore(max_concurrency) - def handle_item(item): - try: - handler(item) - finally: - req_sem.release() + if is_async_callable(h): + async def handle_item(item): + try: + await h(item) + finally: + req_sem.release() - def handle_item_thread(i) -> Awaitable[None]: - return to_thread.run_sync(handle_item, i, limiter=req_threads) + handler = handle_item + else: + req_threads = CapacityLimiter(max_concurrency) + + def handle_item(item): + try: + h(item) + finally: + req_sem.release() + + def handler(item): + return to_thread.run_sync(handle_item, item, limiter=req_threads) def handle_items(): - acquire_sem(req_sem) - while True: - from_thread.run_sync(tg.start_soon, handle_item_thread, pull_queue(stream)) - acquire_sem(req_sem) + with suppress(ShutDown): + req_sem.acquire() + while True: + from_thread.run_sync(tg.start_soon, handler, _pull_queue(stream)) + req_sem.acquire() async def handle_items_thread(): with shutdown_scope: @@ -64,24 +85,3 @@ async def handle_items_thread(): # Wait for the remaining items to be processed (until the source queue is closed) or # until the shutdown timeout is reached shutdown_scope.deadline = anyio.current_time() + shutdown_timeout - - -def _sample_usage(): - import logging - import anyio - - logging.basicConfig(level=logging.DEBUG) - - async def _run(): - async with serve(simple_app, WorkerConfig()) as w: - with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: - async for _ in signals: - w.shutdown() - break - - # noinspection PyTypeChecker - anyio.run(_run) - - -if __name__ == "__main__": - _sample_usage() diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py index eec005e..0d756ab 100644 --- a/localpost/consumers/stream.py +++ b/localpost/consumers/stream.py @@ -2,13 +2,11 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress -import anyio -from anyio import create_task_group, Semaphore, ClosedResourceError, CancelScope +from anyio import create_task_group, Semaphore, ClosedResourceError from anyio.abc import ObjectReceiveStream from localpost._utils import NullSemaphore, ensure_int_or_inf -from localpost.consumers import ensure_async_handler -from localpost.consumers._utils import AsyncHandler +from localpost.consumers._utils import AnyHandler, ensure_async_handler __all__ = ["stream_consumer"] @@ -16,7 +14,7 @@ @asynccontextmanager async def stream_consumer[T]( stream: ObjectReceiveStream[T], - h: AsyncHandler[T], + h: AnyHandler[T], /, *, max_concurrency: int | float = math.inf, @@ -46,24 +44,3 @@ async def handle_items(): # Immediately stop consuming (close the receiver) and ignore the remaining items await stream.aclose() # Otherwise process all the remaining items (until the source stream is completed) - - -def _sample_usage(): - import logging - import anyio - - logging.basicConfig(level=logging.DEBUG) - - async def _run(): - async with serve(simple_app, WorkerConfig()) as w: - with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: - async for _ in signals: - w.shutdown() - break - - # noinspection PyTypeChecker - anyio.run(_run) - - -if __name__ == "__main__": - _sample_usage() diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index e69de29..b88a27b 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -0,0 +1,5 @@ +from ._host import HostLifetime, current_host, serve_app, run, arun, ServiceState, Starting, Running, ShuttingDown, Stopped, \ + AppF + +__all__ = ["HostLifetime", "current_host", "arun", "run", "serve_app", "ServiceState", "Starting", "Running", "ShuttingDown", + "Stopped", "AppF"] diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index b82e667..3d91496 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -5,153 +5,260 @@ import threading from _contextvars import ContextVar from collections.abc import Callable +from contextlib import asynccontextmanager +from functools import wraps from typing import ( - Any, ClassVar, Literal, - Protocol, - final, + final, Any, TypeVar, overload, cast, Awaitable, ) +import anyio from anyio import ( - CancelScope, + CancelScope, create_task_group, get_cancelled_exc_class, ) +from anyio import open_signal_receiver +from anyio.abc import TaskGroup from anyio.from_thread import BlockingPortal from localpost._utils import ( - EventView, + EventView, Event, cancellable_from, unwrap_exc, ) +from localpost._utils import HANDLED_SIGNALS, choose_anyio_backend + +__all__ = ["ServiceLifetime", "HostLifetime", "current_host", "serve", "serve_app", "run", "arun"] + +F = TypeVar("F", bound=Callable[..., Any]) logger = logging.getLogger("localpost.hosting") -# @final -# @dc.dataclass(frozen=True, slots=True) -# class Created: -# name: ClassVar[Literal["created"]] = "created" -# -# -# @final -# @dc.dataclass(frozen=True, slots=True) -# class Starting: -# name: ClassVar[Literal["starting"]] = "starting" -# # timeout: float -# -# -# @final -# @dc.dataclass(frozen=True, slots=True) -# class Running: -# name: ClassVar[Literal["running"]] = "running" -# value: Any = None -# # graceful_shutdown_scope: CancelScope | None = None -# -# -# @final -# @dc.dataclass(frozen=True, slots=True) -# class ShuttingDown: -# name: ClassVar[Literal["shutting_down"]] = "shutting_down" -# reason: BaseException | str | None = None -# # timeout: float -# -# -# @final -# @dc.dataclass(frozen=True, slots=True) -# class Stopped: -# name: ClassVar[Literal["stopped"]] = "stopped" -# shutdown_reason: BaseException | str | None = None -# exception: BaseException | None = None - - -# ServiceState = Starting | Running | ShuttingDown | Stopped -HostState = Created | Starting | Running | ShuttingDown | Stopped - - - -class HostLifetime(Protocol): - exit_code: int = 0 +@final +@dc.dataclass(frozen=True, slots=True) +class Starting: + name: ClassVar[Literal["starting"]] = "starting" + # timeout: float - @property - def name(self) -> str: ... - @property - def state(self) -> HostState: ... +@final +@dc.dataclass(frozen=True, slots=True) +class Running: + name: ClassVar[Literal["running"]] = "running" + # graceful_shutdown_scope: CancelScope | None = None - @property - def status(self) -> ServiceStatus: ... - @property - def started(self) -> EventView: ... +@final +@dc.dataclass(frozen=True, slots=True) +class ShuttingDown: + name: ClassVar[Literal["shutting_down"]] = "shutting_down" + reason: BaseException | str | None = None + # timeout: float - @property - def shutting_down(self) -> EventView: ... - @property - def stopped(self) -> EventView: ... +@final +@dc.dataclass(frozen=True, slots=True) +class Stopped: + name: ClassVar[Literal["stopped"]] = "stopped" + shutdown_reason: BaseException | str | None = None + exception: BaseException | None = None - def shutdown(self, *, reason: BaseException | str | None = None) -> None: ... +ServiceState = Starting | Running | ShuttingDown | Stopped -_current_host: ContextVar[Host] = ContextVar("localpost.current_host") +_current_host: ContextVar[HostLifetime] = ContextVar("localpost.current_host") -def current_host() -> Host: + +def current_host() -> HostLifetime: if _current_host.get(None) is None: raise RuntimeError("Not in localpost hosting context") return _current_host.get() -@final -@dc.dataclass(slots=True) -class Host: # Actually a Host run +def _start_scv[T: AbstractLifetime]( + lt: T, svc: Callable[[T], Awaitable[None]], /, + start_timeout: float | None = None, + shutdown_timeout: float | None = None, +) -> None: + @wraps(svc) + async def _observed(): + try: + await svc(lt) + except get_cancelled_exc_class(): # BaseException + raise + except Exception as exc: + source_exc = unwrap_exc(exc) + # lt.exception = source_exc + object.__setattr__(lt, "exception", source_exc) + finally: + lt.stopped.set() + + lt.tg.start_soon(_observed) + + +@dc.dataclass(frozen=True, eq=False, slots=True) +class AbstractLifetime: + tg: TaskGroup portal: BlockingPortal - thread_id: int - run_scope: CancelScope - _exit_code: int | None = None + thread_id: int = dc.field(default_factory=threading.get_ident) - @property - def exit_code(self) -> int: - if self._exit_code is not None: - return self._exit_code # Set by the user - if self._exec_context: - return 1 if self._exec_context.root_service_lifetime.exception else 0 - return 0 + started: EventView = dc.field(default_factory=Event) + shutting_down: EventView = dc.field(default_factory=Event) + stopped: EventView = dc.field(default_factory=Event) - @exit_code.setter - def exit_code(self, value: int): - if not 0 <= value <= 255: - raise ValueError("Exit code must be in [0,255] range") - self._exit_code = value + shutdown_reason: BaseException | str | None = None + exception: BaseException | None = None @property - def state(self) -> HostState: - if self._exec_context: - return self._exec_context.root_service_lifetime.state - return Created() - - # @property - # def status(self) -> ServiceStatus: - # if self._exec_context: - # return self._exec_context.root_service_lifetime.status - # return { - # "name": self.name, - # "state": "created", - # "services": [], - # "shutdown_reason": None, - # "exception": None, - # } + def run_scope(self) -> CancelScope: + return self.tg.cancel_scope + + @property + def state(self) -> ServiceState: + if self.stopped: + return Stopped(self.shutdown_reason, self.exception) + if self.shutting_down: + return ShuttingDown(self.shutdown_reason) + if self.started: + return Running() + return Starting() + + def wait_started(self) -> None: + """Helper for sync code, to wait in a thread.""" + if self.same_thread: + raise RuntimeError("Deadlock: synchronous wait in the async thread") + return self.portal.start_task_soon(self.started.wait).result() + + def wait_shutting_down(self) -> None: + """Helper for sync code, to wait in a thread.""" + if self.same_thread: + raise RuntimeError("Deadlock: synchronous wait in the async thread") + return self.portal.start_task_soon(self.shutting_down.wait).result() + + @overload + def cancel_on_shutdown(self) -> Callable[[F], F]: ... + + @overload + def cancel_on_shutdown(self, target: F | None = None, /) -> F: ... + + def cancel_on_shutdown(self, target: F | None = None, /) -> Any: + dec = cancellable_from(self.shutting_down) + return dec(target) if target is not None else dec + + @overload + def cancel_on_stop(self) -> Callable[[F], F]: ... + + @overload + def cancel_on_stop(self, target: F | None = None, /) -> F: ... + + def cancel_on_stop(self, target: F | None = None, /) -> Any: + dec = cancellable_from(self.stopped) + return dec(target) if target is not None else dec @property def same_thread(self) -> bool: return threading.get_ident() == self.thread_id + def set_started(self) -> None: + def do_set_started(): + assert not self.stopped, "Cannot mark already stopped service as started" + if self.started.is_set(): + return + cast(Event, self.started).set() + + in_host_thread(self, do_set_started) + def shutdown(self, *, reason: BaseException | str | None = None) -> None: + def do_shutdown(): + if self.stopped or self.shutting_down: + return + # self.shutdown_reason = reason + object.__setattr__(self, "shutdown_reason", reason) + cast(Event, self.shutting_down).set() + + in_host_thread(self, do_shutdown) def stop(self) -> None: in_host_thread(self, self.run_scope.cancel) -# def in_host_thread(h: HostLifetime, func: Callable[..., T]) -> T: -def in_host_thread[T](h: Host, func: Callable[..., T]) -> T: +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class ServiceLifetime(AbstractLifetime): + pass + + +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class HostLifetime(AbstractLifetime): + _exit_code: int | None = None + + @property + def exit_code(self) -> int: + if self._exit_code is not None: + return self._exit_code # Set by the user + return 1 if self.exception else 0 + + @exit_code.setter + def exit_code(self, value: int): + if not 0 <= value <= 255: + raise ValueError("Exit code must be in [0,255] range") + object.__setattr__(self, "_exit_code", value) + + +def in_host_thread[T](h: AbstractLifetime, func: Callable[..., T]) -> T: if h.same_thread: return func() return h.portal.start_task_soon(func).result() + +ServiceF = Callable[[ServiceLifetime], Awaitable[None]] +AppF = Callable[[HostLifetime], Awaitable[None]] + + +@asynccontextmanager +async def serve(app: ServiceF, /): + """Start the target service and return control to the caller.""" + async with BlockingPortal() as portal, create_task_group() as host_tg: + host_lifetime = HostLifetime(host_tg, portal) + _start_scv(host_lifetime, app) + yield host_lifetime + host_lifetime.shutdown() + + +@asynccontextmanager +async def serve_app(app: AppF, /): + """Start the target app and return control to the caller.""" + async with BlockingPortal() as portal, create_task_group() as host_tg: + host_lifetime = HostLifetime(host_tg, portal) + _start_scv(host_lifetime, app) + yield host_lifetime + host_lifetime.shutdown() + + +def run(app_f: AppF, /) -> int: + """Run the target app until it stops or is interrupted by a signal.""" + return anyio.run(arun, app_f, **choose_anyio_backend()) + + +async def arun(app_f: AppF, /) -> int: + """Run the target app until it stops or is interrupted by a signal.""" + if threading.current_thread() is not threading.main_thread(): + raise RuntimeError("Signals can only be installed on the main thread") + + async def handle_signals(): + with open_signal_receiver(*HANDLED_SIGNALS) as signals: + async for _ in signals: + # First Ctrl+C (or other termination method) + if not host.shutting_down: + logger.info("Shutting down...") + host.shutdown() + continue + # Ctrl+C again + logger.warning("Forced shutdown") + host.stop() + break + + async with serve_app(app_f) as host: + await host.cancel_on_stop(handle_signals)() + + return host.exit_code diff --git a/localpost/hosting/middlewares.py b/localpost/hosting/middlewares.py deleted file mode 100644 index f001253..0000000 --- a/localpost/hosting/middlewares.py +++ /dev/null @@ -1,7 +0,0 @@ -__all__ = [ - "shutdown_timeout", - "start_timeout", -] - - -# lifespan — simply an async context manager (that can be used as a decorator) diff --git a/localpost/hosting/services/grpc.py b/localpost/hosting/services/grpc.py deleted file mode 100644 index 95ea78f..0000000 --- a/localpost/hosting/services/grpc.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import final - -import grpc - -from localpost._utils import wait_any - -from .._host import ServiceLifetimeManager - - -@final -class AsyncGrpcService: - def __init__(self, server: grpc.aio.Server): - self._server = server - self.name = "grpc" - self.grace_termination_period = 5 - - @property - def server(self) -> grpc.aio.Server: - return self._server - - async def __call__(self, service_lifetime: ServiceLifetimeManager): - async def handle_svc_shutdown(): - await service_lifetime.shutting_down - # During the grace period, the server won't accept new connections and allow existing RPCs to continue - # within the grace period. - await self._server.stop(self.grace_termination_period) - - await self._server.start() - service_lifetime.set_started() - await wait_any(handle_svc_shutdown, self._server.wait_for_termination) diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py deleted file mode 100644 index 23da148..0000000 --- a/localpost/hosting/services/hypercorn.py +++ /dev/null @@ -1,24 +0,0 @@ -from collections.abc import Callable -from typing import Any, final - -import hypercorn -from sniffio import current_async_library - -from .._host import ServiceLifetimeManager - - -@final -class HypercornService: - def __init__(self, app: Callable[..., Any], config: hypercorn.Config): - self.app = app - self.config = config - self.name = "hypercorn" - - # See https://hypercorn.readthedocs.io/en/latest/how_to_guides/api_usage.html - async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: - if current_async_library() == "trio": - from hypercorn.trio import serve - else: - from hypercorn.asyncio import serve # type: ignore[assignment] - - await serve(self.app, self.config, shutdown_trigger=service_lifetime.shutting_down.wait) diff --git a/localpost/hosting/services/uvicorn.py b/localpost/hosting/services/uvicorn.py index fd3cb85..739d62b 100644 --- a/localpost/hosting/services/uvicorn.py +++ b/localpost/hosting/services/uvicorn.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import asynccontextmanager from os import getenv from typing import Any, cast, final @@ -9,8 +10,7 @@ from typing_extensions import Self from localpost._utils import start_task_soon - -from .._host import ServiceLifetimeManager +from localpost.hosting._host import ServiceLifetime # Also see /health endpoint in http_app.py example @@ -34,26 +34,14 @@ def for_app(cls, app: Callable[..., Any]) -> Self: # It is hard to use server.serve() directly, because it overrides the signal handlers. A possible workaround is # to call it in a separate thread, but currently it looks like an overkill. # See uvicorn.Server._serve() for the original implementation. - async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: + async def __call__(self, sl: ServiceLifetime) -> None: config = self.config server = uvicorn.Server(config) if config.should_reload: - raise ValueError("Reload is not supported") + raise ValueError("Uvicorn: reload is not supported") elif config.workers > 1: - raise ValueError("Multiple workers are not supported") - - try: - if not config.loaded: - config.load() - server.lifespan = config.lifespan_class(config) - await server.startup() - except SystemExit as e: - service_lifetime.host.exit_code = cast(int, e.code) - raise e.__context__ if e.__context__ else RuntimeError("Server startup failed") from None - - if not server.started: - raise RuntimeError("Server did not start") + raise ValueError("Uvicorn: multiple workers are not supported") async def serve(): service_lifetime.set_started() diff --git a/localpost/http/config.py b/localpost/http/config.py index 4ad7696..033bac6 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -27,8 +27,7 @@ class ServerConfig: """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB """Maximum request body size (bytes).""" - max_connections: int = 100 - """Max open connections (including idle).""" + # TODO Support Keep-Alive response header (timeout, max requests) @final diff --git a/localpost/http/worker.py b/localpost/http/worker.py index 38811da..584dfb3 100644 --- a/localpost/http/worker.py +++ b/localpost/http/worker.py @@ -12,20 +12,20 @@ import anyio from anyio import CancelScope, create_task_group, from_thread, to_thread -from localpost._sync_utils import acquire_sem +from localpost import threadtools from localpost.http.config import WorkerConfig from localpost.http.server import Server, start_http_server from localpost.http.wsgi import wrap_wsgi -__all__ = ["Worker", "serve"] +__all__ = ["Worker", "serve_http"] @asynccontextmanager -async def serve(app: WSGIApplication, config: WorkerConfig, /) -> AsyncIterator[Worker]: +async def serve_http(app: WSGIApplication, config: WorkerConfig, /) -> AsyncIterator[Worker]: """Run multiple servers (workers).""" handler = wrap_wsgi(app) req_threads = anyio.CapacityLimiter(config.max_requests) - req_sem = threading.BoundedSemaphore(config.max_requests) + req_sem = threadtools.cancellable_semaphore(config.max_requests) def handle_client(c): try: @@ -37,11 +37,11 @@ def handle_client_thread(c) -> Awaitable[None]: return to_thread.run_sync(handle_client, c, limiter=req_threads) def handle_clients(): - acquire_sem(req_sem) + req_sem.acquire() for c in server.accept(): # Handle each client connection in a separate thread from_thread.run_sync(tg.start_soon, handle_client_thread, c) - acquire_sem(req_sem) + req_sem.acquire() def handle_clients_thread() -> Awaitable[None]: return to_thread.run_sync(handle_clients, limiter=anyio.CapacityLimiter(1)) @@ -72,7 +72,7 @@ def simple_app(_, start_response): return [f"Hello from worker thread {threading.get_ident()}!\n".encode()] async def _run(): - async with serve(simple_app, WorkerConfig()) as w: + async with serve_http(simple_app, WorkerConfig()) as w: with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: async for _ in signals: w.close() diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 9323144..72fc501 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -17,7 +17,7 @@ def_full_name, is_async_callable, ) -from localpost.flow import AsyncHandlerManager, FlowHandlerManager, HandlerDecorator, ensure_async_handler_manager +#from localpost.flow import AsyncHandlerManager, FlowHandlerManager, HandlerDecorator, ensure_async_handler_manager from localpost.hosting import ( AbstractHost, ExposedService, diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 91e1ec8..19d2f88 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -9,7 +9,7 @@ from typing import Any, Self, Protocol, final import anyio -from anyio import EndOfStream, ClosedResourceError, from_thread, CapacityLimiter, to_thread +from anyio import EndOfStream, ClosedResourceError, from_thread, CapacityLimiter, to_thread, WouldBlock from anyio.abc import TaskGroup as AnyioTaskGroup __all__ = [ @@ -31,6 +31,7 @@ # Synchronization primitives ### + @final class CancellableLock: """Same interface as threading.Lock, but acquire() is cancellation aware.""" @@ -52,18 +53,43 @@ def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None if hasattr(lock, '_is_owned'): self._is_owned = lock._is_owned - def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: + def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: if not blocking: return self.source.acquire(blocking=False) - while not self.source.acquire(timeout=CHECK_TIMEOUT): + if timeout is None or timeout < 0: + # No timeout — loop until acquired, checking for cancellation + while not self.source.acquire(timeout=CHECK_TIMEOUT): + check_cancelled() + return True + # Finite timeout — respect the deadline + deadline = anyio.current_time() + timeout + while (remaining := deadline - anyio.current_time()) > 0: + if self.source.acquire(timeout=min(CHECK_TIMEOUT, remaining)): + return True check_cancelled() - return True + return False __enter__ = acquire def cancellable_condition(lock: CancellableLock | None = None) -> threading.Condition: - return threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore + cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore + orig_wait = cond.wait + def cancellable_wait(timeout: float | None = None) -> bool: + end_time = None if timeout is None else anyio.current_time() + timeout + while True: + check_cancelled() + if timeout is None: + remaining = CHECK_TIMEOUT + else: + remaining = min(CHECK_TIMEOUT, max(0.0, end_time - anyio.current_time())) + if orig_wait(remaining): + return True + if timeout is not None and anyio.current_time() >= end_time: + return False + + cond.wait = cancellable_wait # type: ignore + return cond def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: @@ -130,6 +156,12 @@ def __enter__(self) -> Self: def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + def __iter__(self) -> Iterator[T]: while True: try: @@ -140,9 +172,10 @@ def __iter__(self) -> Iterator[T]: def clone(self) -> ReceiveChannel[T]: ... # Raises: - # EndOfChannel – if the sender has been closed cleanly, and no more objects are coming. This is not an error condition. - # ClosedResourceError – if you previously closed this ReceiveChannel object. - # BrokenResourceError – if something has gone wrong, and the channel is broken. + # EndOfStream – if the sender has been closed cleanly, and no more objects are coming. This is not an error + # condition. + # ClosedResourceError – if you previously closed this ReceiveChannel object. + # BrokenResourceError – if something has gone wrong, and the channel is broken. def get(self) -> T: ... def close(self): ... @@ -155,11 +188,19 @@ def __enter__(self) -> Self: def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + def clone(self) -> SendChannel[T]: ... # Raises: - # BrokenResourceError – if something has gone wrong, and the channel is broken. For example, you may get this if the receiver has already been closed. - # ClosedResourceError – if you previously closed this SendChannel object, or if another task closes it while send() is running. + # BrokenResourceError – if something has gone wrong, and the channel is broken. For example, you may get this if + # the receiver has already been closed. + # ClosedResourceError – if you previously closed this SendChannel object, or if another task closes it while + # put() is running. def put(self, item: T, /) -> None: ... def close(self) -> None: ... @@ -217,18 +258,25 @@ def clone(self): state.open_send_channels += 1 return SendChannel(state) + def put_nowait(self, item: T, /) -> None: + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if not state.can_put: + raise WouldBlock + state.buffer.append(item) + state.not_empty.notify() + def put(self, item: T, /) -> None: # Phase 1: wait for space in the buffer while True: check_cancelled() with self._state as state: - if self._closed: - raise ClosedResourceError("send channel has been closed") - if state.can_put: - state.buffer.append(item) - state.not_empty.notify() + try: + self.put_nowait(item) break - state.not_full.wait() + except WouldBlock: + state.not_full.wait() # Phase 2 (rendezvous only): wait until the item is consumed if self._state.capacity == 0: diff --git a/tests/threadtools.py b/tests/threadtools/channels.py similarity index 67% rename from tests/threadtools.py rename to tests/threadtools/channels.py index c68b0cd..c704000 100644 --- a/tests/threadtools.py +++ b/tests/threadtools/channels.py @@ -1,13 +1,21 @@ import threading import time import pytest -from anyio import EndOfStream +from anyio import EndOfStream, ClosedResourceError +from localpost import threadtools from localpost.threadtools import Channel, SendChannel -def no_anyio +@pytest.fixture +def no_anyio(): + original_check_cancelled = threadtools.check_cancelled + threadtools.check_cancelled = lambda: None + try: + yield + finally: + threadtools.check_cancelled = original_check_cancelled -def test_basic_send_receive(): +def test_basic_send_receive(no_anyio): """Test basic send and receive operations.""" sender, receiver = Channel.create() @@ -26,19 +34,19 @@ def test_basic_send_receive(): receiver.close() -def test_multiple_senders_single_receiver(): +def test_multiple_senders_single_receiver(no_anyio): """Test multiple senders with a single receiver.""" sender, receiver = Channel.create() results = [] num_senders = 3 - num_messages_per_sender = 4 + messages_per_sender = 3 senders = [sender] + [sender.clone() for _ in range(num_senders - 1)] def send_messages(thread_sender: SendChannel[str], sender_id: int): - for i in range(3): + for i in range(messages_per_sender): thread_sender.put(f"sender{sender_id}-msg{i}") - time.sleep(0.01) # Small delay to mix messages - sender.close() + time.sleep(0.05) # Small delay to mix messages + thread_sender.close() # Start multiple sender threads threads = [] @@ -61,33 +69,32 @@ def send_messages(thread_sender: SendChannel[str], sender_id: int): receiver.close() # Should have received all messages - assert len(results) == num_senders * num_messages_per_sender + assert len(results) == num_senders * messages_per_sender # Check that we got messages from all senders for i in range(num_senders): sender_msgs = [msg for msg in results if msg.startswith(f"sender{i}")] - assert len(sender_msgs) == num_messages_per_sender + assert len(sender_msgs) == messages_per_sender -def test_single_sender_multiple_receivers(): +def test_single_sender_multiple_receivers(no_anyio): """Test single sender with multiple receivers.""" - channel = Channel[int]() - sender = channel.open_sender() + sender, receiver = Channel.create() results = {i: [] for i in range(3)} + receivers = [receiver] + [receiver.clone() for _ in range(2)] - def receive_messages(receiver_id: int): - receiver = channel.open_receiver() + def receive_messages(recv, receiver_id: int): try: while True: - value = receiver.get() + value = recv.get() results[receiver_id].append(value) except EndOfStream: pass - receiver.close() + recv.close() # Start multiple receiver threads threads = [] - for i in range(3): - t = threading.Thread(target=receive_messages, args=(i,)) + for i, recv in enumerate(receivers): + t = threading.Thread(target=receive_messages, args=(recv, i)) t.start() threads.append(t) @@ -113,30 +120,21 @@ def receive_messages(receiver_id: int): assert len(receiver_results) > 0 -def test_no_receivers_error(): - """Test that sending without receivers does not raise an error.""" - channel = Channel[int]() - sender = channel.open_sender() - - # Should not raise an error anymore - items go to buffer - sender.put(42) - sender.put(43) +def test_no_receivers_error(no_anyio): + """Test that sending without receivers raises ClosedResourceError.""" + sender, receiver = Channel.create() + receiver.close() - # Now open a receiver and verify it gets the buffered items - receiver = channel.open_receiver() - assert receiver.get() == 42 - assert receiver.get() == 43 + with pytest.raises(ClosedResourceError): + sender.put(42) sender.close() - receiver.close() -def test_end_of_channel(): +def test_end_of_channel(no_anyio): """Test that receivers get EndOfStream when all senders close.""" - channel = Channel[int]() - sender1 = channel.open_sender() - sender2 = channel.open_sender() - receiver = channel.open_receiver() + sender1, receiver = Channel.create() + sender2 = sender1.clone() sender1.put(1) sender2.put(2) @@ -157,11 +155,9 @@ def test_end_of_channel(): receiver.close() -def test_closed_channel_errors(): +def test_closed_channel_errors(no_anyio): """Test operations on closed channels raise errors.""" - channel = Channel[int]() - sender = channel.open_sender() - receiver = channel.open_receiver() + sender, receiver = Channel.create() # Close sender and try to use it sender.close() @@ -174,11 +170,9 @@ def test_closed_channel_errors(): receiver.get() -def test_blocking_receive(): +def test_blocking_receive(no_anyio): """Test that receive blocks until item is available.""" - channel = Channel[str]() - sender = channel.open_sender() - receiver = channel.open_receiver() + sender, receiver = Channel.create() result = [] def delayed_send(): @@ -208,34 +202,35 @@ def receive(): assert result == ["delayed message"] -def test_concurrent_stress(): +def test_concurrent_stress(no_anyio): """Stress test with many concurrent senders and receivers.""" - channel = Channel[int]() num_senders = 5 num_receivers = 3 messages_per_sender = 100 + sender, receiver = Channel.create() + senders = [sender] + [sender.clone() for _ in range(num_senders - 1)] + receivers = [receiver] + [receiver.clone() for _ in range(num_receivers - 1)] + received = [] received_lock = threading.Lock() - def sender_work(sender_id: int): - sender = channel.open_sender() + def sender_work(s, sender_id: int): try: for i in range(messages_per_sender): - sender.put(sender_id * 1000 + i) + s.put(sender_id * 1000 + i) finally: - sender.close() + s.close() - def receiver_work(): - receiver = channel.open_receiver() + def receiver_work(r): local_received = [] try: while True: - local_received.append(receiver.get()) + local_received.append(r.get()) except EndOfStream: pass finally: - receiver.close() + r.close() with received_lock: received.extend(local_received) @@ -244,19 +239,17 @@ def receiver_work(): threads = [] # Start senders first to ensure at least one is open when receivers start - sender_threads = [] - for i in range(num_senders): - t = threading.Thread(target=sender_work, args=(i,)) + for i, s in enumerate(senders): + t = threading.Thread(target=sender_work, args=(s, i)) t.start() - sender_threads.append(t) threads.append(t) # Give senders time to start - time.sleep(0.01) + time.sleep(0.05) # Start receivers - for _ in range(num_receivers): - t = threading.Thread(target=receiver_work) + for r in receivers: + t = threading.Thread(target=receiver_work, args=(r,)) t.start() threads.append(t) @@ -277,20 +270,17 @@ def receiver_work(): assert sorted(received) == sorted(expected) -def test_channel_cleanup(): +def test_channel_cleanup(no_anyio): """Test that channels clean up properly when references are dropped.""" - channel = Channel[int]() - - # Open and immediately close channels for _ in range(10): - sender = channel.open_sender() - receiver = channel.open_receiver() + sender, receiver = Channel.create() sender.put(42) assert receiver.get() == 42 + state = sender._state sender.close() receiver.close() - # Verify state is clean - assert channel._state.open_send_channels == 0 - assert channel._state.open_receive_channels == 0 - assert len(channel._state.buffer) == 0 + # Verify state is clean + assert state.open_send_channels == 0 + assert state.open_receive_channels == 0 + assert len(state.buffer) == 0 From 88c7038eb1e6612f4339ce0a7274313a6e6f5f51 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 28 Feb 2026 21:47:12 +0000 Subject: [PATCH 025/286] WIP --- .python-version | 2 + CHANGELOG.md | 16 + CONVENTIONS.md | 3 - justfile | 6 +- localpost/__init__.py | 1 + localpost/_utils.py | 31 +- localpost/consumers/_utils.py | 4 +- localpost/consumers/channel.py | 95 +++++ localpost/consumers/pubsub.py | 10 +- localpost/consumers/stdlib_queue.py | 8 +- localpost/consumers/stream.py | 7 +- localpost/hosting/__init__.py | 41 +- localpost/hosting/_host.py | 379 ++++++++++------- localpost/hosting/middleware.py | 54 +++ localpost/hosting/services/_asgi.py | 23 ++ localpost/hosting/services/grpc.py | 30 ++ localpost/hosting/services/hypercorn.py | 21 + localpost/hosting/services/uvicorn.py | 68 ++- localpost/http/README.md | 38 +- localpost/http/TODO.md | 17 + localpost/http/_benchmark.py | 28 ++ localpost/http/_benchmark.sh | 6 + localpost/http/_benchmark_app.py | 30 ++ localpost/http/_benchmark_uvicorn.py | 18 + localpost/http/config.py | 17 +- localpost/http/openapi/__init__.py | 0 localpost/http/openapi/app.py | 348 ++++++++++++++++ localpost/http/openrpc/__init__.py | 13 + localpost/http/router.py | 146 +++++++ localpost/http/server.py | 381 +++++++++-------- localpost/http/uritemplate.py | 20 + localpost/http/worker.py | 57 +-- localpost/http/wsgi.py | 134 ------ localpost/http/wsgi/__init__.py | 165 ++++++++ localpost/scheduler/_scheduler.py | 3 +- localpost/threadtools.py | 135 ++++-- pyproject.toml | 17 +- uv.lock | 529 ++++++++++++++---------- 38 files changed, 2040 insertions(+), 861 deletions(-) create mode 100644 .python-version delete mode 100644 CONVENTIONS.md create mode 100644 localpost/consumers/channel.py create mode 100644 localpost/hosting/middleware.py create mode 100644 localpost/hosting/services/_asgi.py create mode 100644 localpost/hosting/services/grpc.py create mode 100644 localpost/hosting/services/hypercorn.py create mode 100644 localpost/http/TODO.md create mode 100644 localpost/http/_benchmark.py create mode 100644 localpost/http/_benchmark.sh create mode 100644 localpost/http/_benchmark_app.py create mode 100644 localpost/http/_benchmark_uvicorn.py create mode 100644 localpost/http/openapi/__init__.py create mode 100644 localpost/http/openapi/app.py create mode 100644 localpost/http/openrpc/__init__.py create mode 100644 localpost/http/router.py create mode 100644 localpost/http/uritemplate.py delete mode 100644 localpost/http/wsgi.py create mode 100644 localpost/http/wsgi/__init__.py diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..3a8498a --- /dev/null +++ b/.python-version @@ -0,0 +1,2 @@ +# Because pyspy doesn't support Python 3.14 yet +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0abd8..4b74e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +## [0.6.0] - 2026-02-22 + +Complete rewrite of the hosting system, to simplify it and make it more robust. + +### Fixed + +### Added + +- `localpost.http` — selectors-based non-async HTTP server + +### Changed + +### Removed + +- `localpost.flow` — too complicated + ## [0.5.0] - 2025-07-18 ### Added diff --git a/CONVENTIONS.md b/CONVENTIONS.md deleted file mode 100644 index 7aa7f5a..0000000 --- a/CONVENTIONS.md +++ /dev/null @@ -1,3 +0,0 @@ -- Use AnyIO for async stuff, to follow structured concurrency patterns -- For public API, use types everywhere (and validate it using Pyright, see `just check-type-coverage`) -- For internals API, use types only where it makes sense (to make the code more readable) diff --git a/justfile b/justfile index 09af4b6..b756be1 100755 --- a/justfile +++ b/justfile @@ -13,10 +13,8 @@ deps-upgrade: [doc("Check types (using both PyRight and MyPy)")] types: -ty check localpost - -pyright --pythonpath $(which python) \ - localpost - -mypy --pretty --strict-bytes --python-executable $(which python) \ - localpost + -pyright --pythonpath $(which python) localpost + -mypy --pretty --strict-bytes --python-executable $(which python) localpost [doc("Check types (using both PyRight and MyPy)")] types-strict: diff --git a/localpost/__init__.py b/localpost/__init__.py index fc0a80a..d75d9bf 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -2,6 +2,7 @@ from importlib.metadata import version from ._debug import debug + # from ._run import arun, run from ._utils import Result diff --git a/localpost/_utils.py b/localpost/_utils.py index 810ef7f..b0e79c2 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -16,8 +16,10 @@ Any, Final, Generic, + NotRequired, ParamSpec, Protocol, + Self, TypeAlias, TypedDict, TypeGuard, @@ -27,12 +29,19 @@ ) import anyio -from anyio import CancelScope, CapacityLimiter, WouldBlock, create_task_group, from_thread, to_thread, \ - create_memory_object_stream +from anyio import ( + CancelScope, + CapacityLimiter, + WouldBlock, + create_memory_object_stream, + create_task_group, + from_thread, + to_thread, +) from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream, _MemoryObjectStreamState -from typing_extensions import NotRequired, Self, TypeVar +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from typing_extensions import TypeVar T = TypeVar("T", default=Any) P = ParamSpec("P") @@ -293,6 +302,20 @@ def ensure_delay_factory(delay: DelayFactory, /) -> Callable[[], timedelta]: return FixedDelay.create(delay) +@dc.dataclass(eq=False, slots=True, init=False) +class Switch: + value: bool = False + + def __bool__(self) -> bool: + return self.value + + def __enter__(self) -> None: + self.value = True + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.value = False + + # sleep(0) is used to return control to the event loop (in both Trio and AsyncIO) def sleep(i: timedelta | int | float | None, /) -> Coroutine[Any, Any, None]: interval_sec: float = i.total_seconds() if isinstance(i, timedelta) else 0 if i is None else i diff --git a/localpost/consumers/_utils.py b/localpost/consumers/_utils.py index 516c9f4..cca1b6a 100644 --- a/localpost/consumers/_utils.py +++ b/localpost/consumers/_utils.py @@ -1,6 +1,6 @@ -from collections.abc import Callable, Awaitable +from collections.abc import Awaitable, Callable -from anyio import CapacityLimiter, to_thread, from_thread +from anyio import CapacityLimiter, from_thread, to_thread from localpost._utils import is_async_callable diff --git a/localpost/consumers/channel.py b/localpost/consumers/channel.py new file mode 100644 index 0000000..5ff0f87 --- /dev/null +++ b/localpost/consumers/channel.py @@ -0,0 +1,95 @@ +from collections.abc import AsyncIterator +from contextlib import suppress + +from anyio import CapacityLimiter, ClosedResourceError, create_task_group, from_thread, to_thread + +from localpost import threadtools, hosting +from localpost._utils import is_async_callable +from localpost.consumers._utils import AnyHandler +from localpost.threadtools import Channel, ReceiveChannel + +__all__ = ["channel_consumer"] + + +# noinspection DuplicatedCode +@hosting.service +async def channel_consumer[T]( + stream: ReceiveChannel[T], + h: AnyHandler[T], + /, + *, + max_concurrency: int, + process_leftovers: bool = True, +) -> AsyncIterator[None]: + req_sem = threadtools.cancellable_semaphore(max_concurrency) + + if is_async_callable(h): + + async def handle_item(item): + try: + await h(item) + finally: + req_sem.release() + + handler = handle_item + else: + req_threads = CapacityLimiter(max_concurrency) + + def handle_item(item): + try: + h(item) + finally: + req_sem.release() + + def handler(item): + return to_thread.run_sync(handle_item, item, limiter=req_threads) + + def handle_items(): + with suppress(ClosedResourceError): # Receiver has been closed (according to process_leftovers setting) + req_sem.acquire() + for item in stream: + from_thread.run_sync(tg.start_soon, handler, item) + req_sem.acquire() + + def handle_items_thread(): + return to_thread.run_sync(handle_items, limiter=CapacityLimiter(1)) + + async with stream, create_task_group() as tg: + tg.start_soon(handle_items_thread) + yield + if not process_leftovers: + # Immediately stop consuming (close the receiver) and ignore the remaining items + stream.close() + # Otherwise process all the remaining items (until the source stream is completed) + + +def _sample_usage(): + import logging + import signal + import time + + import anyio + + logging.basicConfig(level=logging.DEBUG) + + def log_item(x): + time.sleep(0.5) + logging.info(f"Received: {x}") + + async def _run(): + sender, receiver = Channel.create() + consumer = channel_consumer(receiver, log_item, max_concurrency=5) + + async with consumer, sender: + for i in range(10): + sender.put_nowait(i) + with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: + async for _ in signals: + break # Shutdown + + # noinspection PyTypeChecker + anyio.run(_run) + + +if __name__ == "__main__": + _sample_usage() diff --git a/localpost/consumers/pubsub.py b/localpost/consumers/pubsub.py index 1051993..96d8020 100644 --- a/localpost/consumers/pubsub.py +++ b/localpost/consumers/pubsub.py @@ -6,19 +6,18 @@ from collections.abc import Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager from functools import partial -from typing import Final, TypeAlias, final, overload +from typing import Final, final, overload +import google.pubsub_v1 as pubsub from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread from google.api_core import retry from google.api_core.exceptions import AlreadyExists from google.api_core.gapic_v1.method import DEFAULT -import google.pubsub_v1 as pubsub -from google.pubsub_v1 import SubscriberAsyncClient, SubscriberClient +from google.pubsub_v1 import SubscriberClient from localpost._utils import MemoryStream from localpost.flow import ( AnyHandlerManager, - AsyncHandler, SyncHandler, ensure_async_handler, ensure_sync_handler, @@ -139,8 +138,7 @@ async def create( # https://cloud.google.com/pubsub/docs/reference/error-codes try: logger.info("Creating subscription for pulling messages") - await to_thread.run_sync( - client.create_subscription, {"name": subscription_path, "topic": topic_path}) + await to_thread.run_sync(client.create_subscription, {"name": subscription_path, "topic": topic_path}) except AlreadyExists: logger.info("Subscription already exists, using it for pulling messages") yield cls(client, subscription_path, topic_path, max_messages, retry_policy) diff --git a/localpost/consumers/stdlib_queue.py b/localpost/consumers/stdlib_queue.py index e760f50..b5f84e2 100644 --- a/localpost/consumers/stdlib_queue.py +++ b/localpost/consumers/stdlib_queue.py @@ -1,19 +1,20 @@ import queue import sys -from collections.abc import AsyncIterator, Awaitable +from collections.abc import AsyncIterator from contextlib import asynccontextmanager, suppress from queue import Queue, SimpleQueue import anyio -from anyio import create_task_group, CancelScope, to_thread, CapacityLimiter, from_thread +from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread from localpost import threadtools from localpost._utils import is_async_callable -from localpost.consumers._utils import AsyncHandler, ensure_sync_handler, AnyHandler +from localpost.consumers._utils import AnyHandler if sys.version_info >= (3, 13): from queue import ShutDown else: + class ShutDown(Exception): pass @@ -44,6 +45,7 @@ async def queue_consumer[T]( req_sem = threadtools.cancellable_semaphore(max_concurrency) if is_async_callable(h): + async def handle_item(item): try: await h(item) diff --git a/localpost/consumers/stream.py b/localpost/consumers/stream.py index 0d756ab..935ea0e 100644 --- a/localpost/consumers/stream.py +++ b/localpost/consumers/stream.py @@ -1,17 +1,18 @@ import math from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, suppress +from contextlib import suppress -from anyio import create_task_group, Semaphore, ClosedResourceError +from anyio import ClosedResourceError, Semaphore, create_task_group from anyio.abc import ObjectReceiveStream +from localpost import hosting from localpost._utils import NullSemaphore, ensure_int_or_inf from localpost.consumers._utils import AnyHandler, ensure_async_handler __all__ = ["stream_consumer"] -@asynccontextmanager +@hosting.service async def stream_consumer[T]( stream: ObjectReceiveStream[T], h: AnyHandler[T], diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index b88a27b..1b1b06e 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -1,5 +1,38 @@ -from ._host import HostLifetime, current_host, serve_app, run, arun, ServiceState, Starting, Running, ShuttingDown, Stopped, \ - AppF +import anyio -__all__ = ["HostLifetime", "current_host", "arun", "run", "serve_app", "ServiceState", "Starting", "Running", "ShuttingDown", - "Stopped", "AppF"] +from .._utils import choose_anyio_backend +from ._host import ( + Running, + ServiceF, + ServiceState, + ShuttingDown, + Starting, + Stopped, + current_service, + observe_services, + run, + serve, + service, +) +from .middleware import shutdown_on_signal + +__all__ = [ + "service", + "observe_services", + "current_service", + "serve", + "run", + "run_app", + "ServiceState", + "Starting", + "Running", + "ShuttingDown", + "Stopped", +] + + +def run_app(svc: ServiceF, /) -> int: + """Run the target app until it stops or is interrupted by a signal.""" + + app = shutdown_on_signal()(svc) + return anyio.run(run, app, None, **choose_anyio_backend()) diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 3d91496..4a3eb88 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -1,32 +1,20 @@ from __future__ import annotations import dataclasses as dc +import inspect import logging import threading from _contextvars import ContextVar -from collections.abc import Callable -from contextlib import asynccontextmanager -from functools import wraps -from typing import ( - ClassVar, - Literal, - final, Any, TypeVar, overload, cast, Awaitable, -) - -import anyio -from anyio import ( - CancelScope, create_task_group, get_cancelled_exc_class, -) -from anyio import open_signal_receiver +from collections.abc import AsyncIterator, Awaitable, Callable, AsyncGenerator +from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext, AsyncExitStack +from functools import cached_property, wraps +from typing import Any, ClassVar, Literal, TypeVar, final, overload + +from anyio import CancelScope, create_task_group, get_cancelled_exc_class, to_thread, CapacityLimiter from anyio.abc import TaskGroup from anyio.from_thread import BlockingPortal -from localpost._utils import ( - EventView, Event, cancellable_from, unwrap_exc, -) -from localpost._utils import HANDLED_SIGNALS, choose_anyio_backend - -__all__ = ["ServiceLifetime", "HostLifetime", "current_host", "serve", "serve_app", "run", "arun"] +from localpost._utils import Event, EventView, cancellable_from, unwrap_exc, wait_all, is_async_callable F = TypeVar("F", bound=Callable[..., Any]) @@ -44,7 +32,6 @@ class Starting: @dc.dataclass(frozen=True, slots=True) class Running: name: ClassVar[Literal["running"]] = "running" - # graceful_shutdown_scope: CancelScope | None = None @final @@ -66,74 +53,57 @@ class Stopped: ServiceState = Starting | Running | ShuttingDown | Stopped -_current_host: ContextVar[HostLifetime] = ContextVar("localpost.current_host") - +_svc_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_service") -def current_host() -> HostLifetime: - if _current_host.get(None) is None: - raise RuntimeError("Not in localpost hosting context") - return _current_host.get() +def _current_service() -> ServiceLifetime: + if lt := _svc_lt.get(None): + return lt + raise RuntimeError("Not in hosting context") -def _start_scv[T: AbstractLifetime]( - lt: T, svc: Callable[[T], Awaitable[None]], /, - start_timeout: float | None = None, - shutdown_timeout: float | None = None, -) -> None: - @wraps(svc) - async def _observed(): - try: - await svc(lt) - except get_cancelled_exc_class(): # BaseException - raise - except Exception as exc: - source_exc = unwrap_exc(exc) - # lt.exception = source_exc - object.__setattr__(lt, "exception", source_exc) - finally: - lt.stopped.set() - lt.tg.start_soon(_observed) +def current_service() -> ServiceLifetimeView: + return _current_service().view -@dc.dataclass(frozen=True, eq=False, slots=True) -class AbstractLifetime: - tg: TaskGroup - portal: BlockingPortal - thread_id: int = dc.field(default_factory=threading.get_ident) +@dc.dataclass(frozen=True) +class ServiceLifetimeView: + _state: ServiceLifetime - started: EventView = dc.field(default_factory=Event) - shutting_down: EventView = dc.field(default_factory=Event) - stopped: EventView = dc.field(default_factory=Event) + started: EventView + shutting_down: EventView + stopped: EventView - shutdown_reason: BaseException | str | None = None - exception: BaseException | None = None + @asynccontextmanager + async def observe(self) -> AsyncIterator[ServiceLifetimeView]: + await self.started + try: + yield self + finally: + self.shutdown() + await self.stopped @property - def run_scope(self) -> CancelScope: - return self.tg.cancel_scope + def exit_code(self) -> int: + return self._state.exit_code @property def state(self) -> ServiceState: - if self.stopped: - return Stopped(self.shutdown_reason, self.exception) - if self.shutting_down: - return ShuttingDown(self.shutdown_reason) - if self.started: - return Running() - return Starting() + return self._state.state def wait_started(self) -> None: """Helper for sync code, to wait in a thread.""" - if self.same_thread: + if self._state.same_thread: raise RuntimeError("Deadlock: synchronous wait in the async thread") - return self.portal.start_task_soon(self.started.wait).result() + # noinspection PyTypeChecker + return self._state.portal.start_task_soon(self.started.wait).result() def wait_shutting_down(self) -> None: """Helper for sync code, to wait in a thread.""" - if self.same_thread: + if self._state.same_thread: raise RuntimeError("Deadlock: synchronous wait in the async thread") - return self.portal.start_task_soon(self.shutting_down.wait).result() + # noinspection PyTypeChecker + return self._state.portal.start_task_soon(self.shutting_down.wait).result() @overload def cancel_on_shutdown(self) -> Callable[[F], F]: ... @@ -155,44 +125,38 @@ def cancel_on_stop(self, target: F | None = None, /) -> Any: dec = cancellable_from(self.stopped) return dec(target) if target is not None else dec - @property - def same_thread(self) -> bool: - return threading.get_ident() == self.thread_id - - def set_started(self) -> None: - def do_set_started(): - assert not self.stopped, "Cannot mark already stopped service as started" - if self.started.is_set(): - return - cast(Event, self.started).set() - - in_host_thread(self, do_set_started) - def shutdown(self, *, reason: BaseException | str | None = None) -> None: def do_shutdown(): if self.stopped or self.shutting_down: return - # self.shutdown_reason = reason - object.__setattr__(self, "shutdown_reason", reason) - cast(Event, self.shutting_down).set() + self._state.shutdown_reason = reason + self._state.shutting_down.set() - in_host_thread(self, do_shutdown) + in_host_thread(self._state, do_shutdown) def stop(self) -> None: - in_host_thread(self, self.run_scope.cancel) + in_host_thread(self._state, self._state.run_scope.cancel) -@final -@dc.dataclass(frozen=True, eq=False, slots=True) -class ServiceLifetime(AbstractLifetime): - pass +@dc.dataclass(eq=False, unsafe_hash=True) +class ServiceLifetime: + portal: BlockingPortal + tg: TaskGroup = dc.field(default_factory=create_task_group) + thread_id: int = dc.field(default_factory=threading.get_ident) + started: Event = dc.field(default_factory=Event) + shutting_down: Event = dc.field(default_factory=Event) + stopped: Event = dc.field(default_factory=Event) + + shutdown_reason: BaseException | str | None = None + exception: BaseException | None = None -@final -@dc.dataclass(frozen=True, eq=False, slots=True) -class HostLifetime(AbstractLifetime): _exit_code: int | None = None + @cached_property + def view(self) -> ServiceLifetimeView: + return ServiceLifetimeView(self, self.started, self.shutting_down, self.stopped) + @property def exit_code(self) -> int: if self._exit_code is not None: @@ -205,60 +169,197 @@ def exit_code(self, value: int): raise ValueError("Exit code must be in [0,255] range") object.__setattr__(self, "_exit_code", value) + @property + def run_scope(self) -> CancelScope: + return self.tg.cancel_scope -def in_host_thread[T](h: AbstractLifetime, func: Callable[..., T]) -> T: + @property + def state(self) -> ServiceState: + if self.stopped: + return Stopped(self.shutdown_reason, self.exception) + if self.shutting_down: + return ShuttingDown(self.shutdown_reason) + if self.started: + return Running() + return Starting() + + @property + def same_thread(self) -> bool: + return threading.get_ident() == self.thread_id + + def set_started(self) -> None: + def do_set(): + assert not self.stopped, "Cannot mark already stopped service as started" + self.started.set() + + in_host_thread(self, do_set) + + def set_shutting_down(self, reason: BaseException | str | None = None) -> None: + def do_set(): + assert not self.stopped, "Cannot mark already stopped service as started" + if not self.shutting_down.is_set(): + self.shutdown_reason = reason + self.shutting_down.set() + + in_host_thread(self, do_set) + + def start(self, svc_f: ServiceF, /) -> ServiceLifetimeView: + """Start a child service from the given function.""" + + def do_start() -> ServiceLifetimeView: + child_lt = ServiceLifetime(self.portal) + self.tg.start_soon(_run, svc_f, child_lt) + return child_lt.view + + return in_host_thread(self, do_start) + + +def in_host_thread[R](h: ServiceLifetime, func: Callable[..., R], *args) -> R: if h.same_thread: - return func() + return func(*args) + # noinspection PyTypeChecker return h.portal.start_task_soon(func).result() + ServiceF = Callable[[ServiceLifetime], Awaitable[None]] -AppF = Callable[[HostLifetime], Awaitable[None]] +# AppF = Callable[[HostLifetime], Awaitable[None]] + + +@dc.dataclass() +class _ResolvedService(AbstractAsyncContextManager[ServiceLifetimeView]): + func: ServiceF + _run: AbstractAsyncContextManager | None = dc.field(default=None, compare=False, repr=False) + + def __call__(self, lt: ServiceLifetime, /) -> Awaitable[None]: + return self.func(lt) + + async def __aenter__(self) -> ServiceLifetimeView: + assert self._run is None + self._run = serve(self.func, parent=_svc_lt.get(None)) + return await self._run.__aenter__() + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + assert self._run is not None + return await self._run.__aexit__(exc_type, exc_val, exc_tb) + + +@asynccontextmanager +async def _serve_and_observe(svc_f: ServiceF, /): + async with serve(svc_f, parent=_svc_lt.get(None)) as lt, lt.observe(): + yield lt + + +@overload +def service[**P](target: Callable[P, Any]) -> Callable[P, _ResolvedService]: + """Decorator to create a hosted service.""" + ... + + +@overload +def service[**P]() -> Callable[[Callable[P, Any]], Callable[P, _ResolvedService]]: + """Decorator to create a hosted service.""" + ... + + +def service(target: Callable[..., Any] | None = None): + # FIXME Wrap sync functions, wrap context managers + def decorator(func: Callable[..., Any]) -> Callable[..., _ResolvedService]: + @wraps(func) + def wrapper(*args, **kwargs): + raw_svc_f = func(*args, **kwargs) + if is_async_callable(raw_svc_f): + svc_f = raw_svc_f + else: + svc_f = lambda lt: to_thread.run_sync(raw_svc_f, lt, limiter=CapacityLimiter(1)) + return _ResolvedService(svc_f) + + if inspect.isasyncgenfunction(func): + return _service_cm(func) + return wrapper + + return decorator(target) if callable(target) else decorator + + +def _service_cm(func: Callable[..., AsyncGenerator]): + """Decorator to transform a generator function into a service factory.""" + cm_f = asynccontextmanager(func) + + @wraps(func) + def wrapper(*args, **kwargs): + async def svc_f(lt: ServiceLifetime) -> None: + async with cm_f(*args, **kwargs): + lt.set_started() + await lt.shutting_down.wait() + + return _ResolvedService(svc_f) + + return wrapper + + +@asynccontextmanager +async def observe_services(*lifetimes: ServiceLifetimeView): + await wait_all(svc.started for svc in lifetimes) + try: + yield + finally: + for svc in lifetimes: + svc.shutdown() + await wait_all(svc.stopped for svc in lifetimes) + + +async def _run(svc_f: ServiceF, lt: ServiceLifetime) -> None: + outer_ctx_token = _svc_lt.set(lt) + try: + async with lt.tg as tg: + await svc_f(lt) + tg.cancel_scope.cancel() # Cancel any remaining tasks + except get_cancelled_exc_class() as exc: # BaseException + lt.exception = exc + raise # Always reraise cancellations + except Exception as exc: + lt.exception = unwrap_exc(exc) + finally: + lt.stopped.set() + _svc_lt.reset(outer_ctx_token) + + +def serve( + svc: ServiceF, /, *, parent: ServiceLifetime | None = None +) -> AbstractAsyncContextManager[ServiceLifetimeView]: + return _serve_in(svc, parent) if parent else _serve_root(svc) @asynccontextmanager -async def serve(app: ServiceF, /): - """Start the target service and return control to the caller.""" - async with BlockingPortal() as portal, create_task_group() as host_tg: - host_lifetime = HostLifetime(host_tg, portal) - _start_scv(host_lifetime, app) - yield host_lifetime - host_lifetime.shutdown() +async def _serve_root(svc: ServiceF) -> AsyncIterator[ServiceLifetimeView]: + async with BlockingPortal() as portal: + child_lt = ServiceLifetime(portal) + tg = portal._task_group # noqa + tg.start_soon(_run, svc, child_lt) + await child_lt.started + yield child_lt.view + child_lt.view.shutdown() + await child_lt.stopped @asynccontextmanager -async def serve_app(app: AppF, /): - """Start the target app and return control to the caller.""" - async with BlockingPortal() as portal, create_task_group() as host_tg: - host_lifetime = HostLifetime(host_tg, portal) - _start_scv(host_lifetime, app) - yield host_lifetime - host_lifetime.shutdown() - - -def run(app_f: AppF, /) -> int: - """Run the target app until it stops or is interrupted by a signal.""" - return anyio.run(arun, app_f, **choose_anyio_backend()) - - -async def arun(app_f: AppF, /) -> int: - """Run the target app until it stops or is interrupted by a signal.""" - if threading.current_thread() is not threading.main_thread(): - raise RuntimeError("Signals can only be installed on the main thread") - - async def handle_signals(): - with open_signal_receiver(*HANDLED_SIGNALS) as signals: - async for _ in signals: - # First Ctrl+C (or other termination method) - if not host.shutting_down: - logger.info("Shutting down...") - host.shutdown() - continue - # Ctrl+C again - logger.warning("Forced shutdown") - host.stop() - break - - async with serve_app(app_f) as host: - await host.cancel_on_stop(handle_signals)() - - return host.exit_code +async def _serve_in(svc: ServiceF, parent: ServiceLifetime) -> AsyncIterator[ServiceLifetimeView]: + async def _bind_parent_to(shutdown_trigger: EventView): + await shutdown_trigger + parent.view.shutdown() + + child_lt = parent.start(svc) + async with create_task_group() as observe_tg: + # Bind the parent lifetime (if the child is stopped, shutdown the parent) + observe_tg.start_soon(_bind_parent_to, child_lt.stopped) + await child_lt.started + yield child_lt + child_lt.shutdown() + observe_tg.cancel_scope.cancel() + await child_lt.stopped + + +async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: + async with nullcontext(parent.portal) if parent else BlockingPortal() as portal: + lt = ServiceLifetime(portal) + await _run(svc_f, lt) + return lt.exit_code diff --git a/localpost/hosting/middleware.py b/localpost/hosting/middleware.py new file mode 100644 index 0000000..b97a62a --- /dev/null +++ b/localpost/hosting/middleware.py @@ -0,0 +1,54 @@ +from collections.abc import Awaitable, Callable +from functools import wraps + +from anyio import move_on_after, open_signal_receiver + +from localpost._utils import HANDLED_SIGNALS +from localpost.hosting._host import ServiceLifetime, ServiceLifetimeView, logger + +ServiceF = Callable[[ServiceLifetime], Awaitable[None]] + + +async def _observe_started(lt: ServiceLifetimeView, timeout: float) -> None: + with move_on_after(timeout): + await lt.started + return + raise TimeoutError(f"Service did not start within {timeout} second(s)") + + +def start_timeout[F](timeout: float) -> Callable[[F], F]: + def decorator(func: F) -> F: + @wraps(func) + def wrapper(lt: ServiceLifetime) -> Awaitable[None]: + lt.tg.start_soon(lt.view.cancel_on_shutdown(_observe_started), lt, timeout) + return func(lt) + + return wrapper + + return decorator + + +async def _handle_signals(h: ServiceLifetimeView, signals): + with open_signal_receiver(*signals) as received: + async for _ in received: + # First Ctrl+C (or other termination method) + if not h.shutting_down: + logger.info("Shutting down...") + h.shutdown() + continue + # Ctrl+C again + logger.warning("Forced shutdown") + h.stop() + break + + +def shutdown_on_signal(*signals) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: + @wraps(func) + def wrapper(lt: ServiceLifetime) -> Awaitable[None]: + lt.tg.start_soon(_handle_signals, lt.view, signals or HANDLED_SIGNALS) + return func(lt) + + return wrapper + + return decorator diff --git a/localpost/hosting/services/_asgi.py b/localpost/hosting/services/_asgi.py new file mode 100644 index 0000000..f7b2c5b --- /dev/null +++ b/localpost/hosting/services/_asgi.py @@ -0,0 +1,23 @@ +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any + +from localpost._utils import Event + + +def report_started(started: Event, asgi_app: Callable[..., Any]) -> Callable[..., Any]: + @wraps(asgi_app) + def asgi_app_wrapper( + scope: dict[str, Any], receive: Callable[..., Awaitable[Any]], send: Callable[..., Awaitable[Any]] + ) -> Awaitable[Any]: + if scope["type"] == "lifespan": + + def wrapped_send(message: dict[str, Any]) -> Awaitable[Any]: + if message["type"] == "lifespan.startup.complete": + started.set() + return send(message) + + return asgi_app(scope, receive, wrapped_send) + return asgi_app(scope, receive, send) + + return asgi_app_wrapper diff --git a/localpost/hosting/services/grpc.py b/localpost/hosting/services/grpc.py new file mode 100644 index 0000000..95ea78f --- /dev/null +++ b/localpost/hosting/services/grpc.py @@ -0,0 +1,30 @@ +from typing import final + +import grpc + +from localpost._utils import wait_any + +from .._host import ServiceLifetimeManager + + +@final +class AsyncGrpcService: + def __init__(self, server: grpc.aio.Server): + self._server = server + self.name = "grpc" + self.grace_termination_period = 5 + + @property + def server(self) -> grpc.aio.Server: + return self._server + + async def __call__(self, service_lifetime: ServiceLifetimeManager): + async def handle_svc_shutdown(): + await service_lifetime.shutting_down + # During the grace period, the server won't accept new connections and allow existing RPCs to continue + # within the grace period. + await self._server.stop(self.grace_termination_period) + + await self._server.start() + service_lifetime.set_started() + await wait_any(handle_svc_shutdown, self._server.wait_for_termination) diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py new file mode 100644 index 0000000..65a1cc1 --- /dev/null +++ b/localpost/hosting/services/hypercorn.py @@ -0,0 +1,21 @@ +from collections.abc import Awaitable + +import hypercorn +from sniffio import current_async_library + +from localpost.hosting._host import ServiceF, ServiceLifetime, service +from localpost.hosting.services._asgi import report_started + + +@service +def hypercorn_server(app, config: hypercorn.Config, /) -> ServiceF: + def run(sl: ServiceLifetime) -> Awaitable[None]: + # See https://hypercorn.readthedocs.io/en/latest/how_to_guides/api_usage.html + if current_async_library() == "trio": + from hypercorn.trio import serve + else: + from hypercorn.asyncio import serve # type: ignore[assignment] + observed_app = report_started(sl.started, app) + return serve(observed_app, config, shutdown_trigger=sl.shutting_down.wait) + + return run diff --git a/localpost/hosting/services/uvicorn.py b/localpost/hosting/services/uvicorn.py index 739d62b..38b9e85 100644 --- a/localpost/hosting/services/uvicorn.py +++ b/localpost/hosting/services/uvicorn.py @@ -1,58 +1,38 @@ -from __future__ import annotations - -from collections.abc import Callable -from contextlib import asynccontextmanager -from os import getenv -from typing import Any, cast, final +from contextlib import nullcontext import uvicorn from anyio import create_task_group -from typing_extensions import Self -from localpost._utils import start_task_soon -from localpost.hosting._host import ServiceLifetime +from localpost._utils import AnyEventView +from localpost.hosting._host import ServiceLifetime, service # Also see /health endpoint in http_app.py example -@final -class UvicornService: - def __init__(self, config: uvicorn.Config): - self.config = config - self.name = "uvicorn" - - @classmethod - def for_app(cls, app: Callable[..., Any]) -> Self: - return cls( - uvicorn.Config( - app, - host=getenv("UVICORN_HOST", "127.0.0.1"), - port=int(getenv("UVICORN_PORT", "8000")), - log_config=None, # Do not touch current logging configuration - ) - ) - - # It is hard to use server.serve() directly, because it overrides the signal handlers. A possible workaround is - # to call it in a separate thread, but currently it looks like an overkill. - # See uvicorn.Server._serve() for the original implementation. - async def __call__(self, sl: ServiceLifetime) -> None: - config = self.config +@service +def uvicorn_server(config: uvicorn.Config): + if config.should_reload: + raise ValueError("Uvicorn: reload is not supported") + elif config.workers > 1: + raise ValueError("Uvicorn: multiple workers are not supported") + + async def run(sl: ServiceLifetime) -> None: server = uvicorn.Server(config) + server_main_loop = server.main_loop - if config.should_reload: - raise ValueError("Uvicorn: reload is not supported") - elif config.workers > 1: - raise ValueError("Uvicorn: multiple workers are not supported") + async def lf_aware_main_loop(): + sl.set_started() + await server_main_loop() + sl.set_shutting_down() - async def serve(): - service_lifetime.set_started() - await server.main_loop() - service_lifetime.set_shutting_down() - await server.shutdown() + server.main_loop = lf_aware_main_loop + server.capture_signals = nullcontext - async def observe_shutdown(): - await service_lifetime.shutting_down + async def observe_shutdown(trigger: AnyEventView): + await trigger.wait() server.should_exit = True async with create_task_group() as tg: - start_task_soon(tg, serve) - start_task_soon(tg, observe_shutdown) + tg.start_soon(server.serve, None) + tg.start_soon(observe_shutdown, sl.shutting_down) + + return run diff --git a/localpost/http/README.md b/localpost/http/README.md index 68ba270..c244610 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -1 +1,37 @@ -# Simple HTTP server for modern environments +## HTTP server + + +## REST (OpenAPI) framework + +Focus: +- LLM apps, APIs + - streaming + +Vision: +- batteries not included + - no DB, just Python to/from HTTP orchestration +- go from specs + - OpenAPI app + - OpenRPC app + - JSON-API app + - application/vnd.api+json + - https://github.com/apiad/jsonapi + - ... +- plain old (sync) Python, to eliminate async complexity and pitfalls +- (type) inference as much as possible, to eliminate boilerplate + - make life easier when possible, when we can infer things from Python code (like OpenAPI schema from types, etc.) +- + + +### How is it different from? +- Flask + - web first (templates, etc), no built-in OpenAPI support + - https://flask-restless.readthedocs.io/en/latest/ +- FastAPI + - Pydantic only + - OpenAPI only + - async only + - more complex (dependencies, etc.) + + +## JSON-RPC (OpenRPC) framework diff --git a/localpost/http/TODO.md b/localpost/http/TODO.md new file mode 100644 index 0000000..dacfa85 --- /dev/null +++ b/localpost/http/TODO.md @@ -0,0 +1,17 @@ +# Vision + +- HTTP 1.1 only, no other versions for now (e.g. HTTP/2, HTTP/3) + - so we expose h11 directly + + + +## TODOs + +- Converters (for fluent handler, in OpenAPI) + - werkzeug.Request + - Pydantic + - msgspec + - dataclass + - attrs + + diff --git a/localpost/http/_benchmark.py b/localpost/http/_benchmark.py new file mode 100644 index 0000000..5c82070 --- /dev/null +++ b/localpost/http/_benchmark.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import logging +import signal + +import anyio + +from localpost.http.config import WorkerConfig +from localpost.http.worker import http_server +from ._benchmark_app import app + + +def _flask_app_bench(): + logging.basicConfig(level=logging.DEBUG) + + async def _run(): + async with http_server(app, WorkerConfig(max_requests=100)) as w: + with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: + async for _ in signals: + w.shutdown() + break + + # noinspection PyTypeChecker + anyio.run(_run) + + +if __name__ == "__main__": + _flask_app_bench() diff --git a/localpost/http/_benchmark.sh b/localpost/http/_benchmark.sh new file mode 100644 index 0000000..61ac6c1 --- /dev/null +++ b/localpost/http/_benchmark.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# TODO For each option: Run hey, output CSV + + +# TODO After all: compare CSV results, output summary table diff --git a/localpost/http/_benchmark_app.py b/localpost/http/_benchmark_app.py new file mode 100644 index 0000000..4c8f878 --- /dev/null +++ b/localpost/http/_benchmark_app.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from flask import Flask, stream_with_context +from flask import request as flask_request + +app = Flask(__name__) + + +@app.route("/hello/") +def hello(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + return f"Hello, {name}! Your User-Agent is: {user_agent}\n" + + +@app.route("/hello-stream/") +@stream_with_context +def hello_stream(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + yield f"Hello, {name}! " + yield f"Your User-Agent is: {user_agent}\n" + + +@app.route("/hello-data/", methods=["POST"]) +@stream_with_context +def hello_data(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + yield f"Hello, {name}! " + yield f"Your User-Agent is: {user_agent}\n" + json_data = flask_request.get_json(force=True, cache=False) + yield f"And you sent: {json_data}\n" diff --git a/localpost/http/_benchmark_uvicorn.py b/localpost/http/_benchmark_uvicorn.py new file mode 100644 index 0000000..95c5651 --- /dev/null +++ b/localpost/http/_benchmark_uvicorn.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import logging + +import uvicorn +from a2wsgi import WSGIMiddleware +from ._benchmark_app import app + + +def _flask_app_bench(): + logging.basicConfig(level=logging.DEBUG) + + asgi_app = WSGIMiddleware(app) + uvicorn.run(asgi_app, host="0.0.0.0", port=8000, access_log=False) + + +if __name__ == "__main__": + _flask_app_bench() diff --git a/localpost/http/config.py b/localpost/http/config.py index 033bac6..c3335fe 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -1,9 +1,8 @@ from __future__ import annotations +import io from dataclasses import dataclass, field -from typing import final - -from localpost._sync_utils import CHECK_TIMEOUT +from typing import final, Final __all__ = [ "LOGGER_NAME", @@ -11,7 +10,10 @@ "ServerConfig", ] -LOGGER_NAME = "localpost.http" +DEFAULT_BUFFER_SIZE: Final = io.DEFAULT_BUFFER_SIZE + +LOGGER_NAME: Final = "localpost.http" +# TODO Access logger?.. @final @@ -20,9 +22,9 @@ class ServerConfig: host: str = "0.0.0.0" port: int = 8000 backlog: int = 1024 - """Maximum number of queued connections.""" - rw_timeout: float = 3.0 - """Timeout (seconds) for receive/send operations on a client connection.""" + """Maximum number of queued (in the kernel) connections.""" + # rw_timeout: float = 3.0 + # """Timeout (seconds) for receive/send operations on a client connection.""" keep_alive_timeout: float = 15.0 """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB @@ -30,6 +32,7 @@ class ServerConfig: # TODO Support Keep-Alive response header (timeout, max requests) +# FIXME Remove, just move to ... @final @dataclass(frozen=True, slots=True) class WorkerConfig: diff --git a/localpost/http/openapi/__init__.py b/localpost/http/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/http/openapi/app.py b/localpost/http/openapi/app.py new file mode 100644 index 0000000..c6099eb --- /dev/null +++ b/localpost/http/openapi/app.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import inspect +import json +import logging +import threading +from collections.abc import Callable, Mapping, Sequence, Collection +from dataclasses import dataclass +from http import HTTPMethod +from typing import Protocol, Self, final, Annotated + +import localpost.spec.openapi as openapi_spec +from localpost.http.router import RequestHandler, Router + +FluentOpDecorator = Callable[[Callable[..., object]], RequestHandler] + + +@final +class HttpApp: + def __init__(self): + self._router: Router | None = None + self._router_lock: threading.Lock = threading.Lock() + self._path_ops: list[FluentPathOp] = [] + + def __call__(self, request: HTTPContext) -> None: + self.router(request) + + def wsgi(self, environ, start_response): + """WSGI app, to be used with any WSGI server, e.g. Granian.""" + return self.router.wsgi(environ, start_response) + + def register_arg_resolver(self, resolver_factory: ArgResolverFactory) -> None: + # TODO Register a custom ArgResolverFactory for handling custom types in path operation parameters + pass + + def register_result_resolver(self, resolver_factory: ArgResolverFactory) -> None: + # TODO Register a custom ArgResolverFactory for handling custom types in path operation parameters + pass + + @property + def docs(self) -> openapi_spec.OpenAPI: + pass + + @property + def router(self) -> Router: + if router := self._router: + return router + with self._router_lock: + if not self._router: + self._router = self._create_router() + return self._router + + def _create_router(self) -> Router: + # FIXME Go through all registered FluentPathOps, convert their path patterns to regexes, group by path regex, create a Router with the resulting list of (regex, {method: handler}) pairs + pass + + def register(self, op: FluentPathOp) -> FluentPathOp: + self._path_ops.append(op) + with self._router_lock: + self._router = None + return op + + def get(self, path: str) -> FluentOpDecorator: + """Decorator to register a GET operation on the given path.""" + def decorator(func) -> RequestHandler: + return self.register(FluentPathOp(HTTPMethod.GET, path, func)) + + return decorator + + def post(self, path: str) -> FluentOpDecorator: + """Decorator to register a POST operation on the given path.""" + def decorator(func) -> RequestHandler: + return self.register(FluentPathOp(HTTPMethod.POST, path, func)) + + return decorator + + + + + + + + + + +@final +@dataclass(eq=False, frozen=True) +class FluentPathOp: + method: HTTPMethod + path: str + """Path pattern, like /books/{id} or /shop/{name}/checkout.""" + + target: Callable[..., object] + arg_resolvers: Mapping[str, ArgResolver] + response_resolver: ResponseResolver + """ + To resolve (create) a response from the target's return value. + + E.g. if the target returns a dict, we can serialize it to JSON and set the Content-Type header. + """ + + # # Security is for sure out of our scope, so this should be declared manually by the user + # security: openapi_spec.SecurityScheme | None = None + # TODO Simply supply a doc customizer... + + @classmethod + def create(cls, method: HTTPMethod, path: str, func, /) -> Self: + pass # FIXME Implement: inspect func signature, create ArgResolvers for its parameters, create a FluentPathOpHandler with the target and resolvers + + # Build it all in the root app... + # @cached_property + # def docs(self) -> openapi_spec.OpenAPIRoute: + # pass + + def __call__(self, request: RESTContext) -> None: + args = {name: resolver(request) for name, resolver in self.arg_resolvers.items()} + return_value = self.target(**args) + self.response_resolver(request, return_value) + + + + + + + + + +# class ResponseResolverFactory(Protocol): +# def __call__(self, return_annotation: type, /) -> ResponseResolver: +# ... + + +class ResponseResolver(Protocol): + def __call__(self, result: object, ctx: RESTContext, http_ctx: HTTPContext, /) -> None: + # if generator — run the generator and send chunks as they are produced, set Content-Type to text/event-stream + # if File — set Content-Type according to the file type, send file in chunks + # ... + ... + + +class DefaultResponseResolverFactory: + def __call__(self, return_annotation: type, /) -> ResponseResolver: + # TODO Implement a default resolver that handles common types (str, dict, etc.) and serializes them to the appropriate format according to the Accept header (e.g. JSON for dict) + pass + + +# class Redirect: # Not an exception, but a valid return type for a path operation, to be handled by a custom ResponseResolver +# pass +# +# +# class FileResponse: +# pass +# +# +# class SSEResponse: +# pass + + + + + + + + + + + + + +class ArgResolverFactory: + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + ... + + +class ArgResolver(Protocol): + def __call__(self, request: RESTContext, /) -> object: + """ + Resolve an argument for the target function from the request context. + + Raises ValueError if case of any issues, e.g. missing required header or query param, invalid path param + format, etc. + """ + ... + + +@final +@dataclass(kw_only=True, frozen=True, slots=True) +class FromBody(ArgResolverFactory): + """ + Built-in body resolver that supports most common Python types (dataclasses, Pydantic, msgpack) and input + formats (JSON, msgpack). + + For any complex case, just implement a custom ArgResolver and attach it to the function parameter via Annotated. + """ + + json_converter: Callable[[bytes], object] | None = None + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + json_converter = self.json_converter or json.loads + + if param.annotation is inspect.Parameter.empty: + assert param.default is inspect.Parameter.empty # Body is required (more for simplicity for now) + + def resolve_any(request: RESTContext) -> object: + if not (content_type := request.get_header("content-type")): + raise ValueError("Missing Content-Type header") + if "json" in content_type: + return json_converter(request.body.read()) + raise ValueError(f"Unsupported Content-Type: {content_type}") + + return resolve_any + + param_type = param.annotation # TODO Deal with Annotated, Optional, etc. + assert isinstance(param_type, type) + + # TODO Decode Pydantic models with Pydantic, msgspec.Struct with msgspec + # TODO Decode dataclasses with msgspec + def resolve(request: RESTContext) -> object: + pass + + return resolve + + +@final +@dataclass(frozen=True, slots=True) +class FromHeader(ArgResolverFactory): + """Resolve from a header, e.g. X-User-Id.""" + + name: str | None = None + converter: Callable[[str], object] | None = None + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + pass # TODO Implement, similar to FromQuery and FromPath + + +@final +@dataclass(frozen=True, slots=True) +class FromQuery(ArgResolverFactory): + """Resolve from a query parameter, e.g. /books?author=tolkien.""" + + param_name: str | None = None + converter: Callable[[Sequence[str]], object] | None = None + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + param_name = self.param_name or param.name + has_default = param.default is not inspect.Parameter.empty + if self.converter: + converter = self.converter + elif param.annotation is not inspect.Parameter.empty: + param_type = param.annotation # TODO Deal with Annotated, Optional, etc. + assert isinstance(param_type, type) + if issubclass(param_type, Collection): # list, set, etc. + converter = param_type + else: + converter = lambda values: param_type(values[0]) + else: # First argument as a string by default, if not annotated and no converter provided + converter = lambda values: values[0] + + def resolve(request: RESTContext) -> object: + if param.default is inspect.Parameter.empty: + return converter(request.query_args[param_name]) + return converter(request.query_args.get(param_name, param.default)) + + return resolve + + +@dataclass(frozen=True, slots=True) +class FromPath(ArgResolverFactory): + """Resolve from a path parameter, e.g. /books/{id}.""" + + param_name: str | None = None + converter: Callable[[str], object] | None = None + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + param_name = self.param_name or param.name + if self.converter: + converter = self.converter + elif param.annotation is not inspect.Parameter.empty: + param_type = param.annotation # TODO Deal with Annotated, Optional, etc. + converter = param_type + else: + converter = str + + def resolve(request: RESTContext) -> object: + if param.default is inspect.Parameter.empty: + return converter(request.path_args[param_name]) + return converter(request.path_args.get(param_name, param.default)) + + return resolve + + + + + + + + +class Example: + """Example value for a parameter or return type, to be included in the OpenAPI docs.""" + + def __init__(self, value: object): + self.value = value + + + + + + + +# noinspection DuplicatedCode +def _sample_usage(): + from localpost.http.worker import http_server + from localpost.http.config import WorkerConfig + import anyio + import signal + from uuid import UUID + + logging.basicConfig(level=logging.DEBUG) + + app = HttpApp() + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + def hello(book_id: UUID, page_number: int) -> Annotated[dict, + Example({ + "id": "123e4567-e89b-12d3-a456-426614174000", + "title": "The Lord of the Rings", + "author": "J.R.R. Tolkien" + })]: + # Dict (or any other type, actually) is serialized to JSON (or other format, according to the Accept header) + # page_number is just to demonstrate multiple parameters from different sources (path and query) + return {"id": str(id), "title": "The Lord of the Rings", "author": "J.R.R. Tolkien"} + + async def _run(): + async with http_server(simple_app, WorkerConfig()): + with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: + async for _ in signals: + break # Shutdown on SIGTERM or SIGINT + + # noinspection PyTypeChecker + anyio.run(_run) + + +if __name__ == "__main__": + _sample_usage() diff --git a/localpost/http/openrpc/__init__.py b/localpost/http/openrpc/__init__.py new file mode 100644 index 0000000..ac055ff --- /dev/null +++ b/localpost/http/openrpc/__init__.py @@ -0,0 +1,13 @@ +# TODO https://www.open-rpc.org/ + +# TODO JSON-RPC over HTTP + +# See https://github.com/microsoft/vs-streamjsonrpc?tab=readme-ov-file#streamjsonrpc + +# See https://github.com/unum-cloud/UCall + +# See https://github.com/smagafurov/fastapi-jsonrpc + +# ??? — multiple apps on the same server... +# OK, just mount to HTTPContext + diff --git a/localpost/http/router.py b/localpost/http/router.py new file mode 100644 index 0000000..eae8eba --- /dev/null +++ b/localpost/http/router.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping, Buffer, Callable, Iterable +from contextlib import ExitStack, AbstractContextManager +from dataclasses import dataclass, field +from http import HTTPMethod + +import h11 + +from localpost.http.server import HTTPReq +from localpost.http.uritemplate import URITemplate + + +# See also: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/use-http-context#httprequest +@dataclass(eq=False, slots=True) +class RESTContext: + _context: HTTPReq + exit_stack: ExitStack + + request: h11.Request + path_info: str + query_string: str + query_args: Mapping[str, list[str]] + path_args: Mapping[str, str] + """Path params of the matched route.""" + + result: OpResult = field(default_factory=lambda: Ok(None), init=False) + + _body: bytearray | None = field(default=None, init=False) + + def receive_all(self, cache=False) -> Buffer: + """Read the request body.""" + if self._body is not None: + return self._body + body = bytearray() + while True: + chunk = self._context.receive() + if not chunk: + break + body.extend(chunk) + if cache: + self._body = body + return body + + def get_header(self, name: str, /, default=None) -> str | None: + raw_name = name.encode() + for header_name, header_value in self.request.headers: + if header_name == raw_name: + return header_value.decode() + return default + + +RequestHandler = Callable[[RESTContext], None] +RequestHandlerMiddleware = Callable[[RequestHandler], RequestHandler] + +HTTPResponse = tuple[int, Iterable[tuple[str, str]], AbstractContextManager[Iterable[bytes]]] +ResultConverter = Callable[[RESTContext], HTTPResponse] + + +def not_found(request: RESTContext) -> None: + """Default handler for unmatched routes.""" + request.start_response(h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")])) + # TODO Problem details if the client supports JSON (Accept header contains "json") + request.send(b"Not Found") + + +def method_not_allowed(request: RESTContext) -> None: + """Default handler for unsupported HTTP methods.""" + request.start_response(h11.Response(status_code=405, headers=[(b"Content-Type", b"text/plain")])) + # TODO Problem details if the client supports JSON (Accept header contains "json") + request.send(b"Method Not Allowed") + + +class Router: + paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]] + + def __call__(self, request: HTTPReq) -> None: + # FIXME Find a match, create RESTContext with extracted path_args, entered ExitStack, etc. + pass + + def wsgi(self, environ, start_response): + """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" + # TODO Adapt from WSGI, create RESTContext, ... + pass + + + +@dataclass(frozen=True, slots=True) +class OpResult: + code: int + headers: Mapping[str, str] + body: object + + def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | None = None): + object.__setattr__(self, "code", code) + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", headers or {}) + + +@dataclass(frozen=True, slots=True) +class Ok[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(200, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class Created[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(201, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class NoContent(OpResult): + def __init__(self, /, *, headers: dict[str, str] | None = None): + super().__init__(204, None, headers=headers) + + +@dataclass(frozen=True, slots=True) +class MovedPermanently(OpResult): + def __init__(self, location: str, /, *, headers: dict[str, str] | None = None): + super().__init__(301, None, headers={"Location": location, **(headers or {})}) + + +@dataclass(frozen=True, slots=True) +class BadRequest[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(400, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class Unauthorized[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(401, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class Forbidden[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(403, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class NotFound[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(404, body, headers=headers) diff --git a/localpost/http/server.py b/localpost/http/server.py index f9c04e5..6c21214 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -13,74 +13,56 @@ import socket import threading import time -from collections.abc import Callable, Iterable, Iterator -from contextlib import AbstractContextManager, closing, nullcontext, suppress +from collections.abc import Callable, Iterator +from contextlib import closing, contextmanager from dataclasses import dataclass, field -from io import DEFAULT_BUFFER_SIZE, IOBase, RawIOBase -from typing import final +from typing import final, Literal import h11 -from localpost._sync_utils import CHECK_TIMEOUT, check_cancelled -from localpost.http.config import LOGGER_NAME, ServerConfig +from localpost import threadtools +from localpost.http.config import DEFAULT_BUFFER_SIZE, LOGGER_NAME, ServerConfig -__all__ = ["Server", "ClientConn", "RequestHandler", "start_http_server"] +__all__ = ["Server", "HTTPReq", "RequestHandler", "start_http_server"] -def start_http_server(config: ServerConfig) -> AbstractContextManager[Server]: + +@contextmanager +def start_http_server(config: ServerConfig) -> Iterator[Server]: + logger = logging.getLogger(LOGGER_NAME) server_sock = socket.create_server( (config.host, config.port), backlog=config.backlog, reuse_port=True, ) - logger = logging.getLogger(LOGGER_NAME) + selector = selectors.DefaultSelector() - server = Server(server_sock, config, logger) - logger.info(f"Serving on {config.host}:{server.port}") - return closing(server) + server_sock.settimeout(0) + selector.register(server_sock, selectors.EVENT_READ) + + # server_sock.close() # Safe to call it from another thread, will cause accept() to raise OSError + with closing(server_sock), closing(selector): + server = Server(config, logger, server_sock, selector) + logger.info(f"Serving on {config.host}:{server.port}") + yield server @dataclass(slots=True) class Connections: server: Server - _selector: selectors.BaseSelector = field(default_factory=selectors.DefaultSelector) + _selector: selectors.BaseSelector _lock: threading.Lock = field(default_factory=threading.Lock) - # Add self-pipe wakeup trick and queue - def register(self, conn: ClientConn) -> None: - conn.idle_since, sock = time.monotonic(), conn.sock.raw_sock - sock.settimeout(0) - with self._lock: - self._selector.register(sock, selectors.EVENT_READ, data=conn) - - def unregister(self, conn: ClientConn) -> None: - sock = conn.sock.raw_sock - with self._lock: - self._selector.unregister(sock) - sock.settimeout(CHECK_TIMEOUT) - - def _find_stale(self): - now, timeout = time.monotonic(), self.server.config.keep_alive_timeout - for key in self._selector.get_map().values(): - if (conn := key.data) and isinstance(conn, ClientConn): - if now - conn.idle_since > timeout: - yield conn - - def _cleanup_stale(self): - with self._lock: - for conn in list(self._find_stale()): - self._selector.unregister(conn.sock.raw_sock) - conn.sock.close() - def select(self) -> Iterator[selectors.SelectorKey]: selector, server_sock = self._selector, self.server.sock server_sock.settimeout(0) selector.register(server_sock, selectors.EVENT_READ) try: - while self.server.running: - check_cancelled() + while True: self._cleanup_stale() - for key, _ in selector.select(timeout=CHECK_TIMEOUT): + # TODO Take iteration payload (pending connections) and set it empty, under the lock + # TODO Add selector.select() to the current payload (chain) + for key, _ in selector.select(timeout=threadtools.CHECK_TIMEOUT): yield key finally: selector.unregister(server_sock) @@ -90,202 +72,217 @@ def select(self) -> Iterator[selectors.SelectorKey]: class Server: def __init__( self, - server_sock: socket.socket, config: ServerConfig, logger: logging.Logger, + server_sock: socket.socket, + selector: selectors.BaseSelector, ) -> None: self.sock = server_sock self.port = server_sock.getsockname()[1] """ Actual port the server is listening on. - + Can be useful when port 0 is specified to auto-assign a free port. """ + self.selector = selector self.config = config - self._conns = Connections(self) self._logger = logger - self._running = False + self._lock = threading.Lock() - @property - def running(self) -> bool: - return self._running + def _find_stale(self): + now, keep_alive_timeout = time.monotonic(), self.server.config.keep_alive_timeout + for key in self._selector.get_map().values(): + if (conn := key.data) and isinstance(conn, HTTPConn): + if now - conn.idle_since > keep_alive_timeout: + yield conn - def close(self) -> None: - """Stop accepting new connections and close the server socket.""" - if not self._running: - return - self.sock.close() # Safe to call if from another thread, will cause accept() to raise OSError + def _cleanup_stale(self): + with self._lock: + for conn in list(self._find_stale()): + self._selector.unregister(conn.sock.raw_sock) + conn.close() - def keep_alive(self, conn: ClientConn) -> None: - self._conns.register(conn) + # TODO Add self-pipe wakeup trick and queue + def track(self, conn: HTTPConn) -> None: + conn.idle_since, sock = time.monotonic(), conn.sock.raw_sock + sock.settimeout(0) + with self._lock: + self._selector.register(sock, selectors.EVENT_READ, data=conn) + conn.tracked = True - def accept(self) -> Iterable[ClientConn]: + # TODO Remove + def keep_alive(self, conn: HTTPConn) -> None: + self.track(conn) + + def stop_tracking(self, conn: HTTPConn) -> None: + sock = conn.sock.raw_sock + with self._lock: + self._selector.unregister(sock) + sock.settimeout(None) + conn.tracked = False + + def run(self, h: RequestHandler) -> Iterator[None]: conns, server_sock = self._conns, self.sock self._running = True for key in conns.select(): + yield if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() cs = ClientSocket(client_sock, client_addr, self.config.rw_timeout) - yield ClientConn(self, self.config, cs, self._logger) - elif (conn := key.data) and isinstance(conn, ClientConn): - conns.unregister(conn) - yield conn + # yield HTTPConn(self, self.config, cs, self._logger) + self._handle_client_conn(HTTPConn(self, self.config, cs, self._logger)) + elif (conn := key.data) and isinstance(conn, HTTPConn): + # conns.unregister(conn) + # yield conn + self._handle_client_conn(conn) else: raise RuntimeError(f"Unexpected selector key: {key!r}") -@dataclass(frozen=True, slots=True) -class ClientSocket: - raw_sock: socket.socket +@final +@dataclass(slots=True) +class HTTPConn: + sock: socket.socket addr: tuple[str, int] - timeout: float - """Timeout for receive/send operations.""" - - def recv(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - for _ in range(int(self.timeout / CHECK_TIMEOUT)): - check_cancelled() - with suppress(TimeoutError): - return self.raw_sock.recv(size) - raise TimeoutError("receive timeout") - - def sendall(self, buf, /) -> None: - for _ in range(int(self.timeout / CHECK_TIMEOUT)): - check_cancelled() - with suppress(TimeoutError): - return self.raw_sock.sendall(buf) - raise TimeoutError("send timeout") - + parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) + idle_since: float = 0.0 + tracked: bool = False + current_req: _HTTPReq | None = None + + def req_state(self) -> Literal["DRAIN_BODY", "COLLECT_BODY"] | None: + our_state, their_state = self.our_state, self.their_state + if self.current_req: + if our_state in (h11.DONE, h11.MUST_CLOSE): + return "DRAIN_BODY" + # We are in the middle of processing a request, the handler is requested to collect the full request body + assert our_state in (h11.SEND_RESPONSE, h11.SEND_BODY) + if their_state is h11.SEND_BODY: + return "COLLECT_BODY" + + # FIXME Do call it! def close(self) -> None: - self.raw_sock.close() + self.sock.close() + def receive(self, size: int, /) -> None: + self.parser.receive_data(self.sock.recv(size)) -@final -@dataclass(slots=True) -class ClientConn: - server: Server - config: ServerConfig - sock: ClientSocket - _logger: logging.Logger - _conn: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) - idle_since: float = 0.0 + def send(self, event: h11.Event) -> None: + self.sock.sendall(self.parser.send(event)) def __call__(self, h: RequestHandler) -> None: - conn, sock = self._conn, self.sock - try: - while True: - if (prev_state := conn.our_state) is h11.DONE: - conn.start_next_cycle() - event = conn.next_event() - if event is h11.NEED_DATA and prev_state is h11.DONE: - self.server.keep_alive(self) - return - elif event is h11.NEED_DATA: - conn.receive_data(sock.recv()) - elif isinstance(event, h11.Request): - self._handle_request(h, event) - if conn.our_state is h11.MUST_CLOSE: - sock.close() + parser, sock = self.parser, self.sock + + while self.tracked: + if parser.our_state is h11.MUST_CLOSE: + client_conn.close() + return + if parser.our_state is h11.DONE and parser.their_state is h11.DONE: + parser.start_next_cycle() + self.current_req = None + event = parser.next_event() + + if (self.current_req and + parser.their_state in h11.SEND_BODY and + parser.our_state in (h11.SEND_RESPONSE, h11.SEND_BODY) + ): # We are in the middle of processing a request, the handler is requested to collect the full request body + if event is h11.NEED_DATA: + self._conn.receive(size) + elif isinstance(event, h11.Data): + self.current_req.request_body += event.data + elif isinstance(event, h11.EndOfMessage): + h(self.current_req) # Call the handler again, now with the full request body + if req_ctx.borrowed: return - elif isinstance(event, h11.ConnectionClosed): - self._logger.debug("Client closed connection") - sock.close() - return - else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) - raise RuntimeError(f"Unexpected {event!r} in the connection loop") - except TimeoutError: - self._logger.debug("Client connection timed out", exc_info=True) - - def _handle_request(self, h: RequestHandler, request: h11.Request) -> None: - conn, sock = self._conn, self.sock - body = RequestBodyStream(conn, sock) - response, response_chunks = h(self, request, body) - with response_chunks as chunks: - sock.sendall(conn.send(response)) - check_cancelled() - for chunk in chunks: - check_cancelled() - sock.sendall(conn.send(chunk)) - sock.sendall(conn.send(h11.EndOfMessage())) - body.drain() # TODO Finally?.. + elif event is h11.NEED_DATA: + if parser.they_are_waiting_for_100_continue: + if self.req_state() == "DRAIN_BODY": + self._logger.debug("Draining request body for a request with 'Expect: 100-continue' header") + self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) + else: + self.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) + parser.receive_data(sock.recv(DEFAULT_BUFFER_SIZE)) + elif isinstance(event, h11.Request): + self.current_req = req_ctx = _HTTPReq(self, client_conn, event) + h(req_ctx) + if req_ctx.borrowed: + return + # There can be multiple cases: + # - the request is fully processed and the response is sent, we are done + # - their_state is SEND_BODY, which means that the handler requested the full request body + elif isinstance(event, h11.ConnectionClosed): + self._logger.debug("Client closed connection") + client_conn.close() + return + else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) + raise RuntimeError(f"Unexpected {event!r} in the connection loop") -@dataclass(slots=True) -class RequestBodyStream(RawIOBase): - conn: h11.Connection - sock: ClientSocket - finished: bool = False - - def writable(self): - return False - - def seekable(self): - return False - def readable(self): - return True +@dataclass(eq=False, frozen=True, slots=True) +class _HTTPReq: + _server: Server + _conn: HTTPConn - def readall(self): - chunks = bytearray() - with suppress(EOFError): - while not self.finished: - chunks.extend(self._receive(DEFAULT_BUFFER_SIZE)) - return chunks + request: h11.Request + request_body: bytes | None = field(default=None, init=False) - def readinto(self, b: bytearray, /) -> int: - try: - data = self._receive(len(b)) - size = len(data) - b[:size] = data - return size - except EOFError: - return 0 - - def _receive(self, size: int, /) -> bytes: - """Receive next chunk of body data from the socket via h11.""" - if self.finished: - raise EOFError() - conn, sock = self.conn, self.sock + @property + def borrowed(self) -> bool: + return not self._conn.tracked + + def borrow(self) -> BorrowedHTTPReq: + assert self._conn.tracked + self._server.stop_tracking(self._conn) + return BorrowedHTTPReq(self._server, self._conn, self.request) + + # Usually with a simple response, like 404 or 405, with a small body (so it can fit into the kernel socket buffer, + # to not block the server thread) + def complete(self, response: h11.Response, body: bytes | None = None) -> None: + self._conn.send(response) + if body is not None: + self._conn.send(h11.Data(body)) + self._conn.send(h11.EndOfMessage()) + + +@dataclass(eq=False, frozen=True, slots=True) +class BorrowedHTTPReq: + _server: Server + _conn: HTTPConn + + request: h11.Request + request_body: bytearray | None + + def give_back(self) -> None: + assert not self._conn.tracked + self._server.track(self._conn) + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + parser = self._conn.parser + if parser.their_state is h11.DONE: # Request body exhausted + return b"" while True: - event = conn.next_event() + event = parser.next_event() if event is h11.NEED_DATA: - if conn.they_are_waiting_for_100_continue: - sock.sendall(conn.send( - h11.InformationalResponse(status_code=100, headers=[], reason="Continue"))) - conn.receive_data(sock.recv(size)) + if parser.they_are_waiting_for_100_continue: + self._conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) + self._conn.receive(size) elif isinstance(event, h11.Data): return event.data elif isinstance(event, h11.EndOfMessage): - self.finished = True - raise EOFError() - elif isinstance(event, h11.ConnectionClosed): - raise ConnectionAbortedError("Client closed connection unexpectedly") + return b"" + # elif isinstance(event, h11.ConnectionClosed): + # raise ConnectionAbortedError("Client closed connection unexpectedly") else: raise RuntimeError(f"Unexpected h11 event: {event!r}") - def drain(self) -> None: - """Consume any remaining body data (required before starting next request cycle).""" - with suppress(EOFError): - while not self.finished: - self._receive(DEFAULT_BUFFER_SIZE) - - -RequestHandler = Callable[ - [ClientConn, h11.Request, IOBase], - tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]] -] - - -def _main(): - logging.basicConfig(level=logging.DEBUG) + def start_response(self, response: h11.Response, /) -> None: + self._conn.send(response) - def simple_app(c: ClientConn, r: h11.Request, rb: IOBase): - return (h11.Response(status_code=200, headers=[("Content-Type", "text/plain")]), - nullcontext([h11.Data(b"Hello, World!\n")])) + def send(self, chunk: bytes, /) -> None: + self._conn.send(h11.Data(chunk)) - with start_http_server(ServerConfig()) as server: - for client_conn in server.accept(): - client_conn(simple_app) + def end_response(self) -> None: + self._conn.send(h11.EndOfMessage()) -if __name__ == "__main__": - _main() +RequestHandler = Callable[[HTTPReq], None] diff --git a/localpost/http/uritemplate.py b/localpost/http/uritemplate.py new file mode 100644 index 0000000..ece4c97 --- /dev/null +++ b/localpost/http/uritemplate.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import Self, final + + +@final +@dataclass(frozen=True, slots=True) +class URITemplate: + """Basic URI Template (RFC 6570) implementation, just the first level from the spec.""" + + template: str + variable_names: tuple[str, ...] + + @classmethod + def parse(cls, template: str) -> Self: + # TODO Parse the URI template and extract variable names. + raise NotImplementedError + + def match(self, uri: str) -> dict[str, str] | None: + # TODO Implement URI template matching and variable extraction. + raise NotImplementedError diff --git a/localpost/http/worker.py b/localpost/http/worker.py index 584dfb3..0d946af 100644 --- a/localpost/http/worker.py +++ b/localpost/http/worker.py @@ -1,27 +1,27 @@ from __future__ import annotations import logging -import signal import threading -from collections.abc import Awaitable, AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass -from typing import final +from collections.abc import AsyncIterator, Awaitable from wsgiref.types import WSGIApplication import anyio -from anyio import CancelScope, create_task_group, from_thread, to_thread +from anyio import create_task_group, from_thread, to_thread +from anyio.abc import TaskGroup -from localpost import threadtools +from localpost import hosting, threadtools from localpost.http.config import WorkerConfig -from localpost.http.server import Server, start_http_server +from localpost.http.server import start_http_server, RequestHandler from localpost.http.wsgi import wrap_wsgi -__all__ = ["Worker", "serve_http"] +__all__ = ["http_server"] -@asynccontextmanager -async def serve_http(app: WSGIApplication, config: WorkerConfig, /) -> AsyncIterator[Worker]: + + + +@hosting.service +async def http_server(app: WSGIApplication, config: WorkerConfig, /) -> AsyncIterator[None]: """Run multiple servers (workers).""" handler = wrap_wsgi(app) req_threads = anyio.CapacityLimiter(config.max_requests) @@ -49,38 +49,5 @@ def handle_clients_thread() -> Awaitable[None]: async with create_task_group() as tg: with start_http_server(config.server) as server: tg.start_soon(handle_clients_thread) - yield Worker(server, config, tg.cancel_scope) - - -@final -@dataclass(frozen=True, slots=True) -class Worker: - server: Server - config: WorkerConfig - _stop_scope: CancelScope - - def close(self) -> None: - """Graceful shutdown (stop handling new connections, wait for in-flight requests).""" - self.server.close() - - -def _sample_usage(): - logging.basicConfig(level=logging.DEBUG) - - def simple_app(_, start_response): - start_response("200 OK", [("Content-Type", "text/plain")]) - return [f"Hello from worker thread {threading.get_ident()}!\n".encode()] - - async def _run(): - async with serve_http(simple_app, WorkerConfig()) as w: - with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: - async for _ in signals: - w.close() - break - - # noinspection PyTypeChecker - anyio.run(_run) - + yield -if __name__ == "__main__": - _sample_usage() diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py deleted file mode 100644 index 4883012..0000000 --- a/localpost/http/wsgi.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -import logging -import sys -from collections.abc import Callable, Iterable -from contextlib import closing, AbstractContextManager, suppress -from io import BufferedReader, RawIOBase, IOBase -from typing import Any -from wsgiref.types import WSGIApplication - -import h11 -from flask import stream_with_context - -from localpost.http.server import ClientConn, RequestHandler - - -def wrap_wsgi(app: WSGIApplication) -> RequestHandler: - """Wrap a WSGI application as a RequestHandler.""" - - def handler( - client: ClientConn, request: h11.Request, body: IOBase - ) -> tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]]: - environ = _build_environ(client, request, body) - response: h11.Response | None = None - - def wsgi_start_response( - status: str, - headers: list[tuple[str, str]], - exc_info: Any = None, - ) -> Callable[[bytes], None]: - nonlocal response - if exc_info: - try: - raise exc_info[1].with_traceback(exc_info[2]) - finally: - exc_info = None # Avoid circular reference - status_code = int(status.split(' ', 1)[0]) # Parse from "200 OK" format - response = h11.Response(status_code=status_code, headers=[ - (name.encode('ISO-8859-1'), value.encode('ISO-8859-1')) - for name, value in headers - ]) - return _wsgi_response_write - - def wsgi_run(): - chunks = app(environ, wsgi_start_response) - yield # Skip the first iteration, to call the app and set the response object - with closing(chunks) if hasattr(chunks, 'close') else suppress(): # type: ignore - for chunk in chunks: - yield h11.Data(chunk) - - response_body = wsgi_run() - next(response_body) - assert response, "WSGI app did not call start_response" - return response, closing(response_body) - - return handler - - -def _wsgi_response_write(_: bytes) -> None: - raise NotImplementedError("write() is deprecated and not supported") - - -def _build_environ(client: ClientConn, request: h11.Request, body: IOBase) -> dict[str, Any]: - # Decode path and parse query string - path = request.target.decode('ISO-8859-1') - if '?' in path: - path, query_string = path.split('?', 1) - else: - query_string = '' - - headers_dict = {} - for name, value in request.headers: - headers_dict[name.decode('ISO-8859-1').lower()] = value.decode('ISO-8859-1') - - # See https://wsgi.readthedocs.io/en/latest/definitions.html - environ = { - 'REQUEST_METHOD': request.method.decode('ascii'), - 'PATH_INFO': path, - 'QUERY_STRING': query_string, - 'CONTENT_TYPE': headers_dict.get('content-type', ''), - 'CONTENT_LENGTH': headers_dict.get('content-length', ''), - 'SERVER_NAME': client.config.host, - 'SERVER_PORT': str(client.server.port), - 'SERVER_PROTOCOL': f'HTTP/{request.http_version.decode("ascii")}', - 'wsgi.version': (1, 0), - 'wsgi.url_scheme': 'http', - 'wsgi.input': BufferedReader(body), - 'wsgi.errors': sys.stderr, # Handle it later with the logger - 'wsgi.multithread': True, - 'wsgi.multiprocess': False, - 'wsgi.run_once': False, - } - - # Add HTTP headers - for name, value in request.headers: - name_str = name.decode('ISO-8859-1').upper().replace('-', '_') - if name_str not in ('CONTENT_TYPE', 'CONTENT_LENGTH'): - name_str = f'HTTP_{name_str}' - environ[name_str] = value.decode('ISO-8859-1') - - return environ - - -def _main_flask(): - logging.basicConfig(level=logging.DEBUG) - - from flask import Flask, request as flask_request - - from localpost.http.config import ServerConfig - from localpost.http.server import start_http_server - - app = Flask(__name__) - - @app.route('/hello/') - def hello(name): - user_agent = flask_request.headers.get('User-Agent', 'Unknown') - return f'Hello, {name}! Your User-Agent is: {user_agent}\n' - - - @app.route('/hello-stream/') - @stream_with_context - def hello_stream(name): - user_agent = flask_request.headers.get('User-Agent', 'Unknown') - yield f'Hello, {name}! ' - yield f'Your User-Agent is: {user_agent}\n' - - handler = wrap_wsgi(app) - with start_http_server(ServerConfig()) as server: - for client_conn in server.accept(): - client_conn(handler) - - -if __name__ == '__main__': - _main_flask() diff --git a/localpost/http/wsgi/__init__.py b/localpost/http/wsgi/__init__.py new file mode 100644 index 0000000..9594088 --- /dev/null +++ b/localpost/http/wsgi/__init__.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import logging +import sys +from collections.abc import Callable, Iterable +from contextlib import AbstractContextManager, closing, suppress +from dataclasses import dataclass +from io import BufferedReader, IOBase, RawIOBase +from typing import Any +from wsgiref.types import WSGIApplication + +import h11 +from flask import stream_with_context + +from localpost.http.server import HTTPConn, RequestHandler, HTTPReq + + +class RequestBodyStream(RawIOBase): + def __init__(self, ctx: HTTPReq) -> None: + self._ctx = ctx + self.completed = False + + def writable(self): + return False + + def seekable(self): + return False + + def readable(self): + return True + + def readall(self): + chunks = bytearray() + while not self.completed: + chunks.extend(self._ctx.receive()) + return chunks + + def readinto(self, b: bytearray, /) -> int: + try: + data = self._ctx.receive(len(b)) + size = len(data) + b[:size] = data + return size + except EOFError: + return 0 + + +def wrap_wsgi(app: WSGIApplication) -> RequestHandler: + """Wrap a WSGI application as a RequestHandler.""" + + def handler( + client: HTTPConn, request: h11.Request, body: IOBase + ) -> tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]]: + environ = _build_environ(client, request, body) + response: h11.Response | None = None + + def wsgi_start_response( + status: str, + headers: list[tuple[str, str]], + exc_info: Any = None, + ) -> Callable[[bytes], None]: + nonlocal response + if exc_info: + try: + raise exc_info[1].with_traceback(exc_info[2]) + finally: + exc_info = None # Avoid circular reference + status_code = int(status.split(" ", 1)[0]) # Parse from "200 OK" format + response = h11.Response( + status_code=status_code, + headers=[(name.encode("ISO-8859-1"), value.encode("ISO-8859-1")) for name, value in headers], + ) + return _wsgi_response_write + + def wsgi_run(): + chunks = app(environ, wsgi_start_response) + yield # Skip the first iteration, to call the app and set the response object + with closing(chunks) if hasattr(chunks, "close") else suppress(): # type: ignore + for chunk in chunks: + yield h11.Data(chunk) + + response_body = wsgi_run() + next(response_body) + assert response, "WSGI app did not call start_response" + return response, closing(response_body) + + return handler + + +def _wsgi_response_write(_: bytes) -> None: + raise NotImplementedError("write() is deprecated and not supported") + + +def _build_environ(client: HTTPConn, request: h11.Request, body: IOBase) -> dict[str, Any]: + # Decode path and parse query string + path = request.target.decode("ISO-8859-1") + if "?" in path: + path, query_string = path.split("?", 1) + else: + query_string = "" + + headers_dict = {} + for name, value in request.headers: + headers_dict[name.decode("ISO-8859-1").lower()] = value.decode("ISO-8859-1") + + # See https://wsgi.readthedocs.io/en/latest/definitions.html + environ = { + "REQUEST_METHOD": request.method.decode("ascii"), + "PATH_INFO": path, + "QUERY_STRING": query_string, + "CONTENT_TYPE": headers_dict.get("content-type", ""), + "CONTENT_LENGTH": headers_dict.get("content-length", ""), + "SERVER_NAME": client.config.host, + "SERVER_PORT": str(client.server.port), + "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", + "wsgi.version": (1, 0), + "wsgi.url_scheme": "http", + "wsgi.input": BufferedReader(body), + "wsgi.errors": sys.stderr, # Handle it later with the logger + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + } + + # Add HTTP headers + for name, value in request.headers: + name_str = name.decode("ISO-8859-1").upper().replace("-", "_") + if name_str not in ("CONTENT_TYPE", "CONTENT_LENGTH"): + name_str = f"HTTP_{name_str}" + environ[name_str] = value.decode("ISO-8859-1") + + return environ + + +def _main_flask(): + logging.basicConfig(level=logging.DEBUG) + + from flask import Flask + from flask import request as flask_request + + from localpost.http.config import ServerConfig + from localpost.http.server import start_http_server + + app = Flask(__name__) + + @app.route("/hello/") + def hello(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + return f"Hello, {name}! Your User-Agent is: {user_agent}\n" + + @app.route("/hello-stream/") + @stream_with_context + def hello_stream(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + yield f"Hello, {name}! " + yield f"Your User-Agent is: {user_agent}\n" + + handler = wrap_wsgi(app) + with start_http_server(ServerConfig()) as server: + for client_conn in server.accept(): + client_conn(handler) + + +if __name__ == "__main__": + _main_flask() diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 72fc501..9b58e17 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -17,7 +17,8 @@ def_full_name, is_async_callable, ) -#from localpost.flow import AsyncHandlerManager, FlowHandlerManager, HandlerDecorator, ensure_async_handler_manager + +# from localpost.flow import AsyncHandlerManager, FlowHandlerManager, HandlerDecorator, ensure_async_handler_manager from localpost.hosting import ( AbstractHost, ExposedService, diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 19d2f88..a706156 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -1,15 +1,18 @@ from __future__ import annotations import dataclasses as dc +import os import threading +import time from collections import deque -from collections.abc import Iterator, Callable -from contextlib import contextmanager +from collections.abc import Callable, Iterator, AsyncIterable, Awaitable +from contextlib import contextmanager, asynccontextmanager, suppress from functools import partial -from typing import Any, Self, Protocol, final +from typing import Any, Protocol, Self, final, Final, assert_never import anyio -from anyio import EndOfStream, ClosedResourceError, from_thread, CapacityLimiter, to_thread, WouldBlock +from anyio import CapacityLimiter, ClosedResourceError, EndOfStream, WouldBlock, from_thread, to_thread, \ + create_task_group from anyio.abc import TaskGroup as AnyioTaskGroup __all__ = [ @@ -18,7 +21,7 @@ "ReceiveChannel", ] -from localpost._utils import is_async_callable +from localpost._utils import is_async_callable, Switch, RandomDelay CHECK_TIMEOUT: float = 1.0 """Timeout (seconds) for cancellation checks (e.g. in the server loop).""" @@ -26,6 +29,8 @@ # Alias, so it's possible to override if needed check_cancelled = from_thread.check_cancelled +current_time = time.monotonic + ### # Synchronization primitives @@ -36,7 +41,7 @@ class CancellableLock: """Same interface as threading.Lock, but acquire() is cancellation aware.""" - __slots__ = ("source", "release", "__exit__", "locked", "_release_save", "_acquire_restore", "_is_owned") + __slots__ = ("__exit__", "_acquire_restore", "_is_owned", "_release_save", "locked", "release", "source") # noinspection PyProtectedMember def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None: @@ -46,11 +51,11 @@ def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None self.__exit__ = lock.__exit__ if hasattr(self.source, "locked"): self.locked = lock.locked - if hasattr(lock, '_release_save'): + if hasattr(lock, "_release_save"): self._release_save = lock._release_save - if hasattr(lock, '_acquire_restore'): + if hasattr(lock, "_acquire_restore"): self._acquire_restore = lock._acquire_restore - if hasattr(lock, '_is_owned'): + if hasattr(lock, "_is_owned"): self._is_owned = lock._is_owned def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: @@ -62,8 +67,8 @@ def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: check_cancelled() return True # Finite timeout — respect the deadline - deadline = anyio.current_time() + timeout - while (remaining := deadline - anyio.current_time()) > 0: + deadline = current_time() + timeout + while (remaining := deadline - current_time()) > 0: if self.source.acquire(timeout=min(CHECK_TIMEOUT, remaining)): return True check_cancelled() @@ -75,18 +80,23 @@ def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: def cancellable_condition(lock: CancellableLock | None = None) -> threading.Condition: cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore orig_wait = cond.wait + def cancellable_wait(timeout: float | None = None) -> bool: - end_time = None if timeout is None else anyio.current_time() + timeout + if timeout is None or timeout < 0: + while True: + check_cancelled() + if orig_wait(CHECK_TIMEOUT): + return True + + end_time = current_time() + timeout while True: + now = current_time() + if now >= end_time: + return False check_cancelled() - if timeout is None: - remaining = CHECK_TIMEOUT - else: - remaining = min(CHECK_TIMEOUT, max(0.0, end_time - anyio.current_time())) + remaining = min(CHECK_TIMEOUT, max(0.0, end_time - now)) if orig_wait(remaining): return True - if timeout is not None and anyio.current_time() >= end_time: - return False cond.wait = cancellable_wait # type: ignore return cond @@ -99,31 +109,74 @@ def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: ### -# Task groups +# Thread Pool ### -class TaskGroup: - def __init__(self, tg: AnyioTaskGroup) -> None: - self._tg = tg +@dc.dataclass(frozen=True, eq=False, slots=True) +class ExecutorThread[T]: + func: Callable[[T], None] + inbox: ReceiveChannel[T] + stop_at: float | None - def start_soon(self, func: Callable[..., object], *args: Any, limiter: CapacityLimiter | None = None) -> None: - target = func - if is_async_callable(func): - target = partial(to_thread.run_sync, target, limiter=limiter) - from_thread.run_sync(self._tg.start_soon, target, *args) + def arun(self, wl: threading.Semaphore, tl: CapacityLimiter) -> Awaitable[None]: + return to_thread.run_sync(self.run, wl, limiter=tl) + def run(self, limiter: threading.Semaphore) -> None: + with self.inbox as work_items: + for item in work_items: + try: + self.func(item) # It's AnyIO-managed thread in the end, so exceptions will be propagated + if (stop_at := self.stop_at) and (current_time() >= stop_at): + return + finally: + limiter.release() -@contextmanager -def create_task_group() -> Iterator[TaskGroup]: - atg = anyio.create_task_group() - try: - yield TaskGroup(from_thread.run(atg.__aenter__)) - except Exception as e: - if not from_thread.run(atg.__aexit__, type(e), e, e.__traceback__): - raise e - else: - from_thread.run(atg.__aexit__, None, None, None) + +@final +@dc.dataclass(frozen=True, eq=False, slots=True) +class ThreadPoolExecutor[T]: + _sender: SendChannel[T] + _limiter: threading.Semaphore + _start_worker: Callable[[], None] + + def acquire(self) -> Callable[[T], None]: + """ + Acquire a slot in the executor, get a submit callable for the payload. + + The slot is released when the payload is processed. + """ + self._limiter.acquire() + return self._handle + + def _handle(self, payload: T) -> None: + try: + self._sender.put_nowait(payload) + except WouldBlock: # No idle workers, start a new one + self._start_worker() + self._sender.put(payload) + + +@asynccontextmanager +async def create_executor[T]( + func: Callable[[T], None], + max_workers: int = min(32, (os.process_cpu_count() or 1) + 4), + worker_max_age: int = 60 * 5, # Seconds after which a thread is stopped +) -> AsyncIterable[ThreadPoolExecutor[T]]: + max_age_jitter = RandomDelay((0.0, worker_max_age * 0.2)) + threads_limiter = CapacityLimiter(max_workers) + work_limiter = cancellable_semaphore(max_workers) + sender, receiver = Channel.create() + + def start_thread() -> None: + stop_at = current_time() + worker_max_age + max_age_jitter().total_seconds() + thread = ExecutorThread(func, receiver.clone(), stop_at) + from_thread.run_sync(exec_tg.start_soon, thread.arun, work_limiter, threads_limiter) + + async with create_task_group() as exec_tg, sender: + permanent_thread = ExecutorThread(func, receiver, None) + exec_tg.start_soon(permanent_thread.arun, work_limiter, threads_limiter) + yield ThreadPoolExecutor(sender, work_limiter, start_thread) ### @@ -206,6 +259,7 @@ def put(self, item: T, /) -> None: ... def close(self) -> None: ... +@final @dc.dataclass(slots=True) class ChannelState[T]: buffer: deque[T] @@ -222,7 +276,8 @@ def __init__(self, capacity: int | None = None): """ None: unbounded 0: rendezvous (at most 1 item in-flight, put() blocks until a receiver consumes the item) - Positive int: bounded (put blocks until len(buffer) < capacity)""" + Positive int: bounded (put blocks until len(buffer) < capacity) + """ self.open_send_channels = 0 self.open_receive_channels = 0 self._lock = CancellableLock(threading.RLock()) @@ -231,7 +286,7 @@ def __init__(self, capacity: int | None = None): @property def can_put(self) -> bool: - if self.open_receive_channels == 0: + if self.open_receive_channels == 0: # TODO Make this state permanent raise ClosedResourceError("no more receivers") return self.capacity is None or len(self.buffer) < max(self.capacity, 1) @@ -246,6 +301,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self._lock.release() +# TODO dataclass + repr class SendChannel[T](BaseSendChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state @@ -299,6 +355,7 @@ def close(self) -> None: state.not_full.notify() # Wake up threads waiting in put() immediately +# TODO dataclass + repr class ReceiveChannel[T](BaseReceiveChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state diff --git a/pyproject.toml b/pyproject.toml index ab674c5..37e4961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ classifiers = [ "Intended Audience :: System Administrators", ] dependencies = [ - "typing_extensions ~=4.10", +# "typing_extensions ~=4.10", "anyio ~=4.12", ] @@ -55,6 +55,13 @@ scheduler = [ # Better name?.. "humanize >=3.0,<5.0", "pytimeparse2 ~=1.6", ] +http-rest = [ + "h11 ~=0.16", +# "uritemplate ~=4.2", +# "werkzeug ~=3.1", +# "multipart", +# "python-multipart", +] sqs = [ "botocore ~=1.38", # Sync SQS client (default) # "boto3 ~=1.38", # In case boto3 is available, the default session will be used @@ -87,11 +94,9 @@ dev = [ "structlog ~=25.0", "trio ~=0.32", ] -dev-hosting-http = [ +dev-hosting-services = [ "uvicorn ~=0.30", "hypercorn ~=0.17", -] -dev-hosting-grpc = [ "grpcio ~=1.68", ] dev-consumers = [ @@ -103,7 +108,11 @@ dev-consumers = [ "grpcio-tools ~=1.68", ] dev-http = [ + "punq ~=0.7", "flask ~=3.1", +# "defspec ~=0.5", # Replaced by our own implementation + "msgspec ~=0.20", + "pydantic ~=2.11", "a2wsgi", ] dev-sentry = [ diff --git a/uv.lock b/uv.lock index 81e6880..924fe66 100644 --- a/uv.lock +++ b/uv.lock @@ -67,14 +67,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.7" +version = "1.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" }, ] [[package]] @@ -92,20 +92,20 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.0" +version = "1.38.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, ] [[package]] name = "azure-identity" -version = "1.25.1" +version = "1.25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, @@ -114,9 +114,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, ] [[package]] @@ -159,30 +159,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.44" +version = "1.42.59" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/88/de5c2a0ce069973345f9fac81200de5b58f503e231dbd566357a5b8c9109/boto3-1.42.44.tar.gz", hash = "sha256:d5601ea520d30674c1d15791a1f98b5c055e973c775e1d9952ccc09ee5913c4e", size = 112865, upload-time = "2026-02-06T20:28:05.647Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/4e/499cb52aaee9468c346bcc1158965e24e72b4e2a20052725b680e0ac949b/boto3-1.42.59.tar.gz", hash = "sha256:6c4a14a4eb37b58a9048901bdeefbe1c529638b73e8f55413319a25f010ca211", size = 112725, upload-time = "2026-02-27T20:25:33.228Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/fb/0341da1482f7fa256d257cfba89383f6692570b741598d4e26d879b26c57/boto3-1.42.44-py3-none-any.whl", hash = "sha256:32e995b0d56e19422cff22f586f698e8924c792eb00943de9c517ff4607e4e18", size = 140604, upload-time = "2026-02-06T20:28:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/17/c0/22d868b9408dc5a33935a72896ec8d638b2766c459668d1b37c3e5ac2066/boto3-1.42.59-py3-none-any.whl", hash = "sha256:7a66e3e8e2087ea4403e135e9de592e6d63fc9a91080d8dac415bb74df873a72", size = 140557, upload-time = "2026-02-27T20:25:31.774Z" }, ] [[package]] name = "botocore" -version = "1.42.44" +version = "1.42.59" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/ff/54cef2c5ff4e1c77fabc0ed68781e48eb36f33433f82bba3605e9c0e45ce/botocore-1.42.44.tar.gz", hash = "sha256:47ba27360f2afd2c2721545d8909217f7be05fdee16dd8fc0b09589535a0701c", size = 14936071, upload-time = "2026-02-06T20:27:53.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/ae/50fb33bdf1911c216d50f98d989dd032a506f054cf829ebd737c6fa7e3e6/botocore-1.42.59.tar.gz", hash = "sha256:5314f19e1da8fc0ebc41bdb8bbe17c9a7397d87f4d887076ac8bdef972a34138", size = 14950271, upload-time = "2026-02-27T20:25:20.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9e/b45c54abfbb902ff174444a48558f97f9917143bc2e996729220f2631db1/botocore-1.42.44-py3-none-any.whl", hash = "sha256:ba406b9243a20591ee87d53abdb883d46416705cebccb639a7f1c923f9dd82df", size = 14611152, upload-time = "2026-02-06T20:27:49.565Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/9d52819e0d804ead073d53ab1823bc0f0cb172a250fba31107b0b43fbb04/botocore-1.42.59-py3-none-any.whl", hash = "sha256:d2f2ff7ecc31e86ef46b5daee112cfbca052c13801285fb23af909f7bff5b657", size = 14619293, upload-time = "2026-02-27T20:25:17.455Z" }, ] [[package]] @@ -199,20 +199,20 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.0" +version = "7.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796, upload-time = "2026-02-01T18:59:47.411Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487, upload-time = "2026-02-01T18:59:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -397,76 +397,86 @@ schemaregistry = [ [[package]] name = "coverage" -version = "7.13.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, - { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, - { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, - { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, - { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, - { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, - { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, - { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, - { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, - { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, - { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, - { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, - { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, - { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, - { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, - { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, - { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, - { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, - { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, - { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, - { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, - { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, - { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, - { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, - { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, - { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, - { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, - { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, - { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, - { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [[package]] @@ -484,55 +494,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.4" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, ] [[package]] @@ -569,20 +579,20 @@ wheels = [ [[package]] name = "fast-depends" -version = "3.0.5" +version = "3.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/f3/41e955f5f0811de6ef9f00f8462f2ade7bc4a99b93714c9b134646baa831/fast_depends-3.0.5.tar.gz", hash = "sha256:c915a54d6e0d0f0393686d37c14d54d9ec7c43d7b9def3f3fc4f7b4d52f67f2a", size = 18235, upload-time = "2025-11-30T20:26:12.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/63/3524df7f936537481fafbe9c5428cc26cc06a30cce2810ce6994331a9a19/fast_depends-3.0.6.tar.gz", hash = "sha256:6a2a11645db40d4850f1300f98ffad93dc09fc95750009359930e8ff03299cc5", size = 18283, upload-time = "2026-02-26T16:03:50.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/dd/76697228ae63dcbaf0a0a1b20fc996433a33f184ac4f578382b681dcf5ea/fast_depends-3.0.5-py3-none-any.whl", hash = "sha256:38a3d7044d3d6d0b1bed703691275c870316426e8a9bfa6b1c89e979b15659e2", size = 25362, upload-time = "2025-11-30T20:26:10.96Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/26ff5acc1804ecefd5ec8a351c43f23ba1c6315e9d4dbedd86bd16579e77/fast_depends-3.0.6-py3-none-any.whl", hash = "sha256:b793eee88061cf830679cdb42b5c26b39b0c5fd687d6112a17c46a9efb128a86", size = 25419, upload-time = "2026-02-26T16:03:49.165Z" }, ] [[package]] -name = "fastapi-slim" -version = "0.128.5" +name = "fastapi" +version = "0.134.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -591,14 +601,26 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/da33b1981428d678d148dd8580b3111ad5b19496796e80a13e947fab8961/fastapi_slim-0.128.5.tar.gz", hash = "sha256:8ff38b5229f830cbf1cf8f91ed379bc4de23f4e9381da2d21aea58c70f79ca8a", size = 374868, upload-time = "2026-02-08T10:22:06.713Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/b5/282bfb38ccd4cb5530b5f7a79ab2f576ebbc72d9e69e7e87b00f55b3b1a5/fastapi_slim-0.128.5-py3-none-any.whl", hash = "sha256:107927348db4dfd383f3fac10e00204ad1a014f8d296ab534d7095903c621b1f", size = 103728, upload-time = "2026-02-08T10:22:05.668Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" }, +] + +[[package]] +name = "fastapi-slim" +version = "0.129.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/0c/2e0bcbca77701b740d188f265724c7cabe94553432b9cddba86e45582efa/fastapi_slim-0.129.1.tar.gz", hash = "sha256:c88ac964c7a804b5a739d809b8450a2eeb110ea5f59c8d02273452644fc7098d", size = 5957, upload-time = "2026-02-21T13:10:02.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/ae/026020a1c056978af8ea169edac7c96600c5515da06c8a9bc03fd91eb42d/fastapi_slim-0.129.1-py3-none-any.whl", hash = "sha256:8e6d734797dcfeec171714224e9cbbb1c4d34c861ed3fdd07800fe1cf8e8e862", size = 3231, upload-time = "2026-02-21T13:10:02.911Z" }, ] [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blinker" }, @@ -608,14 +630,14 @@ dependencies = [ { name = "markupsafe" }, { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]] name = "google-api-core" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -624,9 +646,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, ] [package.optional-dependencies] @@ -1010,7 +1032,6 @@ version = "0.6.0.dev0" source = { editable = "." } dependencies = [ { name = "anyio" }, - { name = "typing-extensions" }, ] [package.optional-dependencies] @@ -1025,6 +1046,9 @@ azure-servicebus = [ cron = [ { name = "croniter" }, ] +http-rest = [ + { name = "h11" }, +] kafka = [ { name = "confluent-kafka" }, ] @@ -1054,16 +1078,17 @@ dev-consumers = [ { name = "confluent-kafka", extra = ["protobuf", "schemaregistry"] }, { name = "grpcio-tools" }, ] -dev-hosting-grpc = [ +dev-hosting-services = [ { name = "grpcio" }, -] -dev-hosting-http = [ { name = "hypercorn" }, { name = "uvicorn" }, ] dev-http = [ { name = "a2wsgi" }, { name = "flask" }, + { name = "msgspec" }, + { name = "punq" }, + { name = "pydantic" }, ] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, @@ -1111,12 +1136,12 @@ requires-dist = [ { name = "confluent-kafka", marker = "extra == 'kafka'", specifier = "~=2.4" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "google-cloud-pubsub", marker = "extra == 'pubsub'", specifier = "~=2.28" }, + { name = "h11", marker = "extra == 'http-rest'", specifier = "~=0.16" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, - { name = "typing-extensions", specifier = "~=4.10" }, ] -provides-extras = ["cron", "scheduler", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] +provides-extras = ["cron", "scheduler", "http-rest", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] [package.metadata.requires-dev] dev = [ @@ -1130,14 +1155,17 @@ dev-consumers = [ { name = "confluent-kafka", extras = ["schemaregistry", "protobuf"] }, { name = "grpcio-tools", specifier = "~=1.68" }, ] -dev-hosting-grpc = [{ name = "grpcio", specifier = "~=1.68" }] -dev-hosting-http = [ +dev-hosting-services = [ + { name = "grpcio", specifier = "~=1.68" }, { name = "hypercorn", specifier = "~=0.17" }, { name = "uvicorn", specifier = "~=0.30" }, ] dev-http = [ { name = "a2wsgi" }, { name = "flask", specifier = "~=3.1" }, + { name = "msgspec", specifier = "~=0.20" }, + { name = "punq", specifier = "~=0.7" }, + { name = "pydantic", specifier = "~=2.11" }, ] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, @@ -1237,16 +1265,16 @@ wheels = [ [[package]] name = "msal" -version = "1.34.0" +version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057, upload-time = "2026-02-24T10:58:28.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082, upload-time = "2026-02-24T10:58:27.219Z" }, ] [[package]] @@ -1261,13 +1289,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msgspec" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d1/b902d38b6e5ba3bdddbec469bba388d647f960aeed7b5b3623a8debe8a76/msgspec-0.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c1ff8db03be7598b50dd4b4a478d6fe93faae3bd54f4f17aa004d0e46c14c46", size = 196463, upload-time = "2025-11-24T03:55:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/eff0305961a1d9447ec2b02f8c73c8946f22564d302a504185b730c9a761/msgspec-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f6532369ece217fd37c5ebcfd7e981f2615628c21121b7b2df9d3adcf2fd69b8", size = 188650, upload-time = "2025-11-24T03:55:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/99/93/f2ec1ae1de51d3fdee998a1ede6b2c089453a2ee82b5c1b361ed9095064a/msgspec-0.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a1697da2f85a751ac3cc6a97fceb8e937fc670947183fb2268edaf4016d1ee", size = 218834, upload-time = "2025-11-24T03:55:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" }, + { url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" }, + { url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" }, + { url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" }, + { url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" }, + { url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" }, + { url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" }, + { url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, +] + [[package]] name = "nats-py" -version = "2.13.1" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/70/e08fa8c2c15c9cc458bdf121019df1b9101e32602a408d94953fe1246300/nats_py-2.13.1.tar.gz", hash = "sha256:68a0afe018bdaa12740e0ae8c5a94e1937126d4901ae656afc382e2b3ab40005", size = 116881, upload-time = "2026-02-05T17:26:09.595Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/f8/b956c4621ba88748ed707c52e69f95b7a50c8914e750edca59a5bef84a76/nats_py-2.14.0.tar.gz", hash = "sha256:4ed02cb8e3b55c68074a063aa2687087115d805d1513297da90cb2068fb07bed", size = 120751, upload-time = "2026-02-23T22:44:58.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/0d/38d36347de5088f6cc8c028895043ee264933d00ff729c0f208643fa2ad9/nats_py-2.13.1-py3-none-any.whl", hash = "sha256:838590cd4d3fae141fb3332c16c9861e0831b053a29cd844d51d1104dfc5b4d9", size = 80947, upload-time = "2026-02-05T17:26:08.493Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/0e87753df1072254bac190b33ed34b264f28f6aa9bea0f01b7e818071756/nats_py-2.14.0-py3-none-any.whl", hash = "sha256:4116f5d2233ce16e63c3d5538fa40a5e207f75fcf42a741773929ddf1e29d19d", size = 82259, upload-time = "2026-02-23T22:45:00.152Z" }, ] [[package]] @@ -1507,15 +1575,15 @@ wheels = [ [[package]] name = "psycopg" -version = "3.3.2" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] [package.optional-dependencies] @@ -1528,42 +1596,42 @@ pool = [ [[package]] name = "psycopg-binary" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" }, - { url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" }, - { url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" }, - { url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" }, - { url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" }, - { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" }, - { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" }, - { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" }, - { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" }, - { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" }, - { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" }, - { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" }, - { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" }, - { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" }, - { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" }, - { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" }, - { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] [[package]] @@ -1578,6 +1646,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] +[[package]] +name = "punq" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/f1/c31e86ce1ab85837d8e6a6868163bae3afaef01c49d45f1bfa479c1a5b90/punq-0.7.0.tar.gz", hash = "sha256:bb7a6cc75a2e7d51b861b0e11f4830a12617b3ee33dbced9ce2be6a98ba39d63", size = 7254, upload-time = "2023-11-06T10:45:19.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/6a/c89602766d4416371eb801c3de89eda45fcea4435836ba7c01d1da88d47a/punq-0.7.0-py3-none-any.whl", hash = "sha256:7e0aca446c36a73674ec3fc078ec5e90e7a172ee349537d075ff20d73c3b1810", size = 7719, upload-time = "2023-11-06T10:45:18.118Z" }, +] + [[package]] name = "pyasn1" version = "0.6.2" @@ -1868,15 +1945,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.52.0" +version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, ] [[package]] @@ -1939,11 +2016,11 @@ wheels = [ [[package]] name = "setuptools" -version = "81.0.0" +version = "82.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] @@ -2025,7 +2102,7 @@ nats = [ [[package]] name = "trio" -version = "0.32.0" +version = "0.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -2035,31 +2112,31 @@ dependencies = [ { name = "sniffio" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, ] [[package]] name = "types-awscrt" -version = "0.31.1" +version = "0.31.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/97/be/589b7bba42b5681a72bac4d714287afef4e1bb84d07c859610ff631d449e/types_awscrt-0.31.1.tar.gz", hash = "sha256:08b13494f93f45c1a92eb264755fce50ed0d1dc75059abb5e31670feb9a09724", size = 17839, upload-time = "2026-01-16T02:01:23.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/24/5497a611f32cbaf4b9e1af35f56463e8f02e198ec513b68cb59a63f5a446/types_awscrt-0.31.2.tar.gz", hash = "sha256:dc79705acd24094656b8105b8d799d7e273c8eac37c69137df580cd84beb54f6", size = 18190, upload-time = "2026-02-16T02:33:53.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/fd/ddca80617f230bd833f99b4fb959abebffd8651f520493cae2e96276b1bd/types_awscrt-0.31.1-py3-none-any.whl", hash = "sha256:7e4364ac635f72bd57f52b093883640b1448a6eded0ecbac6e900bf4b1e4777b", size = 42516, upload-time = "2026-01-16T02:01:21.637Z" }, + { url = "https://files.pythonhosted.org/packages/ab/3d/21a2212b5fcef9e8e9f368403885dc567b7d31e50b2ce393efad3cd83572/types_awscrt-0.31.2-py3-none-any.whl", hash = "sha256:3d6a29c1cca894b191be408f4d985a8e3a14d919785652dd3fa4ee558143e4bf", size = 43340, upload-time = "2026-02-16T02:33:52.109Z" }, ] [[package]] name = "types-boto3-lite" -version = "1.42.44" +version = "1.42.59" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/94/eaa5a7e48f14a7b86a8383d730f7b404128346bd1ed2480098d5b3d17aae/types_boto3_lite-1.42.44.tar.gz", hash = "sha256:a5046c57ac169193eedec49a26ca23d1a2aa2d730f5533638f36de0fb574c489", size = 73035, upload-time = "2026-02-06T20:40:14.134Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/18/5114672bd851492d342805deba9a794232ad8d9064508bd2e14fae37c8c7/types_boto3_lite-1.42.59.tar.gz", hash = "sha256:c27a43010d10fbe03a677cabc7426d2f116bff4cc7029f5b60d643fdd81b8a93", size = 73146, upload-time = "2026-02-27T20:47:45.688Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/0a/6f399a6214595438ed3c918adda94c085e79d8b5ac9ddcbb72fe297b10bb/types_boto3_lite-1.42.44-py3-none-any.whl", hash = "sha256:c7fa35b85bde6e5f5b2f4feb6004d06c75fd07bd03dc397338ab24cc3e4ffabf", size = 42694, upload-time = "2026-02-06T20:40:07.092Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e5/325adf7a61064315e9f7706e8afb118a2be157c94b6ced9ca05af526d62a/types_boto3_lite-1.42.59-py3-none-any.whl", hash = "sha256:4b18da86cbbbb440d9af1d3aac28650f66af0a068022f4be0357b71ed97c15ee", size = 42714, upload-time = "2026-02-27T20:47:39.184Z" }, ] [package.optional-dependencies] @@ -2096,11 +2173,11 @@ wheels = [ [[package]] name = "types-protobuf" -version = "6.32.1.20251210" +version = "6.32.1.20260221" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, ] [[package]] @@ -2153,27 +2230,27 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] [[package]] name = "werkzeug" -version = "3.1.5" +version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, ] [[package]] From 2dc5e8b312b18b8a5ad16732c9eae334d477da1c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 2 Mar 2026 22:04:23 +0000 Subject: [PATCH 026/286] WIP --- examples/http/__init__.py | 0 examples/http/openapi_app_server.py | 41 +++++ examples/http/simple_server.py | 23 +++ examples/http/threaded_server.py | 52 ++++++ examples/http/wsgi_app_server.py | 40 +++++ justfile | 2 +- localpost/_utils.py | 6 +- localpost/consumers/channel.py | 2 +- localpost/hosting/__init__.py | 2 + localpost/hosting/_host.py | 23 ++- localpost/hosting/services/_asgi.py | 11 +- localpost/hosting/services/grpc.py | 36 ++-- localpost/http/TODO.md | 1 + localpost/http/_benchmark.py | 1 + localpost/http/_benchmark_uvicorn.py | 1 + localpost/http/_service.py | 42 +++++ localpost/http/config.py | 22 +-- localpost/http/openapi/app.py | 87 +--------- localpost/http/router.py | 34 ++-- localpost/http/server.py | 246 ++++++++++++--------------- localpost/http/worker.py | 53 ------ localpost/http/wsgi/__init__.py | 48 +----- localpost/threadtools.py | 86 +++++++--- pyproject.toml | 3 + 24 files changed, 449 insertions(+), 413 deletions(-) create mode 100644 examples/http/__init__.py create mode 100644 examples/http/openapi_app_server.py create mode 100644 examples/http/simple_server.py create mode 100644 examples/http/threaded_server.py create mode 100644 examples/http/wsgi_app_server.py create mode 100644 localpost/http/_service.py delete mode 100644 localpost/http/worker.py diff --git a/examples/http/__init__.py b/examples/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/http/openapi_app_server.py b/examples/http/openapi_app_server.py new file mode 100644 index 0000000..c813546 --- /dev/null +++ b/examples/http/openapi_app_server.py @@ -0,0 +1,41 @@ +import logging +from typing import Annotated +from uuid import UUID + +from localpost import threadtools +from localpost.http.config import ServerConfig +from localpost.http.openapi.app import HttpApp, Example +from localpost.http.server import start_http_server + + +# noinspection DuplicatedCode +def main(): + logging.basicConfig(level=logging.DEBUG) + threadtools.check_cancelled = lambda: None + + app = HttpApp() + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + def hello( + book_id: UUID, page_number: int + ) -> Annotated[ + dict, + Example( + {"id": "123e4567-e89b-12d3-a456-426614174000", "title": "The Lord of the Rings", "author": "J.R.R. Tolkien"} + ), + ]: + # Dict (or any other type, actually) is serialized to JSON (or other format, according to the Accept header) + # page_number is just to demonstrate multiple parameters from different sources (path and query) + return {"id": str(id), "title": "The Lord of the Rings", "author": "J.R.R. Tolkien"} + + with start_http_server(ServerConfig()) as server: + while True: + server.run(app) + + +if __name__ == "__main__": + main() diff --git a/examples/http/simple_server.py b/examples/http/simple_server.py new file mode 100644 index 0000000..65365ea --- /dev/null +++ b/examples/http/simple_server.py @@ -0,0 +1,23 @@ +import logging + +import h11 + +from localpost import threadtools +from localpost.http.config import ServerConfig +from localpost.http.server import HTTPReqCtx, start_http_server + + +def _main(): + logging.basicConfig(level=logging.DEBUG) + threadtools.check_cancelled = lambda: None + + def simple_app(ctx: HTTPReqCtx): + ctx.complete(h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"Hello, World!\n") + + with start_http_server(ServerConfig()) as server: + while True: + server.run(simple_app) + + +if __name__ == "__main__": + _main() diff --git a/examples/http/threaded_server.py b/examples/http/threaded_server.py new file mode 100644 index 0000000..16d48ea --- /dev/null +++ b/examples/http/threaded_server.py @@ -0,0 +1,52 @@ +import logging +from contextlib import AbstractContextManager + +import anyio +import h11 +from anyio import from_thread, to_thread + +from localpost.http.config import ServerConfig +from localpost.http.server import HTTPReqCtx, start_http_server +from localpost.threadtools import create_executor + + +async def main(): + logging.basicConfig(level=logging.DEBUG) + + routes = { + (b"GET", b"/"), + (b"GET", b"/hello"), + } + + def handle_route(req_ctx: AbstractContextManager[HTTPReqCtx]): + with req_ctx as req: + req.start_response(h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")])) + req.send(b"Hello, World!\n") + req.finish_response() + + def complex_app(ctx: HTTPReqCtx): + key = (ctx.request.method, ctx.request.target) + if key in routes: + if handle_soon := executor.acquire_nowait(): + handle_soon(ctx.borrow()) + else: + ctx.complete( + h11.Response(status_code=503, headers=[(b"Content-Type", b"text/plain")]), b"Server is busy\n" + ) + else: + response = h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]) + body = b"Not found\n" + ctx.complete(response, body) + + def run_server(): + with start_http_server(ServerConfig()) as server: + while True: + from_thread.check_cancelled() + server.run(complex_app) + + async with create_executor(handle_route) as executor: + await to_thread.run_sync(run_server) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py new file mode 100644 index 0000000..3e89151 --- /dev/null +++ b/examples/http/wsgi_app_server.py @@ -0,0 +1,40 @@ +import logging + +from anyio import from_thread, to_thread +from flask import Flask, stream_with_context +from flask import request as flask_request + +from localpost.http.config import ServerConfig +from localpost.http.server import start_http_server +from localpost.http.wsgi import wrap_wsgi + + +async def main(): + logging.basicConfig(level=logging.DEBUG) + + app = Flask(__name__) + + @app.route("/hello/") + def hello(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + return f"Hello, {name}! Your User-Agent is: {user_agent}\n" + + @app.route("/hello-stream/") + @stream_with_context + def hello_stream(name): + user_agent = flask_request.headers.get("User-Agent", "Unknown") + yield f"Hello, {name}! " + yield f"Your User-Agent is: {user_agent}\n" + + def run_server(): + with start_http_server(ServerConfig()) as server: + while True: + from_thread.check_cancelled() + server.run(req_handler) + + async with wrap_wsgi(app) as req_handler: + await to_thread.run_sync(run_server) + + +if __name__ == "__main__": + main() diff --git a/justfile b/justfile index b756be1..418efe7 100755 --- a/justfile +++ b/justfile @@ -37,8 +37,8 @@ type-coverage: pyright --pythonpath $(which python) --verifytypes localpost localpost/* format: - ruff format localpost ruff check --fix localpost + ruff format localpost format-all: format ruff check --fix examples tests diff --git a/localpost/_utils.py b/localpost/_utils.py index b0e79c2..f076a01 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -100,7 +100,9 @@ class _SupportsAsyncClose(Protocol): async def aclose(self) -> object: ... -class ClosingContext(Generic[T], AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): +# TODO Remove +@final +class ClosingContext[T](AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): def __init__(self, enter_result: T): self.enter_result = enter_result @@ -131,7 +133,7 @@ def ensure_int_or_inf(value: int | float, *, min_value: int = 0, name: str = "Va @final -# Actually immutable, but frozen=True has noticeable performance impact +# Actually immutable, but frozen=True has a noticeable performance impact @dc.dataclass(slots=True, eq=True, unsafe_hash=True) class Result(Generic[T]): value: T # | NOT_SET diff --git a/localpost/consumers/channel.py b/localpost/consumers/channel.py index 5ff0f87..ccc30c3 100644 --- a/localpost/consumers/channel.py +++ b/localpost/consumers/channel.py @@ -3,7 +3,7 @@ from anyio import CapacityLimiter, ClosedResourceError, create_task_group, from_thread, to_thread -from localpost import threadtools, hosting +from localpost import hosting, threadtools from localpost._utils import is_async_callable from localpost.consumers._utils import AnyHandler from localpost.threadtools import Channel, ReceiveChannel diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index 1b1b06e..bf2ff8b 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -5,6 +5,7 @@ Running, ServiceF, ServiceState, + ServiceLifetime, ShuttingDown, Starting, Stopped, @@ -27,6 +28,7 @@ "Starting", "Running", "ShuttingDown", + "ServiceLifetime", "Stopped", ] diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 4a3eb88..4665429 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -5,16 +5,16 @@ import logging import threading from _contextvars import ContextVar -from collections.abc import AsyncIterator, Awaitable, Callable, AsyncGenerator -from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext, AsyncExitStack +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext from functools import cached_property, wraps from typing import Any, ClassVar, Literal, TypeVar, final, overload -from anyio import CancelScope, create_task_group, get_cancelled_exc_class, to_thread, CapacityLimiter +from anyio import CancelScope, CapacityLimiter, create_task_group, get_cancelled_exc_class, to_thread from anyio.abc import TaskGroup from anyio.from_thread import BlockingPortal -from localpost._utils import Event, EventView, cancellable_from, unwrap_exc, wait_all, is_async_callable +from localpost._utils import Event, EventView, cancellable_from, is_async_callable, unwrap_exc, wait_all F = TypeVar("F", bound=Callable[..., Any]) @@ -252,13 +252,11 @@ async def _serve_and_observe(svc_f: ServiceF, /): @overload def service[**P](target: Callable[P, Any]) -> Callable[P, _ResolvedService]: """Decorator to create a hosted service.""" - ... @overload def service[**P]() -> Callable[[Callable[P, Any]], Callable[P, _ResolvedService]]: """Decorator to create a hosted service.""" - ... def service(target: Callable[..., Any] | None = None): @@ -333,7 +331,7 @@ def serve( async def _serve_root(svc: ServiceF) -> AsyncIterator[ServiceLifetimeView]: async with BlockingPortal() as portal: child_lt = ServiceLifetime(portal) - tg = portal._task_group # noqa + tg = portal._task_group tg.start_soon(_run, svc, child_lt) await child_lt.started yield child_lt.view @@ -343,23 +341,24 @@ async def _serve_root(svc: ServiceF) -> AsyncIterator[ServiceLifetimeView]: @asynccontextmanager async def _serve_in(svc: ServiceF, parent: ServiceLifetime) -> AsyncIterator[ServiceLifetimeView]: - async def _bind_parent_to(shutdown_trigger: EventView): - await shutdown_trigger + # TODO Just throw an exception if the service stops with an error?.. Instead of binding the parent lifetime + async def bind_parent_to(csl: ServiceLifetimeView): + await csl.stopped parent.view.shutdown() child_lt = parent.start(svc) async with create_task_group() as observe_tg: # Bind the parent lifetime (if the child is stopped, shutdown the parent) - observe_tg.start_soon(_bind_parent_to, child_lt.stopped) + observe_tg.start_soon(bind_parent_to, child_lt) await child_lt.started yield child_lt - child_lt.shutdown() observe_tg.cancel_scope.cancel() + child_lt.shutdown() await child_lt.stopped async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: - async with nullcontext(parent.portal) if parent else BlockingPortal() as portal: + async with (nullcontext(parent.portal) if parent else BlockingPortal()) as portal: lt = ServiceLifetime(portal) await _run(svc_f, lt) return lt.exit_code diff --git a/localpost/hosting/services/_asgi.py b/localpost/hosting/services/_asgi.py index f7b2c5b..5cd4b54 100644 --- a/localpost/hosting/services/_asgi.py +++ b/localpost/hosting/services/_asgi.py @@ -10,13 +10,12 @@ def report_started(started: Event, asgi_app: Callable[..., Any]) -> Callable[... def asgi_app_wrapper( scope: dict[str, Any], receive: Callable[..., Awaitable[Any]], send: Callable[..., Awaitable[Any]] ) -> Awaitable[Any]: - if scope["type"] == "lifespan": - - def wrapped_send(message: dict[str, Any]) -> Awaitable[Any]: - if message["type"] == "lifespan.startup.complete": - started.set() - return send(message) + def wrapped_send(message: dict[str, Any]) -> Awaitable[Any]: + if message["type"] == "lifespan.startup.complete": + started.set() + return send(message) + if scope["type"] == "lifespan": return asgi_app(scope, receive, wrapped_send) return asgi_app(scope, receive, send) diff --git a/localpost/hosting/services/grpc.py b/localpost/hosting/services/grpc.py index 95ea78f..05541ed 100644 --- a/localpost/hosting/services/grpc.py +++ b/localpost/hosting/services/grpc.py @@ -1,30 +1,16 @@ -from typing import final - import grpc -from localpost._utils import wait_any - -from .._host import ServiceLifetimeManager - - -@final -class AsyncGrpcService: - def __init__(self, server: grpc.aio.Server): - self._server = server - self.name = "grpc" - self.grace_termination_period = 5 +from localpost.hosting._host import ServiceLifetime, service - @property - def server(self) -> grpc.aio.Server: - return self._server - async def __call__(self, service_lifetime: ServiceLifetimeManager): - async def handle_svc_shutdown(): - await service_lifetime.shutting_down - # During the grace period, the server won't accept new connections and allow existing RPCs to continue - # within the grace period. - await self._server.stop(self.grace_termination_period) +@service +def grpc_async_server(server: grpc.aio.Server, /, *, grace_termination_period: float | None = 5.0): + async def run(sl: ServiceLifetime): + await server.start() + sl.set_started() + await sl.shutting_down + # During the grace period, the server won't accept new connections and allow existing RPCs to continue + # within the grace period. + await server.stop(grace_termination_period) - await self._server.start() - service_lifetime.set_started() - await wait_any(handle_svc_shutdown, self._server.wait_for_termination) + return run diff --git a/localpost/http/TODO.md b/localpost/http/TODO.md index dacfa85..983ac38 100644 --- a/localpost/http/TODO.md +++ b/localpost/http/TODO.md @@ -2,6 +2,7 @@ - HTTP 1.1 only, no other versions for now (e.g. HTTP/2, HTTP/3) - so we expose h11 directly +- no WebSocket support for now diff --git a/localpost/http/_benchmark.py b/localpost/http/_benchmark.py index 5c82070..c4ab4db 100644 --- a/localpost/http/_benchmark.py +++ b/localpost/http/_benchmark.py @@ -7,6 +7,7 @@ from localpost.http.config import WorkerConfig from localpost.http.worker import http_server + from ._benchmark_app import app diff --git a/localpost/http/_benchmark_uvicorn.py b/localpost/http/_benchmark_uvicorn.py index 95c5651..7f78df0 100644 --- a/localpost/http/_benchmark_uvicorn.py +++ b/localpost/http/_benchmark_uvicorn.py @@ -4,6 +4,7 @@ import uvicorn from a2wsgi import WSGIMiddleware + from ._benchmark_app import app diff --git a/localpost/http/_service.py b/localpost/http/_service.py new file mode 100644 index 0000000..8a0ece9 --- /dev/null +++ b/localpost/http/_service.py @@ -0,0 +1,42 @@ +from collections.abc import Awaitable +from wsgiref.types import WSGIApplication + +from anyio import create_task_group, to_thread, from_thread, CapacityLimiter + +from localpost import hosting +from localpost.hosting import ServiceLifetime +from localpost.http.config import ServerConfig +from localpost.http.server import start_http_server, RequestHandler +from localpost.threadtools import create_executor + + +@hosting.service +def http_server(config: ServerConfig, handler: RequestHandler, /): + def run(sl: ServiceLifetime) -> Awaitable[None]: + def run_forever(): + with start_http_server(config) as server: + sl.set_started() + while not sl.shutting_down.is_set(): + from_thread.check_cancelled() + server.run(handler) + + return to_thread.run_sync(run_forever, limiter=CapacityLimiter(1)) + + return run + + +@hosting.service +def wsgi_server(config: ServerConfig, app: WSGIApplication, /): + async def run(sl: ServiceLifetime): + def run_forever(): + with start_http_server(config) as server: + sl.set_started() + while not sl.shutting_down.is_set(): + from_thread.check_cancelled() + server.run(handler) + + async with create_executor(handle_route) as executor: + # FIXME handler + await to_thread.run_sync(run_forever, limiter=CapacityLimiter(1)) + + diff --git a/localpost/http/config.py b/localpost/http/config.py index c3335fe..7794fc4 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -1,16 +1,14 @@ from __future__ import annotations -import io -from dataclasses import dataclass, field -from typing import final, Final +from dataclasses import dataclass +from typing import Final, final __all__ = [ "LOGGER_NAME", - "WorkerConfig", "ServerConfig", ] -DEFAULT_BUFFER_SIZE: Final = io.DEFAULT_BUFFER_SIZE +DEFAULT_BUFFER_SIZE: Final = 64 * 1024 # 64 KiB LOGGER_NAME: Final = "localpost.http" # TODO Access logger?.. @@ -23,19 +21,9 @@ class ServerConfig: port: int = 8000 backlog: int = 1024 """Maximum number of queued (in the kernel) connections.""" - # rw_timeout: float = 3.0 + # rw_timeout: float = threadtools.CHECK_TIMEOUT # """Timeout (seconds) for receive/send operations on a client connection.""" - keep_alive_timeout: float = 15.0 + keep_alive_timeout: float = 15.0 # TODO add it to the response """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB """Maximum request body size (bytes).""" - # TODO Support Keep-Alive response header (timeout, max requests) - - -# FIXME Remove, just move to ... -@final -@dataclass(frozen=True, slots=True) -class WorkerConfig: - server: ServerConfig = field(default_factory=ServerConfig) - max_requests: int = 5 - """Max parallel requests.""" diff --git a/localpost/http/openapi/app.py b/localpost/http/openapi/app.py index c6099eb..1aa208b 100644 --- a/localpost/http/openapi/app.py +++ b/localpost/http/openapi/app.py @@ -4,10 +4,10 @@ import json import logging import threading -from collections.abc import Callable, Mapping, Sequence, Collection +from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass from http import HTTPMethod -from typing import Protocol, Self, final, Annotated +from typing import Annotated, Protocol, Self, final import localpost.spec.openapi as openapi_spec from localpost.http.router import RequestHandler, Router @@ -62,6 +62,7 @@ def register(self, op: FluentPathOp) -> FluentPathOp: def get(self, path: str) -> FluentOpDecorator: """Decorator to register a GET operation on the given path.""" + def decorator(func) -> RequestHandler: return self.register(FluentPathOp(HTTPMethod.GET, path, func)) @@ -69,20 +70,13 @@ def decorator(func) -> RequestHandler: def post(self, path: str) -> FluentOpDecorator: """Decorator to register a POST operation on the given path.""" + def decorator(func) -> RequestHandler: return self.register(FluentPathOp(HTTPMethod.POST, path, func)) return decorator - - - - - - - - @final @dataclass(eq=False, frozen=True) class FluentPathOp: @@ -118,13 +112,6 @@ def __call__(self, request: RESTContext) -> None: self.response_resolver(request, return_value) - - - - - - - # class ResponseResolverFactory(Protocol): # def __call__(self, return_annotation: type, /) -> ResponseResolver: # ... @@ -156,20 +143,8 @@ def __call__(self, return_annotation: type, /) -> ResponseResolver: # pass - - - - - - - - - - - class ArgResolverFactory: - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: - ... + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: ... class ArgResolver(Protocol): @@ -289,60 +264,8 @@ def resolve(request: RESTContext) -> object: return resolve - - - - - - class Example: """Example value for a parameter or return type, to be included in the OpenAPI docs.""" def __init__(self, value: object): self.value = value - - - - - - - -# noinspection DuplicatedCode -def _sample_usage(): - from localpost.http.worker import http_server - from localpost.http.config import WorkerConfig - import anyio - import signal - from uuid import UUID - - logging.basicConfig(level=logging.DEBUG) - - app = HttpApp() - - @app.get("/hello/{name}") - def hello(name: str) -> str: - return f"Hello, {name}!" - - @app.get("/books/{book_id}") - def hello(book_id: UUID, page_number: int) -> Annotated[dict, - Example({ - "id": "123e4567-e89b-12d3-a456-426614174000", - "title": "The Lord of the Rings", - "author": "J.R.R. Tolkien" - })]: - # Dict (or any other type, actually) is serialized to JSON (or other format, according to the Accept header) - # page_number is just to demonstrate multiple parameters from different sources (path and query) - return {"id": str(id), "title": "The Lord of the Rings", "author": "J.R.R. Tolkien"} - - async def _run(): - async with http_server(simple_app, WorkerConfig()): - with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: - async for _ in signals: - break # Shutdown on SIGTERM or SIGINT - - # noinspection PyTypeChecker - anyio.run(_run) - - -if __name__ == "__main__": - _sample_usage() diff --git a/localpost/http/router.py b/localpost/http/router.py index eae8eba..4251d2c 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -1,21 +1,20 @@ from __future__ import annotations -import re -from collections.abc import Mapping, Buffer, Callable, Iterable -from contextlib import ExitStack, AbstractContextManager +from collections.abc import Buffer, Callable, Iterable, Mapping +from contextlib import AbstractContextManager, ExitStack from dataclasses import dataclass, field from http import HTTPMethod import h11 -from localpost.http.server import HTTPReq +from localpost.http.server import BorrowedHTTPReq, TrackedHTTPReq from localpost.http.uritemplate import URITemplate # See also: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/use-http-context#httprequest @dataclass(eq=False, slots=True) class RESTContext: - _context: HTTPReq + _http: BorrowedHTTPReq exit_stack: ExitStack request: h11.Request @@ -27,23 +26,23 @@ class RESTContext: result: OpResult = field(default_factory=lambda: Ok(None), init=False) - _body: bytearray | None = field(default=None, init=False) + _req_body: bytearray | None = field(default=None, init=False) - def receive_all(self, cache=False) -> Buffer: + def receive_all(self, cache: bool = False) -> Buffer: """Read the request body.""" - if self._body is not None: - return self._body + if self._req_body is not None: + return self._req_body body = bytearray() while True: - chunk = self._context.receive() + chunk = self._http.receive() if not chunk: break body.extend(chunk) if cache: - self._body = body + self._req_body = body return body - def get_header(self, name: str, /, default=None) -> str | None: + def get_header(self, name: str, /, default: str | None = None) -> str | None: raw_name = name.encode() for header_name, header_value in self.request.headers: if header_name == raw_name: @@ -54,15 +53,15 @@ def get_header(self, name: str, /, default=None) -> str | None: RequestHandler = Callable[[RESTContext], None] RequestHandlerMiddleware = Callable[[RequestHandler], RequestHandler] +# We cannot use h11 types here, to be compatible with WSGI too HTTPResponse = tuple[int, Iterable[tuple[str, str]], AbstractContextManager[Iterable[bytes]]] ResultConverter = Callable[[RESTContext], HTTPResponse] -def not_found(request: RESTContext) -> None: +def not_found(req_ctx: TrackedHTTPReq) -> None: """Default handler for unmatched routes.""" - request.start_response(h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")])) + req_ctx.complete(h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), b"Not Found") # TODO Problem details if the client supports JSON (Accept header contains "json") - request.send(b"Not Found") def method_not_allowed(request: RESTContext) -> None: @@ -72,18 +71,17 @@ def method_not_allowed(request: RESTContext) -> None: request.send(b"Method Not Allowed") +@dataclass(eq=False, frozen=True, slots=True) class Router: paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]] - def __call__(self, request: HTTPReq) -> None: + def __call__(self, req_ctx: TrackedHTTPReq) -> None: # FIXME Find a match, create RESTContext with extracted path_args, entered ExitStack, etc. pass def wsgi(self, environ, start_response): """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" # TODO Adapt from WSGI, create RESTContext, ... - pass - @dataclass(frozen=True, slots=True) diff --git a/localpost/http/server.py b/localpost/http/server.py index 6c21214..1b1eedf 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -16,16 +16,18 @@ from collections.abc import Callable, Iterator from contextlib import closing, contextmanager from dataclasses import dataclass, field -from typing import final, Literal +from typing import final import h11 from localpost import threadtools from localpost.http.config import DEFAULT_BUFFER_SIZE, LOGGER_NAME, ServerConfig -__all__ = ["Server", "HTTPReq", "RequestHandler", "start_http_server"] +__all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] +RW_TIMEOUT = threadtools.CHECK_TIMEOUT + @contextmanager def start_http_server(config: ServerConfig) -> Iterator[Server]: @@ -43,29 +45,15 @@ def start_http_server(config: ServerConfig) -> Iterator[Server]: # server_sock.close() # Safe to call it from another thread, will cause accept() to raise OSError with closing(server_sock), closing(selector): server = Server(config, logger, server_sock, selector) - logger.info(f"Serving on {config.host}:{server.port}") + # logger.info(f"Serving on {config.host}:{server.port}") + logger.info("Serving on %s:%d", config.host, server.port) yield server - - -@dataclass(slots=True) -class Connections: - server: Server - _selector: selectors.BaseSelector - _lock: threading.Lock = field(default_factory=threading.Lock) - - def select(self) -> Iterator[selectors.SelectorKey]: - selector, server_sock = self._selector, self.server.sock - server_sock.settimeout(0) - selector.register(server_sock, selectors.EVENT_READ) - try: - while True: - self._cleanup_stale() - # TODO Take iteration payload (pending connections) and set it empty, under the lock - # TODO Add selector.select() to the current payload (chain) - for key, _ in selector.select(timeout=threadtools.CHECK_TIMEOUT): - yield key - finally: - selector.unregister(server_sock) + # TODO Close all the client connections that are currently registered in the selector + # They can be in either: + # - Idle state (keep-alive) — just close the socket, no need to send anything + # - Request being sent by the client, but not fully received yet — send 503 Service Unavailable and close the socket + # or simply close the socket for now + # - Draining request body, but not fully received yet — response already sent, just close the socket @final @@ -78,7 +66,7 @@ def __init__( selector: selectors.BaseSelector, ) -> None: self.sock = server_sock - self.port = server_sock.getsockname()[1] + self.port: int = server_sock.getsockname()[1] """ Actual port the server is listening on. @@ -86,203 +74,191 @@ def __init__( """ self.selector = selector self.config = config - self._logger = logger + self.logger = logger self._lock = threading.Lock() def _find_stale(self): - now, keep_alive_timeout = time.monotonic(), self.server.config.keep_alive_timeout - for key in self._selector.get_map().values(): - if (conn := key.data) and isinstance(conn, HTTPConn): - if now - conn.idle_since > keep_alive_timeout: - yield conn + now = time.monotonic() + for key in self.selector.get_map().values(): + if (conn := key.data) and isinstance(conn, HTTPConn) and conn.close_at and now > conn.close_at: + yield conn def _cleanup_stale(self): with self._lock: for conn in list(self._find_stale()): - self._selector.unregister(conn.sock.raw_sock) - conn.close() + self.selector.unregister(conn.sock) + conn.close() # TODO Send 408 Request Timeout with Connection: close, then close the socket - # TODO Add self-pipe wakeup trick and queue + # TODO Add self-pipe wakeup trick and a queue def track(self, conn: HTTPConn) -> None: - conn.idle_since, sock = time.monotonic(), conn.sock.raw_sock + sock = conn.sock sock.settimeout(0) with self._lock: - self._selector.register(sock, selectors.EVENT_READ, data=conn) + self.selector.register(sock, selectors.EVENT_READ, data=conn) conn.tracked = True - # TODO Remove - def keep_alive(self, conn: HTTPConn) -> None: - self.track(conn) - def stop_tracking(self, conn: HTTPConn) -> None: - sock = conn.sock.raw_sock + sock = conn.sock with self._lock: - self._selector.unregister(sock) - sock.settimeout(None) + self.selector.unregister(sock) + sock.settimeout(RW_TIMEOUT) conn.tracked = False - def run(self, h: RequestHandler) -> Iterator[None]: - conns, server_sock = self._conns, self.sock - self._running = True - for key in conns.select(): - yield + def run(self, h: RequestHandler) -> None: + """One iteration of the server loop. Should be called repeatedly until the server is stopped.""" + server_sock = self.sock + threadtools.check_cancelled() + self._cleanup_stale() + # TODO Take iteration payload (pending connections) and set it empty, under the lock + # TODO Add selector.select() to the current payload (chain) + for key, _ in self.selector.select(timeout=threadtools.CHECK_TIMEOUT): if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() - cs = ClientSocket(client_sock, client_addr, self.config.rw_timeout) - # yield HTTPConn(self, self.config, cs, self._logger) - self._handle_client_conn(HTTPConn(self, self.config, cs, self._logger)) + conn = HTTPConn(self, client_sock, client_addr) + self.track(conn) elif (conn := key.data) and isinstance(conn, HTTPConn): - # conns.unregister(conn) - # yield conn - self._handle_client_conn(conn) + conn(h) # TODO Handle exceptions... else: raise RuntimeError(f"Unexpected selector key: {key!r}") @final -@dataclass(slots=True) +@dataclass(eq=False, slots=True) class HTTPConn: + server: Server sock: socket.socket addr: tuple[str, int] + recv_closed: bool = False parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) - idle_since: float = 0.0 + close_at: float | None = None # Used only when tracked, to enforce keep-alive and read timeouts tracked: bool = False - current_req: _HTTPReq | None = None - - def req_state(self) -> Literal["DRAIN_BODY", "COLLECT_BODY"] | None: - our_state, their_state = self.our_state, self.their_state - if self.current_req: - if our_state in (h11.DONE, h11.MUST_CLOSE): - return "DRAIN_BODY" - # We are in the middle of processing a request, the handler is requested to collect the full request body - assert our_state in (h11.SEND_RESPONSE, h11.SEND_BODY) - if their_state is h11.SEND_BODY: - return "COLLECT_BODY" - - # FIXME Do call it! + def close(self) -> None: + if self.tracked: + self.server.selector.unregister(self.sock) self.sock.close() - def receive(self, size: int, /) -> None: - self.parser.receive_data(self.sock.recv(size)) - - def send(self, event: h11.Event) -> None: - self.sock.sendall(self.parser.send(event)) + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: + data = self.sock.recv(size) + self.parser.receive_data(data) + if not data: + self.recv_closed = True + + # Helper method when a req (conn) is borrowed + def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11.EndOfMessage) -> None: + payload = self.parser.send(event) + payload_len = len(payload) + sock, total_sent = self.sock, 0 + while total_sent < payload_len: + sent = sock.send(payload[total_sent:]) + if sent == 0: + raise ConnectionAbortedError("socket is broken") + total_sent = total_sent + sent def __call__(self, h: RequestHandler) -> None: - parser, sock = self.parser, self.sock + parser = self.parser + + # TODO Handler LocalProtocolError, it will send respo automatically + + # TODO Check state: if our_state is not h11.DONE, then send 500 Internal Server Error and give back + # (a handler should always finish with a response) while self.tracked: if parser.our_state is h11.MUST_CLOSE: - client_conn.close() + self.close() # TODO Proper half close, later + # FIXME Remove the socket from the selector return if parser.our_state is h11.DONE and parser.their_state is h11.DONE: parser.start_next_cycle() - self.current_req = None + self.close_at = time.monotonic() + self.server.config.keep_alive_timeout + event = parser.next_event() - if (self.current_req and - parser.their_state in h11.SEND_BODY and - parser.our_state in (h11.SEND_RESPONSE, h11.SEND_BODY) - ): # We are in the middle of processing a request, the handler is requested to collect the full request body - if event is h11.NEED_DATA: - self._conn.receive(size) - elif isinstance(event, h11.Data): - self.current_req.request_body += event.data - elif isinstance(event, h11.EndOfMessage): - h(self.current_req) # Call the handler again, now with the full request body - if req_ctx.borrowed: - return - - elif event is h11.NEED_DATA: - if parser.they_are_waiting_for_100_continue: - if self.req_state() == "DRAIN_BODY": - self._logger.debug("Draining request body for a request with 'Expect: 100-continue' header") - self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) - else: - self.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) - parser.receive_data(sock.recv(DEFAULT_BUFFER_SIZE)) + if event is h11.NEED_DATA: + if parser.they_are_waiting_for_100_continue: # Drain the request body + self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) + continue + # TODO h11.RemoteProtocolError: peer closed connection without sending complete message body + try: + self.receive() + except BlockingIOError: + return # Wait for it in the selector + self.close_at = time.monotonic() + RW_TIMEOUT + elif isinstance(event, h11.Data | h11.EndOfMessage): + continue # Drain the request body elif isinstance(event, h11.Request): - self.current_req = req_ctx = _HTTPReq(self, client_conn, event) + req_ctx = HTTPReqCtx(self.server, self, event) h(req_ctx) if req_ctx.borrowed: return - # There can be multiple cases: - # - the request is fully processed and the response is sent, we are done - # - their_state is SEND_BODY, which means that the handler requested the full request body elif isinstance(event, h11.ConnectionClosed): - self._logger.debug("Client closed connection") - client_conn.close() + self.server.logger.debug("Client closed connection") + self.close() return - else: # h11.Data | h11.EndOfMessage should be handled while processing the request (body) + else: raise RuntimeError(f"Unexpected {event!r} in the connection loop") @dataclass(eq=False, frozen=True, slots=True) -class _HTTPReq: +class HTTPReqCtx: _server: Server _conn: HTTPConn - request: h11.Request - request_body: bytes | None = field(default=None, init=False) @property def borrowed(self) -> bool: return not self._conn.tracked - def borrow(self) -> BorrowedHTTPReq: - assert self._conn.tracked + @contextmanager + def borrow(self): + assert not self.borrowed self._server.stop_tracking(self._conn) - return BorrowedHTTPReq(self._server, self._conn, self.request) + try: + yield self + finally: + self._maybe_give_back() + # if not self._conn.recv_closed: + # self._server.track(self._conn) + + def _maybe_give_back(self) -> None: + if self.borrowed: + self._server.track(self._conn) # Usually with a simple response, like 404 or 405, with a small body (so it can fit into the kernel socket buffer, # to not block the server thread) def complete(self, response: h11.Response, body: bytes | None = None) -> None: - self._conn.send(response) + self.start_response(response) if body is not None: - self._conn.send(h11.Data(body)) - self._conn.send(h11.EndOfMessage()) - - -@dataclass(eq=False, frozen=True, slots=True) -class BorrowedHTTPReq: - _server: Server - _conn: HTTPConn - - request: h11.Request - request_body: bytearray | None - - def give_back(self) -> None: - assert not self._conn.tracked - self._server.track(self._conn) + self.send(body) + self.finish_response() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: parser = self._conn.parser if parser.their_state is h11.DONE: # Request body exhausted return b"" + if parser.they_are_waiting_for_100_continue: + self._conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) while True: event = parser.next_event() if event is h11.NEED_DATA: - if parser.they_are_waiting_for_100_continue: - self._conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) self._conn.receive(size) elif isinstance(event, h11.Data): return event.data elif isinstance(event, h11.EndOfMessage): return b"" - # elif isinstance(event, h11.ConnectionClosed): - # raise ConnectionAbortedError("Client closed connection unexpectedly") - else: + else: # h11.ConnectionClosed is not possible, it will be a protocol error raise RuntimeError(f"Unexpected h11 event: {event!r}") - def start_response(self, response: h11.Response, /) -> None: + def start_response(self, response: h11.Response | h11.InformationalResponse, /) -> None: self._conn.send(response) + # TODO Accept any Buffer, later def send(self, chunk: bytes, /) -> None: self._conn.send(h11.Data(chunk)) - def end_response(self) -> None: + def finish_response(self) -> None: self._conn.send(h11.EndOfMessage()) + self._maybe_give_back() -RequestHandler = Callable[[HTTPReq], None] +RequestHandler = Callable[[HTTPReqCtx], None] diff --git a/localpost/http/worker.py b/localpost/http/worker.py deleted file mode 100644 index 0d946af..0000000 --- a/localpost/http/worker.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import logging -import threading -from collections.abc import AsyncIterator, Awaitable -from wsgiref.types import WSGIApplication - -import anyio -from anyio import create_task_group, from_thread, to_thread -from anyio.abc import TaskGroup - -from localpost import hosting, threadtools -from localpost.http.config import WorkerConfig -from localpost.http.server import start_http_server, RequestHandler -from localpost.http.wsgi import wrap_wsgi - -__all__ = ["http_server"] - - - - - -@hosting.service -async def http_server(app: WSGIApplication, config: WorkerConfig, /) -> AsyncIterator[None]: - """Run multiple servers (workers).""" - handler = wrap_wsgi(app) - req_threads = anyio.CapacityLimiter(config.max_requests) - req_sem = threadtools.cancellable_semaphore(config.max_requests) - - def handle_client(c): - try: - c(handler) - finally: - req_sem.release() - - def handle_client_thread(c) -> Awaitable[None]: - return to_thread.run_sync(handle_client, c, limiter=req_threads) - - def handle_clients(): - req_sem.acquire() - for c in server.accept(): - # Handle each client connection in a separate thread - from_thread.run_sync(tg.start_soon, handle_client_thread, c) - req_sem.acquire() - - def handle_clients_thread() -> Awaitable[None]: - return to_thread.run_sync(handle_clients, limiter=anyio.CapacityLimiter(1)) - - async with create_task_group() as tg: - with start_http_server(config.server) as server: - tg.start_soon(handle_clients_thread) - yield - diff --git a/localpost/http/wsgi/__init__.py b/localpost/http/wsgi/__init__.py index 9594088..869eeb3 100644 --- a/localpost/http/wsgi/__init__.py +++ b/localpost/http/wsgi/__init__.py @@ -1,40 +1,43 @@ from __future__ import annotations -import logging import sys from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, closing, suppress -from dataclasses import dataclass from io import BufferedReader, IOBase, RawIOBase -from typing import Any +from typing import Any, final, override from wsgiref.types import WSGIApplication import h11 -from flask import stream_with_context -from localpost.http.server import HTTPConn, RequestHandler, HTTPReq +from localpost.http.server import BorrowedHTTPReq, RequestHandler +@final class RequestBodyStream(RawIOBase): - def __init__(self, ctx: HTTPReq) -> None: + def __init__(self, ctx: BorrowedHTTPReq) -> None: self._ctx = ctx self.completed = False + @override def writable(self): return False + @override def seekable(self): return False + @override def readable(self): return True + @override def readall(self): chunks = bytearray() while not self.completed: chunks.extend(self._ctx.receive()) return chunks + @override def readinto(self, b: bytearray, /) -> int: try: data = self._ctx.receive(len(b)) @@ -130,36 +133,3 @@ def _build_environ(client: HTTPConn, request: h11.Request, body: IOBase) -> dict environ[name_str] = value.decode("ISO-8859-1") return environ - - -def _main_flask(): - logging.basicConfig(level=logging.DEBUG) - - from flask import Flask - from flask import request as flask_request - - from localpost.http.config import ServerConfig - from localpost.http.server import start_http_server - - app = Flask(__name__) - - @app.route("/hello/") - def hello(name): - user_agent = flask_request.headers.get("User-Agent", "Unknown") - return f"Hello, {name}! Your User-Agent is: {user_agent}\n" - - @app.route("/hello-stream/") - @stream_with_context - def hello_stream(name): - user_agent = flask_request.headers.get("User-Agent", "Unknown") - yield f"Hello, {name}! " - yield f"Your User-Agent is: {user_agent}\n" - - handler = wrap_wsgi(app) - with start_http_server(ServerConfig()) as server: - for client_conn in server.accept(): - client_conn(handler) - - -if __name__ == "__main__": - _main_flask() diff --git a/localpost/threadtools.py b/localpost/threadtools.py index a706156..9a61c74 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -5,23 +5,29 @@ import threading import time from collections import deque -from collections.abc import Callable, Iterator, AsyncIterable, Awaitable -from contextlib import contextmanager, asynccontextmanager, suppress -from functools import partial -from typing import Any, Protocol, Self, final, Final, assert_never - -import anyio -from anyio import CapacityLimiter, ClosedResourceError, EndOfStream, WouldBlock, from_thread, to_thread, \ - create_task_group -from anyio.abc import TaskGroup as AnyioTaskGroup +from collections.abc import Awaitable, Callable, Iterator, Sequence +from contextlib import asynccontextmanager +from typing import Any, Protocol, Self, final, override + +from anyio import ( + CapacityLimiter, + ClosedResourceError, + EndOfStream, + WouldBlock, + create_task_group, + from_thread, + to_thread, +) __all__ = [ "Channel", "SendChannel", "ReceiveChannel", + "create_executor", + "gather", ] -from localpost._utils import is_async_callable, Switch, RandomDelay +from localpost._utils import NOT_SET, RandomDelay, is_async_callable CHECK_TIMEOUT: float = 1.0 """Timeout (seconds) for cancellation checks (e.g. in the server loop).""" @@ -50,13 +56,13 @@ def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None self.release = lock.release self.__exit__ = lock.__exit__ if hasattr(self.source, "locked"): - self.locked = lock.locked + self.locked = lock.locked # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] if hasattr(lock, "_release_save"): - self._release_save = lock._release_save + self._release_save = lock._release_save # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] if hasattr(lock, "_acquire_restore"): - self._acquire_restore = lock._acquire_restore + self._acquire_restore = lock._acquire_restore # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] if hasattr(lock, "_is_owned"): - self._is_owned = lock._is_owned + self._is_owned = lock._is_owned # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: if not blocking: @@ -78,7 +84,7 @@ def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: def cancellable_condition(lock: CancellableLock | None = None) -> threading.Condition: - cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore + cond = threading.Condition(lock or CancellableLock(threading.RLock())) # pyright: ignore[reportArgumentType] orig_wait = cond.wait def cancellable_wait(timeout: float | None = None) -> bool: @@ -104,10 +110,40 @@ def cancellable_wait(timeout: float | None = None) -> bool: def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: source = threading.BoundedSemaphore(value) - source._cond = cancellable_condition() + source._cond = cancellable_condition() # pyright: ignore[reportAttributeAccessIssue] return source +### +# Task Group related +### + + +@dc.dataclass(eq=False, slots=True) +class FutureResult: + value: Any = NOT_SET + + +def gather(*workload: Callable[[], Any] | tuple[Any, ...]) -> Sequence[Any]: + async def run_all(): + results: list[FutureResult] = [] + async with create_task_group() as tg: + for item in workload: + if callable(item): + func, args = item, () + else: + func, *args = item + res = FutureResult() + results.append(res) + tg.start_soon(run_item, res, func, *args) + return [res.value for res in results] + + async def run_item(res: FutureResult, func: Callable[..., Any], *args: Any) -> None: + res.value = await (func(*args) if is_async_callable(func) else to_thread.run_sync(func, *args)) + + return from_thread.run(run_all) + + ### # Thread Pool ### @@ -140,14 +176,13 @@ class ThreadPoolExecutor[T]: _limiter: threading.Semaphore _start_worker: Callable[[], None] - def acquire(self) -> Callable[[T], None]: + def acquire_nowait(self) -> Callable[[T], None] | None: """ Acquire a slot in the executor, get a submit callable for the payload. The slot is released when the payload is processed. """ - self._limiter.acquire() - return self._handle + return self._handle if self._limiter.acquire(blocking=False) else None def _handle(self, payload: T) -> None: try: @@ -162,11 +197,11 @@ async def create_executor[T]( func: Callable[[T], None], max_workers: int = min(32, (os.process_cpu_count() or 1) + 4), worker_max_age: int = 60 * 5, # Seconds after which a thread is stopped -) -> AsyncIterable[ThreadPoolExecutor[T]]: +): max_age_jitter = RandomDelay((0.0, worker_max_age * 0.2)) threads_limiter = CapacityLimiter(max_workers) work_limiter = cancellable_semaphore(max_workers) - sender, receiver = Channel.create() + sender, receiver = Channel[T].create() def start_thread() -> None: stop_at = current_time() + worker_max_age + max_age_jitter().total_seconds() @@ -176,7 +211,7 @@ def start_thread() -> None: async with create_task_group() as exec_tg, sender: permanent_thread = ExecutorThread(func, receiver, None) exec_tg.start_soon(permanent_thread.arun, work_limiter, threads_limiter) - yield ThreadPoolExecutor(sender, work_limiter, start_thread) + yield ThreadPoolExecutor[T](sender, work_limiter, start_thread) ### @@ -301,12 +336,14 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self._lock.release() +@final # TODO dataclass + repr class SendChannel[T](BaseSendChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False + @override def clone(self): with self._state as state: if self._closed: @@ -323,6 +360,7 @@ def put_nowait(self, item: T, /) -> None: state.buffer.append(item) state.not_empty.notify() + @override def put(self, item: T, /) -> None: # Phase 1: wait for space in the buffer while True: @@ -355,12 +393,14 @@ def close(self) -> None: state.not_full.notify() # Wake up threads waiting in put() immediately +@final # TODO dataclass + repr class ReceiveChannel[T](BaseReceiveChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False + @override def clone(self): with self._state as state: if self._closed: @@ -368,6 +408,7 @@ def clone(self): state.open_receive_channels += 1 return ReceiveChannel(state) + @override def get(self) -> T: check_cancelled() while not self._closed: @@ -381,6 +422,7 @@ def get(self) -> T: state.not_empty.wait() raise ClosedResourceError("receive channel has been closed") + @override def close(self) -> None: with self._state as state: if not self._closed: diff --git a/pyproject.toml b/pyproject.toml index 37e4961..50539fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,9 @@ omit = ["tests/*"] [tool.pyright] include = ["localpost"] +reportAny = false +reportExplicitAny = false +reportUnusedCallResult = false [[tool.mypy.overrides]] module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] From 63400e139f1f12f2559266cf919e0ad45bc6535b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 2 Mar 2026 22:40:10 +0000 Subject: [PATCH 027/286] WIP on tests --- justfile | 20 +-- tests/http/__init__.py | 0 tests/http/server.py | 325 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 tests/http/__init__.py create mode 100644 tests/http/server.py diff --git a/justfile b/justfile index 418efe7..b0c6808 100755 --- a/justfile +++ b/justfile @@ -12,29 +12,20 @@ deps-upgrade: [doc("Check types (using both PyRight and MyPy)")] types: - -ty check localpost - -pyright --pythonpath $(which python) localpost - -mypy --pretty --strict-bytes --python-executable $(which python) localpost + -basedpyright --pythonpath $(which python) localpost [doc("Check types (using both PyRight and MyPy)")] types-strict: - -pyright --pythonpath $(which python) localpost - -mypy --pretty \ - --python-executable $(which python) \ - --strict-bytes \ - --warn-unreachable \ - localpost + -basedpyright --pythonpath $(which python) localpost [doc("Check types (including examples and tests)")] types-all: types - -pyright --pythonpath $(which python) \ - examples tests - -mypy --pretty --strict-bytes --python-executable $(which python) \ + -basedpyright --pythonpath $(which python) \ examples tests [doc("Check that the public API is correctly typed")] type-coverage: - pyright --pythonpath $(which python) --verifytypes localpost localpost/* + basedpyright --pythonpath $(which python) --verifytypes localpost localpost/* format: ruff check --fix localpost @@ -46,8 +37,7 @@ format-all: format check file: -ruff check --fix {{ file }} - -pyright --pythonpath $(which python) {{ file }} - -mypy --pretty --strict-bytes --python-executable $(which python) {{ file }} + -basedpyright --pythonpath $(which python) {{ file }} tests: pytest --cov-report=term --cov-report=xml --cov-branch --cov -v diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/http/server.py b/tests/http/server.py new file mode 100644 index 0000000..9287305 --- /dev/null +++ b/tests/http/server.py @@ -0,0 +1,325 @@ +"""Tests for the HTTP server (localpost.http.server).""" + +import socket +import threading + +import h11 +import pytest + +from localpost import threadtools +from localpost.http.config import ServerConfig +from localpost.http.server import HTTPReqCtx, start_http_server + + +@pytest.fixture(autouse=True) +def _disable_cancellation_check(monkeypatch): + """Server loop calls check_cancelled(), which requires AnyIO context. Disable it for sync tests.""" + monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) + + +@pytest.fixture +def server_config(): + return ServerConfig(host="127.0.0.1", port=0) # port=0 → auto-assign + + +def _send_raw(port: int, data: bytes) -> bytes: + """Send raw bytes to the server and return the full response.""" + with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: + sock.sendall(data) + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + + +def _h11_request(port: int, method: str = "GET", target: str = "/", headers=None, body: bytes | None = None): + """Send a request via h11 client and return (response, body).""" + conn = h11.Connection(h11.CLIENT) + hdrs = list(headers or []) + hdrs.append(("Host", "localhost")) + if body is not None: + hdrs.append(("Content-Length", str(len(body)))) + req = h11.Request(method=method, target=target, headers=hdrs) + + with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: + sock.sendall(conn.send(req)) + if body is not None: + sock.sendall(conn.send(h11.Data(data=body))) + sock.sendall(conn.send(h11.EndOfMessage())) + + response = None + body_chunks = [] + + while True: + event = conn.next_event() + if event is h11.NEED_DATA: + data = sock.recv(4096) + conn.receive_data(data) + continue + if isinstance(event, h11.Response): + response = event + elif isinstance(event, h11.Data): + body_chunks.append(event.data) + elif isinstance(event, h11.EndOfMessage): + break + elif isinstance(event, h11.ConnectionClosed | h11.NEED_DATA.__class__): + break + + return response, b"".join(body_chunks) + + +def _run_server_iterations(server, handler, n=10): + """Run the server loop for a fixed number of iterations.""" + for _ in range(n): + try: + server.run(handler) + except OSError: + return # Server socket closed (context manager exited) + + +# --- Tests --- + + +class TestStartHttpServer: + def test_creates_server_with_auto_port(self, server_config): + with start_http_server(server_config) as server: + assert server.port > 0 + assert server.port != 8000 # auto-assigned, should differ from the default + + def test_server_socket_is_listening(self, server_config): + with start_http_server(server_config) as server: + # Should be able to connect + with socket.create_connection(("127.0.0.1", server.port), timeout=2): + pass + + def test_server_socket_closed_after_context(self, server_config): + with start_http_server(server_config) as server: + port = server.port + # Socket should be closed, connection should fail + with pytest.raises(ConnectionRefusedError): + socket.create_connection(("127.0.0.1", port), timeout=1) + + +class TestBasicRequestResponse: + def test_simple_200(self, server_config): + def handler(ctx: HTTPReqCtx): + ctx.complete( + h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + b"OK", + ) + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + + resp, body = _h11_request(server.port) + + t.join(timeout=5) + + assert resp is not None + assert resp.status_code == 200 + assert body == b"OK" + + def test_404_response(self, server_config): + def handler(ctx: HTTPReqCtx): + ctx.complete( + h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), + b"Not Found", + ) + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + + resp, body = _h11_request(server.port) + + t.join(timeout=5) + + assert resp.status_code == 404 + assert body == b"Not Found" + + def test_empty_body(self, server_config): + def handler(ctx: HTTPReqCtx): + ctx.complete(h11.Response(status_code=204, headers=[])) + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + + resp, body = _h11_request(server.port) + + t.join(timeout=5) + + assert resp.status_code == 204 + assert body == b"" + + +class TestRequestRouting: + def test_handler_sees_method_and_target(self, server_config): + captured = {} + + def handler(ctx: HTTPReqCtx): + captured["method"] = ctx.request.method + captured["target"] = ctx.request.target + ctx.complete(h11.Response(status_code=200, headers=[]), b"") + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + _h11_request(server.port, method="POST", target="/api/items?q=1") + t.join(timeout=5) + + assert captured["method"] == b"POST" + assert captured["target"] == b"/api/items?q=1" + + def test_handler_sees_headers(self, server_config): + captured_headers = {} + + def handler(ctx: HTTPReqCtx): + for name, value in ctx.request.headers: + captured_headers[name] = value + ctx.complete(h11.Response(status_code=200, headers=[]), b"") + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + _h11_request(server.port, headers=[("X-Custom", "hello")]) + t.join(timeout=5) + + assert captured_headers[b"x-custom"] == b"hello" + + +class TestRequestBody: + def test_receive_post_body(self, server_config): + received_body = bytearray() + + def handler(ctx: HTTPReqCtx): + with ctx.borrow(): + while True: + chunk = ctx.receive() + if not chunk: + break + received_body.extend(chunk) + ctx.complete(h11.Response(status_code=200, headers=[]), b"ok") + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler, 20)) + t.start() + + payload = b"hello world body" + _h11_request(server.port, method="POST", body=payload) + + t.join(timeout=5) + + assert bytes(received_body) == b"hello world body" + + +class TestChunkedResponse: + def test_streaming_response(self, server_config): + def handler(ctx: HTTPReqCtx): + ctx.start_response( + h11.Response( + status_code=200, + headers=[(b"Transfer-Encoding", b"chunked")], + ) + ) + ctx.send(b"chunk1") + ctx.send(b"chunk2") + ctx.finish_response() + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + + resp, body = _h11_request(server.port) + + t.join(timeout=5) + + assert resp.status_code == 200 + assert body == b"chunk1chunk2" + + +class TestBorrow: + def test_borrow_and_return(self, server_config): + borrow_states = [] + + def handler(ctx: HTTPReqCtx): + borrow_states.append(ctx.borrowed) # False — still tracked + with ctx.borrow(): + borrow_states.append(ctx.borrowed) # True — untracked + ctx.complete(h11.Response(status_code=200, headers=[]), b"borrowed") + borrow_states.append(ctx.borrowed) # False — re-tracked after finish_response + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler, 15)) + t.start() + _h11_request(server.port) + t.join(timeout=5) + + assert borrow_states == [False, True, False] + + +class TestKeepAlive: + def test_multiple_requests_on_same_connection(self, server_config): + call_count = 0 + + def handler(ctx: HTTPReqCtx): + nonlocal call_count + call_count += 1 + ctx.complete( + h11.Response(status_code=200, headers=[(b"Content-Length", b"2")]), + b"ok", + ) + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler, 30)) + t.start() + + # Use a single h11 client connection for two requests + client = h11.Connection(h11.CLIENT) + with socket.create_connection(("127.0.0.1", server.port), timeout=5) as sock: + for _ in range(2): + req = h11.Request( + method="GET", target="/", headers=[("Host", "localhost")] + ) + sock.sendall(client.send(req)) + sock.sendall(client.send(h11.EndOfMessage())) + + while True: + event = client.next_event() + if event is h11.NEED_DATA: + data = sock.recv(4096) + client.receive_data(data) + continue + if isinstance(event, h11.EndOfMessage): + break + client.start_next_cycle() + + t.join(timeout=5) + + assert call_count == 2 + + def test_connection_close_header(self, server_config): + """When client sends Connection: close, server should close after one request.""" + call_count = 0 + + def handler(ctx: HTTPReqCtx): + nonlocal call_count + call_count += 1 + ctx.complete( + h11.Response(status_code=200, headers=[(b"Content-Length", b"2")]), + b"ok", + ) + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_server_iterations, args=(server, handler)) + t.start() + + _h11_request(server.port, headers=[("Connection", "close")]) + + t.join(timeout=5) + + assert call_count == 1 From 6f6798a782ded159771195b2eec3e9bc1d4da59c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Mar 2026 15:55:24 +0000 Subject: [PATCH 028/286] WIP --- localpost/_utils.py | 3 +- localpost/http/TODO.md | 18 ++- localpost/http/openapi/DESIGN.md | 66 +++++++++ localpost/http/openapi/TODO.md | 14 ++ localpost/http/openapi/app.py | 83 +++++++++-- localpost/http/router.py | 139 +++++-------------- localpost/http/{wsgi/__init__.py => wsgi.py} | 0 pyproject.toml | 1 + tests/http/server.py | 116 ++++------------ tests/threadtools/__init__.py | 0 10 files changed, 230 insertions(+), 210 deletions(-) create mode 100644 localpost/http/openapi/DESIGN.md create mode 100644 localpost/http/openapi/TODO.md rename localpost/http/{wsgi/__init__.py => wsgi.py} (100%) create mode 100644 tests/threadtools/__init__.py diff --git a/localpost/_utils.py b/localpost/_utils.py index f076a01..7920ce3 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -57,7 +57,8 @@ signal.SIGTERM, # Unix signal 15. Sent by `kill `. ) if sys.platform == "win32": - HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break. + # Windows signal 21. Sent by Ctrl+Break. + HANDLED_SIGNALS += (signal.SIGBREAK,) # pyright: ignore[reportConstantRedefinition] class _IgnoredTaskStatus(TaskStatus[Any]): diff --git a/localpost/http/TODO.md b/localpost/http/TODO.md index 983ac38..cf47a4f 100644 --- a/localpost/http/TODO.md +++ b/localpost/http/TODO.md @@ -1,12 +1,22 @@ # Vision -- HTTP 1.1 only, no other versions for now (e.g. HTTP/2, HTTP/3) +## http server +- HTTP 1.1 only, no other versions (e.g. HTTP/2, HTTP/3) - so we expose h11 directly -- no WebSocket support for now +- no WebSocket support (for now) +## Router +- Simple router, very slim layer like Werkzeug or Starlette -## TODOs +## OpenAPI + +- build API from Python types + + +# TODOs + +- Brotli middleware - Converters (for fluent handler, in OpenAPI) - werkzeug.Request @@ -14,5 +24,3 @@ - msgspec - dataclass - attrs - - diff --git a/localpost/http/openapi/DESIGN.md b/localpost/http/openapi/DESIGN.md new file mode 100644 index 0000000..bc464e5 --- /dev/null +++ b/localpost/http/openapi/DESIGN.md @@ -0,0 +1,66 @@ +# Idea + +localpost.http.openapi is slim Python web framework built around OpenAPI spec. The main idea is to use Python type +system to define HTTP endpoints and infer as much OpenAPI spec from it as possible. + +## Router + +This is a slim foundation piece, like Werkzeug or Starlette, to communicate with the underlying HTTP server (WSGI, ASGI or our own HTTP server implementation). + +Here we define a RequestHandler, a basic interface for HTTP handlers (Request in, Response out). + +## Operations + +An operation is a Python function, that maps to an HTTP endpoint in OpenAPI terms. + +Operation configuration is taken from the function's signature: +- parameters type and annotations +- return type and annotations + +An operation in an app is directly converted to a request handler for the Router. + +## Arg resolvers + +Each function parameter should have a corresponding resolver, that will resolve the value from the request. + +Built-in resolver factories: +- FromPath +- FromQuery +- FromHeader +- FromBody + +Each arg resolver can end a operation by returning a instance of `OpResult`. Otherwise, the resolver should return a resolved value which will be used as an argument in the function call, for the corresponding parameter. + +## Filters + +A filter is kinda like a middleware, but limited to the first part of the request handling pipeline. + +Each filter can end a operation by returning a instance of `OpResult`. Otherwise, the filter should return `None` to continue the request handling. + +## Result converters + +A result converter is a function that converts the return value of an operation's function call into a Response instance. + +## OpenAPI integration via the type system + +Ideally, in most of the applications, the OpenAPI spec should be inferred from the type annotations of the operation functions. + +Internally, the mechanism works like this: +- when HttpApp instance is created, an empty OpenAPI doc is created +- when an operation is added to the app, we do two things: + - wrap the operation's function to create a request handler, for the Router (with all the filters, arg resolvers and result converters inside) + - modify passed OpenAPI doc to include the operation's details, component schemas, and security schemes + +HttpApp router & openapi_doc are both immutable + +## OpenAPI spec + +OpenAPI spec is a set of Python dataclasses, which represent the OpenAPI specification. + +These dataclasses are designed to create an immutable structure. + +Usual way to with it: +- create an empty OpenAPI doc instance +- add operations to it (each change creates a new doc instance) +- add security schemes to it (each change creates a new doc instance) +- add components (JSON schemas) to it (each change creates a new doc instance) diff --git a/localpost/http/openapi/TODO.md b/localpost/http/openapi/TODO.md new file mode 100644 index 0000000..4d2c6ac --- /dev/null +++ b/localpost/http/openapi/TODO.md @@ -0,0 +1,14 @@ +Hey, I'm working on a new Python web framework, very slim and compact. + +The design idea is in DESIGN.md + +What I have currently: +- http/router.py — WIP on the router part, which is an slim layer (like Werkzeug or Starlette), to communicate with the underlying HTTP server +- http/openapi/app.py — WIP on the actual framework logic +- spec/openapi/__init__.py — code taken from defspec Python library, but we need to rework it completely + +Some random thoughts: +- if a op function returns something (like a dataclass), it means that it's Ok[this_dataclass] (because it should be OpResult in the end) +- + +Please take a look at it, take a look at the code, and plan the work need to make it working. diff --git a/localpost/http/openapi/app.py b/localpost/http/openapi/app.py index 1aa208b..79354d6 100644 --- a/localpost/http/openapi/app.py +++ b/localpost/http/openapi/app.py @@ -7,12 +7,15 @@ from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass from http import HTTPMethod -from typing import Annotated, Protocol, Self, final +from typing import Annotated, ParamSpec, Protocol, Self, TypeVar, final import localpost.spec.openapi as openapi_spec -from localpost.http.router import RequestHandler, Router +from localpost.http.router import RequestCtx, RequestHandler, Response, Router -FluentOpDecorator = Callable[[Callable[..., object]], RequestHandler] +P = ParamSpec("P") +R = TypeVar("R") +# FluentOpDecorator = Callable[[Callable[..., object]], RequestHandler] +FluentOpDecorator = Callable[[Callable[P, R]], Callable[P, R]] @final @@ -22,8 +25,8 @@ def __init__(self): self._router_lock: threading.Lock = threading.Lock() self._path_ops: list[FluentPathOp] = [] - def __call__(self, request: HTTPContext) -> None: - self.router(request) + def __call__(self, req_ctx) -> None: + self.router(req_ctx) def wsgi(self, environ, start_response): """WSGI app, to be used with any WSGI server, e.g. Granian.""" @@ -63,7 +66,7 @@ def register(self, op: FluentPathOp) -> FluentPathOp: def get(self, path: str) -> FluentOpDecorator: """Decorator to register a GET operation on the given path.""" - def decorator(func) -> RequestHandler: + def decorator(func): return self.register(FluentPathOp(HTTPMethod.GET, path, func)) return decorator @@ -71,7 +74,7 @@ def decorator(func) -> RequestHandler: def post(self, path: str) -> FluentOpDecorator: """Decorator to register a POST operation on the given path.""" - def decorator(func) -> RequestHandler: + def decorator(func): return self.register(FluentPathOp(HTTPMethod.POST, path, func)) return decorator @@ -88,7 +91,7 @@ class FluentPathOp: arg_resolvers: Mapping[str, ArgResolver] response_resolver: ResponseResolver """ - To resolve (create) a response from the target's return value. + To resolve (create) a response from the target's return value. E.g. if the target returns a dict, we can serialize it to JSON and set the Content-Type header. """ @@ -264,8 +267,64 @@ def resolve(request: RESTContext) -> object: return resolve -class Example: - """Example value for a parameter or return type, to be included in the OpenAPI docs.""" +@dataclass(frozen=True, slots=True) +class OpResult: + code: int + headers: Mapping[str, str] + body: object + + def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | None = None): + object.__setattr__(self, "code", code) + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", headers or {}) + + +ResultConverter = Callable[[OpResult], Response] + + +@dataclass(frozen=True, slots=True) +class Ok[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(200, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class Created[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(201, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class NoContent(OpResult): + def __init__(self, /, *, headers: dict[str, str] | None = None): + super().__init__(204, None, headers=headers) + - def __init__(self, value: object): - self.value = value +@dataclass(frozen=True, slots=True) +class MovedPermanently(OpResult): + def __init__(self, location: str, /, *, headers: dict[str, str] | None = None): + super().__init__(301, None, headers={"Location": location, **(headers or {})}) + + +@dataclass(frozen=True, slots=True) +class BadRequest[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(400, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class Unauthorized[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(401, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class Forbidden[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(403, body, headers=headers) + + +@dataclass(frozen=True, slots=True) +class NotFound[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + super().__init__(404, body, headers=headers) diff --git a/localpost/http/router.py b/localpost/http/router.py index 4251d2c..717f5c8 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -1,144 +1,81 @@ from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Buffer, Callable, Iterable, Mapping from contextlib import AbstractContextManager, ExitStack from dataclasses import dataclass, field -from http import HTTPMethod +from http import Headers, HTTPMethod +from typing import cast, final, override import h11 -from localpost.http.server import BorrowedHTTPReq, TrackedHTTPReq +from localpost._utils import NOT_SET +from localpost.http.config import DEFAULT_BUFFER_SIZE +from localpost.http.server import HTTPReqCtx from localpost.http.uritemplate import URITemplate -# See also: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/use-http-context#httprequest -@dataclass(eq=False, slots=True) -class RESTContext: - _http: BorrowedHTTPReq +@final +@dataclass(frozen=True, eq=False, slots=True) +class RequestCtx: exit_stack: ExitStack - request: h11.Request - path_info: str + headers: Headers + method: HTTPMethod + matched_template: URITemplate + path: str query_string: str query_args: Mapping[str, list[str]] path_args: Mapping[str, str] """Path params of the matched route.""" - result: OpResult = field(default_factory=lambda: Ok(None), init=False) + receive: Callable[[int], bytes] + """Receive the body from the connection.""" - _req_body: bytearray | None = field(default=None, init=False) + _req_body: bytearray | None | object = field(default=NOT_SET, init=False) - def receive_all(self, cache: bool = False) -> Buffer: + def body(self, cache: bool = False) -> Buffer: """Read the request body.""" - if self._req_body is not None: + if self._req_body is None: + raise RuntimeError("body has been read and not cached") + if isinstance(self._req_body, bytearray): return self._req_body + body = bytearray() + self._req_body = body if cache else None while True: - chunk = self._http.receive() + chunk = self.receive(DEFAULT_BUFFER_SIZE) if not chunk: break body.extend(chunk) - if cache: - self._req_body = body return body - def get_header(self, name: str, /, default: str | None = None) -> str | None: - raw_name = name.encode() - for header_name, header_value in self.request.headers: - if header_name == raw_name: - return header_value.decode() - return default - - -RequestHandler = Callable[[RESTContext], None] -RequestHandlerMiddleware = Callable[[RequestHandler], RequestHandler] -# We cannot use h11 types here, to be compatible with WSGI too -HTTPResponse = tuple[int, Iterable[tuple[str, str]], AbstractContextManager[Iterable[bytes]]] -ResultConverter = Callable[[RESTContext], HTTPResponse] +@dataclass(frozen=True, eq=False, slots=True) +class Response: + status_code: int + headers: Headers + body: Iterable[bytes] -def not_found(req_ctx: TrackedHTTPReq) -> None: - """Default handler for unmatched routes.""" - req_ctx.complete(h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), b"Not Found") - # TODO Problem details if the client supports JSON (Accept header contains "json") - - -def method_not_allowed(request: RESTContext) -> None: - """Default handler for unsupported HTTP methods.""" - request.start_response(h11.Response(status_code=405, headers=[(b"Content-Type", b"text/plain")])) - # TODO Problem details if the client supports JSON (Accept header contains "json") - request.send(b"Method Not Allowed") +RequestHandler = Callable[[RequestCtx], Response] +RequestHandlerMiddleware = Callable[[RequestHandler], RequestHandler] @dataclass(eq=False, frozen=True, slots=True) class Router: paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]] - def __call__(self, req_ctx: TrackedHTTPReq) -> None: - # FIXME Find a match, create RESTContext with extracted path_args, entered ExitStack, etc. + def __call__(self, req_ctx: HTTPReqCtx) -> None: + # If match not found — respond immediately with 404. + # FIXME Then — borrow, move to thread, create RequestCtx with extracted path_args, entered ExitStack, etc. pass def wsgi(self, environ, start_response): """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" - # TODO Adapt from WSGI, create RESTContext, ... - - -@dataclass(frozen=True, slots=True) -class OpResult: - code: int - headers: Mapping[str, str] - body: object - - def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | None = None): - object.__setattr__(self, "code", code) - object.__setattr__(self, "body", body) - object.__setattr__(self, "headers", headers or {}) - - -@dataclass(frozen=True, slots=True) -class Ok[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(200, body, headers=headers) - - -@dataclass(frozen=True, slots=True) -class Created[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(201, body, headers=headers) - - -@dataclass(frozen=True, slots=True) -class NoContent(OpResult): - def __init__(self, /, *, headers: dict[str, str] | None = None): - super().__init__(204, None, headers=headers) - - -@dataclass(frozen=True, slots=True) -class MovedPermanently(OpResult): - def __init__(self, location: str, /, *, headers: dict[str, str] | None = None): - super().__init__(301, None, headers={"Location": location, **(headers or {})}) - - -@dataclass(frozen=True, slots=True) -class BadRequest[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(400, body, headers=headers) - - -@dataclass(frozen=True, slots=True) -class Unauthorized[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(401, body, headers=headers) - - -@dataclass(frozen=True, slots=True) -class Forbidden[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(403, body, headers=headers) - + # TODO Create RequestCtx with extracted path_args, entered ExitStack, etc. -@dataclass(frozen=True, slots=True) -class NotFound[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(404, body, headers=headers) + async def asgi(self, scope, receive, send): + """ASGI app, to be used with any ASGI server, e.g. Uvicorn.""" + # If match not found — respond immediately with 404. + # TODO Then — execute in a thread, create RequestCtx with extracted path_args, entered ExitStack, etc. diff --git a/localpost/http/wsgi/__init__.py b/localpost/http/wsgi.py similarity index 100% rename from localpost/http/wsgi/__init__.py rename to localpost/http/wsgi.py diff --git a/pyproject.toml b/pyproject.toml index 50539fc..fdd80ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,6 +160,7 @@ include = ["localpost"] reportAny = false reportExplicitAny = false reportUnusedCallResult = false +reportIncompatibleMethodOverride = false [[tool.mypy.overrides]] module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] diff --git a/tests/http/server.py b/tests/http/server.py index 9287305..09e8bcb 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -4,6 +4,7 @@ import threading import h11 +import httpx import pytest from localpost import threadtools @@ -22,62 +23,13 @@ def server_config(): return ServerConfig(host="127.0.0.1", port=0) # port=0 → auto-assign -def _send_raw(port: int, data: bytes) -> bytes: - """Send raw bytes to the server and return the full response.""" - with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: - sock.sendall(data) - chunks = [] - while True: - chunk = sock.recv(4096) - if not chunk: - break - chunks.append(chunk) - return b"".join(chunks) - - -def _h11_request(port: int, method: str = "GET", target: str = "/", headers=None, body: bytes | None = None): - """Send a request via h11 client and return (response, body).""" - conn = h11.Connection(h11.CLIENT) - hdrs = list(headers or []) - hdrs.append(("Host", "localhost")) - if body is not None: - hdrs.append(("Content-Length", str(len(body)))) - req = h11.Request(method=method, target=target, headers=hdrs) - - with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: - sock.sendall(conn.send(req)) - if body is not None: - sock.sendall(conn.send(h11.Data(data=body))) - sock.sendall(conn.send(h11.EndOfMessage())) - - response = None - body_chunks = [] - - while True: - event = conn.next_event() - if event is h11.NEED_DATA: - data = sock.recv(4096) - conn.receive_data(data) - continue - if isinstance(event, h11.Response): - response = event - elif isinstance(event, h11.Data): - body_chunks.append(event.data) - elif isinstance(event, h11.EndOfMessage): - break - elif isinstance(event, h11.ConnectionClosed | h11.NEED_DATA.__class__): - break - - return response, b"".join(body_chunks) - - def _run_server_iterations(server, handler, n=10): """Run the server loop for a fixed number of iterations.""" for _ in range(n): try: server.run(handler) - except OSError: - return # Server socket closed (context manager exited) + except (OSError, ValueError): + return # Server socket/selector closed (context manager exited) # --- Tests --- @@ -115,13 +67,12 @@ def handler(ctx: HTTPReqCtx): t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - resp, body = _h11_request(server.port) + resp = httpx.get(f"http://127.0.0.1:{server.port}/") t.join(timeout=5) - assert resp is not None assert resp.status_code == 200 - assert body == b"OK" + assert resp.text == "OK" def test_404_response(self, server_config): def handler(ctx: HTTPReqCtx): @@ -134,12 +85,12 @@ def handler(ctx: HTTPReqCtx): t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - resp, body = _h11_request(server.port) + resp = httpx.get(f"http://127.0.0.1:{server.port}/") t.join(timeout=5) assert resp.status_code == 404 - assert body == b"Not Found" + assert resp.text == "Not Found" def test_empty_body(self, server_config): def handler(ctx: HTTPReqCtx): @@ -149,12 +100,12 @@ def handler(ctx: HTTPReqCtx): t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - resp, body = _h11_request(server.port) + resp = httpx.get(f"http://127.0.0.1:{server.port}/") t.join(timeout=5) assert resp.status_code == 204 - assert body == b"" + assert resp.content == b"" class TestRequestRouting: @@ -169,7 +120,7 @@ def handler(ctx: HTTPReqCtx): with start_http_server(server_config) as server: t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - _h11_request(server.port, method="POST", target="/api/items?q=1") + httpx.post(f"http://127.0.0.1:{server.port}/api/items?q=1") t.join(timeout=5) assert captured["method"] == b"POST" @@ -186,7 +137,7 @@ def handler(ctx: HTTPReqCtx): with start_http_server(server_config) as server: t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - _h11_request(server.port, headers=[("X-Custom", "hello")]) + httpx.get(f"http://127.0.0.1:{server.port}/", headers={"X-Custom": "hello"}) t.join(timeout=5) assert captured_headers[b"x-custom"] == b"hello" @@ -197,20 +148,18 @@ def test_receive_post_body(self, server_config): received_body = bytearray() def handler(ctx: HTTPReqCtx): - with ctx.borrow(): - while True: - chunk = ctx.receive() - if not chunk: - break - received_body.extend(chunk) + while True: + chunk = ctx.receive() + if not chunk: + break + received_body.extend(chunk) ctx.complete(h11.Response(status_code=200, headers=[]), b"ok") with start_http_server(server_config) as server: t = threading.Thread(target=_run_server_iterations, args=(server, handler, 20)) t.start() - payload = b"hello world body" - _h11_request(server.port, method="POST", body=payload) + httpx.post(f"http://127.0.0.1:{server.port}/", content=b"hello world body") t.join(timeout=5) @@ -234,12 +183,12 @@ def handler(ctx: HTTPReqCtx): t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - resp, body = _h11_request(server.port) + resp = httpx.get(f"http://127.0.0.1:{server.port}/") t.join(timeout=5) assert resp.status_code == 200 - assert body == b"chunk1chunk2" + assert resp.content == b"chunk1chunk2" class TestBorrow: @@ -256,7 +205,7 @@ def handler(ctx: HTTPReqCtx): with start_http_server(server_config) as server: t = threading.Thread(target=_run_server_iterations, args=(server, handler, 15)) t.start() - _h11_request(server.port) + httpx.get(f"http://127.0.0.1:{server.port}/") t.join(timeout=5) assert borrow_states == [False, True, False] @@ -278,25 +227,10 @@ def handler(ctx: HTTPReqCtx): t = threading.Thread(target=_run_server_iterations, args=(server, handler, 30)) t.start() - # Use a single h11 client connection for two requests - client = h11.Connection(h11.CLIENT) - with socket.create_connection(("127.0.0.1", server.port), timeout=5) as sock: - for _ in range(2): - req = h11.Request( - method="GET", target="/", headers=[("Host", "localhost")] - ) - sock.sendall(client.send(req)) - sock.sendall(client.send(h11.EndOfMessage())) - - while True: - event = client.next_event() - if event is h11.NEED_DATA: - data = sock.recv(4096) - client.receive_data(data) - continue - if isinstance(event, h11.EndOfMessage): - break - client.start_next_cycle() + # httpx.Client reuses the TCP connection (keep-alive by default) + with httpx.Client(base_url=f"http://127.0.0.1:{server.port}") as client: + client.get("/") + client.get("/") t.join(timeout=5) @@ -318,7 +252,7 @@ def handler(ctx: HTTPReqCtx): t = threading.Thread(target=_run_server_iterations, args=(server, handler)) t.start() - _h11_request(server.port, headers=[("Connection", "close")]) + httpx.get(f"http://127.0.0.1:{server.port}/", headers={"Connection": "close"}) t.join(timeout=5) diff --git a/tests/threadtools/__init__.py b/tests/threadtools/__init__.py new file mode 100644 index 0000000..e69de29 From 142170c4ef016ea18e218f6a28e2c3df59440a77 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Mar 2026 20:33:12 +0400 Subject: [PATCH 029/286] Implement end-to-end OpenAPI web framework - URITemplate: Level 1 RFC 6570 parse/match with regex - Router: WSGI routing with path matching, method dispatch, 404/405 - OpenAPI spec: frozen dataclasses with immutable add_operation() - FluentPathOp: auto-wires arg resolvers from function signatures - HttpApp: builds router with /openapi.json built-in route - Example updated to use wsgiref.simple_server Co-Authored-By: Claude Opus 4.6 --- examples/http/openapi_app_server.py | 36 +-- localpost/http/openapi/app.py | 482 ++++++++++++++++------------ localpost/http/router.py | 128 ++++++-- localpost/http/uritemplate.py | 29 +- localpost/spec/openapi/__init__.py | 187 +++++++++++ 5 files changed, 604 insertions(+), 258 deletions(-) create mode 100644 localpost/spec/openapi/__init__.py diff --git a/examples/http/openapi_app_server.py b/examples/http/openapi_app_server.py index c813546..d1ca5f7 100644 --- a/examples/http/openapi_app_server.py +++ b/examples/http/openapi_app_server.py @@ -1,18 +1,9 @@ -import logging -from typing import Annotated -from uuid import UUID +from wsgiref.simple_server import make_server -from localpost import threadtools -from localpost.http.config import ServerConfig -from localpost.http.openapi.app import HttpApp, Example -from localpost.http.server import start_http_server +from localpost.http.openapi.app import HttpApp -# noinspection DuplicatedCode def main(): - logging.basicConfig(level=logging.DEBUG) - threadtools.check_cancelled = lambda: None - app = HttpApp() @app.get("/hello/{name}") @@ -20,21 +11,14 @@ def hello(name: str) -> str: return f"Hello, {name}!" @app.get("/books/{book_id}") - def hello( - book_id: UUID, page_number: int - ) -> Annotated[ - dict, - Example( - {"id": "123e4567-e89b-12d3-a456-426614174000", "title": "The Lord of the Rings", "author": "J.R.R. Tolkien"} - ), - ]: - # Dict (or any other type, actually) is serialized to JSON (or other format, according to the Accept header) - # page_number is just to demonstrate multiple parameters from different sources (path and query) - return {"id": str(id), "title": "The Lord of the Rings", "author": "J.R.R. Tolkien"} - - with start_http_server(ServerConfig()) as server: - while True: - server.run(app) + def get_book(book_id: str, page_number: int = 1) -> dict: + return {"id": book_id, "title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "page": page_number} + + print("Starting server on http://localhost:8000") + print("Try: curl http://localhost:8000/hello/world") + print("Try: curl http://localhost:8000/openapi.json") + with make_server("", 8000, app.wsgi) as server: + server.serve_forever() if __name__ == "__main__": diff --git a/localpost/http/openapi/app.py b/localpost/http/openapi/app.py index 79354d6..f12ac0a 100644 --- a/localpost/http/openapi/app.py +++ b/localpost/http/openapi/app.py @@ -1,200 +1,117 @@ -from __future__ import annotations - import inspect import json -import logging import threading from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass from http import HTTPMethod -from typing import Annotated, ParamSpec, Protocol, Self, TypeVar, final +from typing import Annotated, ParamSpec, Protocol, Self, TypeVar, final, get_args, get_origin + +import msgspec import localpost.spec.openapi as openapi_spec from localpost.http.router import RequestCtx, RequestHandler, Response, Router +from localpost.http.uritemplate import URITemplate P = ParamSpec("P") R = TypeVar("R") -# FluentOpDecorator = Callable[[Callable[..., object]], RequestHandler] FluentOpDecorator = Callable[[Callable[P, R]], Callable[P, R]] -@final -class HttpApp: - def __init__(self): - self._router: Router | None = None - self._router_lock: threading.Lock = threading.Lock() - self._path_ops: list[FluentPathOp] = [] - - def __call__(self, req_ctx) -> None: - self.router(req_ctx) - - def wsgi(self, environ, start_response): - """WSGI app, to be used with any WSGI server, e.g. Granian.""" - return self.router.wsgi(environ, start_response) - - def register_arg_resolver(self, resolver_factory: ArgResolverFactory) -> None: - # TODO Register a custom ArgResolverFactory for handling custom types in path operation parameters - pass - - def register_result_resolver(self, resolver_factory: ArgResolverFactory) -> None: - # TODO Register a custom ArgResolverFactory for handling custom types in path operation parameters - pass +# --- OpResult hierarchy --- - @property - def docs(self) -> openapi_spec.OpenAPI: - pass - @property - def router(self) -> Router: - if router := self._router: - return router - with self._router_lock: - if not self._router: - self._router = self._create_router() - return self._router - - def _create_router(self) -> Router: - # FIXME Go through all registered FluentPathOps, convert their path patterns to regexes, group by path regex, create a Router with the resulting list of (regex, {method: handler}) pairs - pass +@dataclass(frozen=True, slots=True) +class OpResult: + code: int + headers: Mapping[str, str] + body: object - def register(self, op: FluentPathOp) -> FluentPathOp: - self._path_ops.append(op) - with self._router_lock: - self._router = None - return op + def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | None = None): + object.__setattr__(self, "code", code) + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", headers or {}) - def get(self, path: str) -> FluentOpDecorator: - """Decorator to register a GET operation on the given path.""" - def decorator(func): - return self.register(FluentPathOp(HTTPMethod.GET, path, func)) +class Ok[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 200, body, headers=headers) - return decorator - def post(self, path: str) -> FluentOpDecorator: - """Decorator to register a POST operation on the given path.""" +class Created[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 201, body, headers=headers) - def decorator(func): - return self.register(FluentPathOp(HTTPMethod.POST, path, func)) - return decorator +class NoContent(OpResult): + def __init__(self, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 204, None, headers=headers) -@final -@dataclass(eq=False, frozen=True) -class FluentPathOp: - method: HTTPMethod - path: str - """Path pattern, like /books/{id} or /shop/{name}/checkout.""" +class BadRequest[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 400, body, headers=headers) - target: Callable[..., object] - arg_resolvers: Mapping[str, ArgResolver] - response_resolver: ResponseResolver - """ - To resolve (create) a response from the target's return value. - E.g. if the target returns a dict, we can serialize it to JSON and set the Content-Type header. - """ +class NotFound[T](OpResult): + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 404, body, headers=headers) - # # Security is for sure out of our scope, so this should be declared manually by the user - # security: openapi_spec.SecurityScheme | None = None - # TODO Simply supply a doc customizer... - @classmethod - def create(cls, method: HTTPMethod, path: str, func, /) -> Self: - pass # FIXME Implement: inspect func signature, create ArgResolvers for its parameters, create a FluentPathOpHandler with the target and resolvers +# --- Protocols --- - # Build it all in the root app... - # @cached_property - # def docs(self) -> openapi_spec.OpenAPIRoute: - # pass - def __call__(self, request: RESTContext) -> None: - args = {name: resolver(request) for name, resolver in self.arg_resolvers.items()} - return_value = self.target(**args) - self.response_resolver(request, return_value) +class ArgResolver(Protocol): + def __call__(self, request: RequestCtx, /) -> object: ... -# class ResponseResolverFactory(Protocol): -# def __call__(self, return_annotation: type, /) -> ResponseResolver: -# ... +class ArgResolverFactory: + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: ... class ResponseResolver(Protocol): - def __call__(self, result: object, ctx: RESTContext, http_ctx: HTTPContext, /) -> None: - # if generator — run the generator and send chunks as they are produced, set Content-Type to text/event-stream - # if File — set Content-Type according to the file type, send file in chunks - # ... - ... - - -class DefaultResponseResolverFactory: - def __call__(self, return_annotation: type, /) -> ResponseResolver: - # TODO Implement a default resolver that handles common types (str, dict, etc.) and serializes them to the appropriate format according to the Accept header (e.g. JSON for dict) - pass + def __call__(self, result: OpResult, /) -> Response: ... -# class Redirect: # Not an exception, but a valid return type for a path operation, to be handled by a custom ResponseResolver -# pass -# -# -# class FileResponse: -# pass -# -# -# class SSEResponse: -# pass - - -class ArgResolverFactory: - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: ... +# --- Resolver implementations --- -class ArgResolver(Protocol): - def __call__(self, request: RESTContext, /) -> object: - """ - Resolve an argument for the target function from the request context. +@final +class DefaultResponseResolver: + def __call__(self, result: OpResult, /) -> Response: + headers: dict[str, str] = dict(result.headers) + + if result.body is None: + return Response(status_code=result.code, headers=headers, body=[]) + + if isinstance(result.body, bytes): + body_bytes = result.body + headers.setdefault("Content-Type", "application/octet-stream") + elif isinstance(result.body, str): + body_bytes = msgspec.json.encode(result.body) + headers.setdefault("Content-Type", "application/json") + else: + try: + body_bytes = msgspec.json.encode(result.body) + except Exception: + body_bytes = json.dumps(result.body).encode() + headers.setdefault("Content-Type", "application/json") - Raises ValueError if case of any issues, e.g. missing required header or query param, invalid path param - format, etc. - """ - ... + headers["Content-Length"] = str(len(body_bytes)) + return Response(status_code=result.code, headers=headers, body=[body_bytes]) @final @dataclass(kw_only=True, frozen=True, slots=True) class FromBody(ArgResolverFactory): - """ - Built-in body resolver that supports most common Python types (dataclasses, Pydantic, msgpack) and input - formats (JSON, msgpack). - - For any complex case, just implement a custom ArgResolver and attach it to the function parameter via Annotated. - """ + """Built-in body resolver.""" json_converter: Callable[[bytes], object] | None = None def __call__(self, param: inspect.Parameter, /) -> ArgResolver: json_converter = self.json_converter or json.loads - if param.annotation is inspect.Parameter.empty: - assert param.default is inspect.Parameter.empty # Body is required (more for simplicity for now) - - def resolve_any(request: RESTContext) -> object: - if not (content_type := request.get_header("content-type")): - raise ValueError("Missing Content-Type header") - if "json" in content_type: - return json_converter(request.body.read()) - raise ValueError(f"Unsupported Content-Type: {content_type}") - - return resolve_any - - param_type = param.annotation # TODO Deal with Annotated, Optional, etc. - assert isinstance(param_type, type) - - # TODO Decode Pydantic models with Pydantic, msgspec.Struct with msgspec - # TODO Decode dataclasses with msgspec - def resolve(request: RESTContext) -> object: - pass + def resolve(request: RequestCtx) -> object: + return json_converter(request.body()) return resolve @@ -208,7 +125,19 @@ class FromHeader(ArgResolverFactory): converter: Callable[[str], object] | None = None def __call__(self, param: inspect.Parameter, /) -> ArgResolver: - pass # TODO Implement, similar to FromQuery and FromPath + header_name = self.name or param.name.replace("_", "-").lower() + converter = self.converter or str + has_default = param.default is not inspect.Parameter.empty + + def resolve(request: RequestCtx) -> object: + value = request.headers.get(header_name) + if value is None: + if has_default: + return param.default + raise ValueError(f"Missing required header: {header_name}") + return converter(value) + + return resolve @final @@ -225,23 +154,30 @@ def __call__(self, param: inspect.Parameter, /) -> ArgResolver: if self.converter: converter = self.converter elif param.annotation is not inspect.Parameter.empty: - param_type = param.annotation # TODO Deal with Annotated, Optional, etc. - assert isinstance(param_type, type) - if issubclass(param_type, Collection): # list, set, etc. + param_type = param.annotation + if get_origin(param_type) is Annotated: + param_type = get_args(param_type)[0] + if isinstance(param_type, type) and issubclass(param_type, Collection) and param_type is not str: converter = param_type + elif isinstance(param_type, type): + converter = lambda values, _t=param_type: _t(values[0]) else: - converter = lambda values: param_type(values[0]) - else: # First argument as a string by default, if not annotated and no converter provided + converter = lambda values: values[0] + else: converter = lambda values: values[0] - def resolve(request: RESTContext) -> object: - if param.default is inspect.Parameter.empty: - return converter(request.query_args[param_name]) - return converter(request.query_args.get(param_name, param.default)) + def resolve(request: RequestCtx) -> object: + values = request.query_args.get(param_name) + if values is None: + if has_default: + return param.default + raise ValueError(f"Missing required query parameter: {param_name}") + return converter(values) return resolve +@final @dataclass(frozen=True, slots=True) class FromPath(ArgResolverFactory): """Resolve from a path parameter, e.g. /books/{id}.""" @@ -254,77 +190,219 @@ def __call__(self, param: inspect.Parameter, /) -> ArgResolver: if self.converter: converter = self.converter elif param.annotation is not inspect.Parameter.empty: - param_type = param.annotation # TODO Deal with Annotated, Optional, etc. - converter = param_type + param_type = param.annotation + if get_origin(param_type) is Annotated: + param_type = get_args(param_type)[0] + if isinstance(param_type, type): + converter = param_type + else: + converter = str else: converter = str - def resolve(request: RESTContext) -> object: - if param.default is inspect.Parameter.empty: - return converter(request.path_args[param_name]) - return converter(request.path_args.get(param_name, param.default)) + def resolve(request: RequestCtx) -> object: + value = request.path_args.get(param_name) + if value is None: + raise ValueError(f"Missing path parameter: {param_name}") + return converter(value) return resolve -@dataclass(frozen=True, slots=True) -class OpResult: - code: int - headers: Mapping[str, str] - body: object +def _extract_resolver_factory(annotation) -> ArgResolverFactory | None: + """Extract an ArgResolverFactory from an Annotated type, if present.""" + if get_origin(annotation) is Annotated: + for arg in get_args(annotation): + if isinstance(arg, ArgResolverFactory): + return arg + return None - def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | None = None): - object.__setattr__(self, "code", code) - object.__setattr__(self, "body", body) - object.__setattr__(self, "headers", headers or {}) +# --- FluentPathOp --- -ResultConverter = Callable[[OpResult], Response] +@final +@dataclass(eq=False, frozen=True) +class FluentPathOp: + method: HTTPMethod + path: str + """Path pattern, like /books/{id} or /shop/{name}/checkout.""" -@dataclass(frozen=True, slots=True) -class Ok[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(200, body, headers=headers) + target: Callable[..., object] + arg_resolvers: Mapping[str, ArgResolver] + response_resolver: ResponseResolver + @classmethod + def create(cls, method: HTTPMethod, path: str, func: Callable, /) -> Self: + template = URITemplate.parse(path) + path_var_names = set(template.variable_names) -@dataclass(frozen=True, slots=True) -class Created[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(201, body, headers=headers) + sig = inspect.signature(func) + arg_resolvers: dict[str, ArgResolver] = {} + for param_name, param in sig.parameters.items(): + annotation = param.annotation + resolver_factory = _extract_resolver_factory(annotation) -@dataclass(frozen=True, slots=True) -class NoContent(OpResult): - def __init__(self, /, *, headers: dict[str, str] | None = None): - super().__init__(204, None, headers=headers) + if resolver_factory is not None: + arg_resolvers[param_name] = resolver_factory(param) + elif param_name in path_var_names: + arg_resolvers[param_name] = FromPath()(param) + else: + arg_resolvers[param_name] = FromQuery()(param) + + return cls( + method=method, + path=path, + target=func, + arg_resolvers=arg_resolvers, + response_resolver=DefaultResponseResolver(), + ) + + def as_handler(self) -> RequestHandler: + def handler(ctx: RequestCtx) -> Response: + return self(ctx) + + return handler + + def __call__(self, request: RequestCtx) -> Response: + args = {} + for name, resolver in self.arg_resolvers.items(): + result = resolver(request) + if isinstance(result, OpResult): + return self.response_resolver(result) + args[name] = result + return_value = self.target(**args) + if isinstance(return_value, OpResult): + op_result = return_value + else: + op_result = Ok(return_value) + return self.response_resolver(op_result) -@dataclass(frozen=True, slots=True) -class MovedPermanently(OpResult): - def __init__(self, location: str, /, *, headers: dict[str, str] | None = None): - super().__init__(301, None, headers={"Location": location, **(headers or {})}) +# --- HttpApp --- -@dataclass(frozen=True, slots=True) -class BadRequest[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(400, body, headers=headers) +@final +class HttpApp: + def __init__(self, *, info: openapi_spec.Info | None = None): + self._router: Router | None = None + self._router_lock: threading.Lock = threading.Lock() + self._path_ops: list[FluentPathOp] = [] + self._info = info or openapi_spec.Info() + def wsgi(self, environ, start_response): + """WSGI app, to be used with any WSGI server, e.g. Granian.""" + return self.router.wsgi(environ, start_response) -@dataclass(frozen=True, slots=True) -class Unauthorized[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(401, body, headers=headers) + @property + def docs(self) -> openapi_spec.OpenAPI: + spec = openapi_spec.OpenAPI(info=self._info) + for op in self._path_ops: + params: list[openapi_spec.Parameter] = [] + template = URITemplate.parse(op.path) + for var_name in template.variable_names: + params.append( + openapi_spec.Parameter(name=var_name, location="path", required=True, schema={"type": "string"}) + ) + for arg_name in op.arg_resolvers: + if arg_name not in template.variable_names: + params.append( + openapi_spec.Parameter( + name=arg_name, location="query", required=True, schema={"type": "string"} + ) + ) + operation = openapi_spec.Operation( + summary=op.target.__doc__ or f"{op.method.value} {op.path}", + operation_id=f"{op.method.value.lower()}_{op.path.replace('/', '_').strip('_')}", + parameters=tuple(params), + responses={ + "200": openapi_spec.Response( + description="Successful response", + content={"application/json": openapi_spec.MediaType(schema={"type": "string"})}, + ) + }, + ) + spec = spec.add_operation(op.path, op.method.value, operation) + return spec + @property + def router(self) -> Router: + if router := self._router: + return router + with self._router_lock: + if not self._router: + self._router = self._create_router() + return self._router -@dataclass(frozen=True, slots=True) -class Forbidden[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(403, body, headers=headers) + def _create_router(self) -> Router: + paths: dict[URITemplate, dict[HTTPMethod, RequestHandler]] = {} + + for op in self._path_ops: + template = URITemplate.parse(op.path) + # Find existing template with same string + target_key: URITemplate | None = None + for existing in paths: + if existing.template == template.template: + target_key = existing + break + if target_key is None: + paths[template] = {op.method: op.as_handler()} + else: + paths[target_key][op.method] = op.as_handler() + # Built-in /openapi.json route + openapi_template = URITemplate.parse("/openapi.json") + app_ref = self -@dataclass(frozen=True, slots=True) -class NotFound[T](OpResult): - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - super().__init__(404, body, headers=headers) + def openapi_handler(ctx: RequestCtx) -> Response: + return Response( + status_code=200, + headers={"Content-Type": "application/json"}, + body=[app_ref.docs.to_json()], + ) + + paths[openapi_template] = {HTTPMethod.GET: openapi_handler} + return Router(paths=paths) + + def register(self, op: FluentPathOp) -> FluentPathOp: + self._path_ops.append(op) + with self._router_lock: + self._router = None + return op + + def get(self, path: str) -> FluentOpDecorator: + """Decorator to register a GET operation on the given path.""" + + def decorator(func): + self.register(FluentPathOp.create(HTTPMethod.GET, path, func)) + return func + + return decorator + + def post(self, path: str) -> FluentOpDecorator: + """Decorator to register a POST operation on the given path.""" + + def decorator(func): + self.register(FluentPathOp.create(HTTPMethod.POST, path, func)) + return func + + return decorator + + def put(self, path: str) -> FluentOpDecorator: + """Decorator to register a PUT operation on the given path.""" + + def decorator(func): + self.register(FluentPathOp.create(HTTPMethod.PUT, path, func)) + return func + + return decorator + + def delete(self, path: str) -> FluentOpDecorator: + """Decorator to register a DELETE operation on the given path.""" + + def decorator(func): + self.register(FluentPathOp.create(HTTPMethod.DELETE, path, func)) + return func + + return decorator diff --git a/localpost/http/router.py b/localpost/http/router.py index 717f5c8..46a420f 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -1,17 +1,14 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from collections.abc import Buffer, Callable, Iterable, Mapping -from contextlib import AbstractContextManager, ExitStack +from collections.abc import Callable, Iterable, Mapping +from contextlib import ExitStack from dataclasses import dataclass, field -from http import Headers, HTTPMethod -from typing import cast, final, override - -import h11 +from http import HTTPMethod +from typing import final +from urllib.parse import parse_qs from localpost._utils import NOT_SET from localpost.http.config import DEFAULT_BUFFER_SIZE -from localpost.http.server import HTTPReqCtx from localpost.http.uritemplate import URITemplate @@ -20,7 +17,7 @@ class RequestCtx: exit_stack: ExitStack - headers: Headers + headers: Mapping[str, str] method: HTTPMethod matched_template: URITemplate path: str @@ -32,29 +29,29 @@ class RequestCtx: receive: Callable[[int], bytes] """Receive the body from the connection.""" - _req_body: bytearray | None | object = field(default=NOT_SET, init=False) + _req_body: bytearray | None | object = field(default=NOT_SET, init=False, repr=False) - def body(self, cache: bool = False) -> Buffer: + def body(self, cache: bool = False) -> bytes: """Read the request body.""" if self._req_body is None: raise RuntimeError("body has been read and not cached") if isinstance(self._req_body, bytearray): - return self._req_body + return bytes(self._req_body) body = bytearray() - self._req_body = body if cache else None + object.__setattr__(self, "_req_body", body if cache else None) while True: chunk = self.receive(DEFAULT_BUFFER_SIZE) if not chunk: break body.extend(chunk) - return body + return bytes(body) @dataclass(frozen=True, eq=False, slots=True) class Response: status_code: int - headers: Headers + headers: Mapping[str, str] body: Iterable[bytes] @@ -62,20 +59,99 @@ class Response: RequestHandlerMiddleware = Callable[[RequestHandler], RequestHandler] +def _headers_from_environ(environ: dict) -> dict[str, str]: + headers: dict[str, str] = {} + for key, value in environ.items(): + if key.startswith("HTTP_"): + header_name = key[5:].replace("_", "-").lower() + headers[header_name] = value + if "CONTENT_TYPE" in environ: + headers["content-type"] = environ["CONTENT_TYPE"] + if "CONTENT_LENGTH" in environ: + headers["content-length"] = environ["CONTENT_LENGTH"] + return headers + + @dataclass(eq=False, frozen=True, slots=True) class Router: paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]] - def __call__(self, req_ctx: HTTPReqCtx) -> None: - # If match not found — respond immediately with 404. - # FIXME Then — borrow, move to thread, create RequestCtx with extracted path_args, entered ExitStack, etc. - pass - - def wsgi(self, environ, start_response): + def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" - # TODO Create RequestCtx with extracted path_args, entered ExitStack, etc. + request_path = environ.get("PATH_INFO", "/") + request_method_str = environ.get("REQUEST_METHOD", "GET").upper() + + # Find matching template + matched_template: URITemplate | None = None + path_args: dict[str, str] = {} + matched_methods: Mapping[HTTPMethod, RequestHandler] | None = None + + for template, methods in self.paths.items(): + result = template.match(request_path) + if result is not None: + matched_template = template + path_args = result + matched_methods = methods + break - async def asgi(self, scope, receive, send): - """ASGI app, to be used with any ASGI server, e.g. Uvicorn.""" - # If match not found — respond immediately with 404. - # TODO Then — execute in a thread, create RequestCtx with extracted path_args, entered ExitStack, etc. + if matched_template is None or matched_methods is None: + start_response("404 Not Found", [("Content-Type", "text/plain")]) + return [b"Not Found"] + + try: + method = HTTPMethod(request_method_str) + except ValueError: + start_response("405 Method Not Allowed", [("Content-Type", "text/plain")]) + return [b"Method Not Allowed"] + + handler = matched_methods.get(method) + if handler is None: + allowed = ", ".join(m.value for m in matched_methods) + start_response("405 Method Not Allowed", [("Content-Type", "text/plain"), ("Allow", allowed)]) + return [b"Method Not Allowed"] + + query_string = environ.get("QUERY_STRING", "") + query_args = parse_qs(query_string) + headers = _headers_from_environ(environ) + + wsgi_input = environ.get("wsgi.input") + + def receive(size: int) -> bytes: + if wsgi_input is None: + return b"" + return wsgi_input.read(size) or b"" + + with ExitStack() as stack: + ctx = RequestCtx( + exit_stack=stack, + headers=headers, + method=method, + matched_template=matched_template, + path=request_path, + query_string=query_string, + query_args=query_args, + path_args=path_args, + receive=receive, + ) + response = handler(ctx) + + response_headers = [(k, v) for k, v in response.headers.items()] + status_line = f"{response.status_code} {_status_phrase(response.status_code)}" + start_response(status_line, response_headers) + return response.body + + +def _status_phrase(code: int) -> str: + phrases = { + 200: "OK", + 201: "Created", + 204: "No Content", + 301: "Moved Permanently", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 500: "Internal Server Error", + } + return phrases.get(code, "Unknown") diff --git a/localpost/http/uritemplate.py b/localpost/http/uritemplate.py index ece4c97..c8c1ac0 100644 --- a/localpost/http/uritemplate.py +++ b/localpost/http/uritemplate.py @@ -1,6 +1,9 @@ +import re from dataclasses import dataclass from typing import Self, final +_VAR_PATTERN = re.compile(r"\{([^}]+)\}") + @final @dataclass(frozen=True, slots=True) @@ -9,12 +12,30 @@ class URITemplate: template: str variable_names: tuple[str, ...] + _regex: re.Pattern[str] @classmethod def parse(cls, template: str) -> Self: - # TODO Parse the URI template and extract variable names. - raise NotImplementedError + variable_names: list[str] = [] + regex_parts: list[str] = [] + last_end = 0 + for m in _VAR_PATTERN.finditer(template): + # Escape literal text between variables + regex_parts.append(re.escape(template[last_end : m.start()])) + var_name = m.group(1) + variable_names.append(var_name) + regex_parts.append(f"(?P<{var_name}>[^/]+)") + last_end = m.end() + regex_parts.append(re.escape(template[last_end:])) + pattern = re.compile("^" + "".join(regex_parts) + "$") + return cls( + template=template, + variable_names=tuple(variable_names), + _regex=pattern, + ) def match(self, uri: str) -> dict[str, str] | None: - # TODO Implement URI template matching and variable extraction. - raise NotImplementedError + m = self._regex.match(uri) + if m is None: + return None + return m.groupdict() diff --git a/localpost/spec/openapi/__init__.py b/localpost/spec/openapi/__init__.py new file mode 100644 index 0000000..6846db7 --- /dev/null +++ b/localpost/spec/openapi/__init__.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +import msgspec + +DEFAULT_CONTENT_TYPE = "application/json" + + +@dataclass(frozen=True, slots=True) +class Schema: + type: str | None = None + properties: dict[str, Any] = field(default_factory=dict) + required: list[str] = field(default_factory=list) + items: dict[str, Any] | None = None + ref: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + """Any additional JSON Schema fields.""" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.ref: + d["$ref"] = self.ref + return d + if self.type: + d["type"] = self.type + if self.properties: + d["properties"] = self.properties + if self.required: + d["required"] = self.required + if self.items: + d["items"] = self.items + d.update(self.extra) + return d + + +@dataclass(frozen=True, slots=True) +class MediaType: + schema: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.schema: + d["schema"] = self.schema + return d + + +@dataclass(frozen=True, slots=True) +class RequestBody: + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + if not self.content: + return {} + return {"content": {k: v.to_dict() for k, v in self.content.items()}} + + +@dataclass(frozen=True, slots=True) +class Response: + description: str = "" + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"description": self.description} + if self.content: + d["content"] = {k: v.to_dict() for k, v in self.content.items()} + return d + + +@dataclass(frozen=True, slots=True) +class Parameter: + name: str + location: Literal["query", "header", "cookie", "path"] = "query" + required: bool = True + description: str = "" + schema: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"name": self.name, "in": self.location, "required": self.required} + if self.description: + d["description"] = self.description + if self.schema: + d["schema"] = self.schema + return d + + +@dataclass(frozen=True, slots=True) +class Operation: + summary: str = "" + operation_id: str = "" + description: str = "" + parameters: tuple[Parameter, ...] = () + request_body: RequestBody | None = None + responses: dict[str, Response] = field(default_factory=dict) + deprecated: bool = False + tags: tuple[str, ...] = () + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.summary: + d["summary"] = self.summary + if self.operation_id: + d["operationId"] = self.operation_id + if self.description: + d["description"] = self.description + if self.parameters: + d["parameters"] = [p.to_dict() for p in self.parameters] + if self.request_body: + rb = self.request_body.to_dict() + if rb: + d["requestBody"] = rb + if self.responses: + d["responses"] = {k: v.to_dict() for k, v in self.responses.items()} + if self.deprecated: + d["deprecated"] = True + if self.tags: + d["tags"] = list(self.tags) + return d + + +@dataclass(frozen=True, slots=True) +class PathItem: + operations: dict[str, Operation] = field(default_factory=dict) + """Keyed by lowercase HTTP method: get, post, put, etc.""" + + def to_dict(self) -> dict[str, Any]: + return {method: op.to_dict() for method, op in self.operations.items()} + + +@dataclass(frozen=True, slots=True) +class Info: + title: str = "API" + description: str = "" + version: str = "0.1.0" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"title": self.title, "version": self.version} + if self.description: + d["description"] = self.description + return d + + +@dataclass(frozen=True, slots=True) +class Tag: + name: str + description: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"name": self.name} + if self.description: + d["description"] = self.description + return d + + +@dataclass(frozen=True, slots=True) +class OpenAPI: + openapi: str = "3.1.0" + info: Info = field(default_factory=Info) + paths: dict[str, PathItem] = field(default_factory=dict) + tags: tuple[Tag, ...] = () + + def add_operation(self, path: str, method: str, operation: Operation) -> OpenAPI: + """Return a new OpenAPI instance with the operation added.""" + new_paths = dict(self.paths) + if path in new_paths: + existing = new_paths[path] + new_ops = dict(existing.operations) + new_ops[method.lower()] = operation + new_paths[path] = PathItem(operations=new_ops) + else: + new_paths[path] = PathItem(operations={method.lower(): operation}) + return replace(self, paths=new_paths) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "openapi": self.openapi, + "info": self.info.to_dict(), + } + if self.paths: + d["paths"] = {path: item.to_dict() for path, item in self.paths.items()} + if self.tags: + d["tags"] = [t.to_dict() for t in self.tags] + return d + + def to_json(self) -> bytes: + return msgspec.json.encode(self.to_dict()) From cddb6c4e88fe81d068cdd0af9371eca4d476e9cf Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Mar 2026 16:33:42 +0000 Subject: [PATCH 030/286] WIP --- localpost/http/openapi/TODO.md | 14 -------------- localpost/spec/__init__.py | 0 2 files changed, 14 deletions(-) delete mode 100644 localpost/http/openapi/TODO.md create mode 100644 localpost/spec/__init__.py diff --git a/localpost/http/openapi/TODO.md b/localpost/http/openapi/TODO.md deleted file mode 100644 index 4d2c6ac..0000000 --- a/localpost/http/openapi/TODO.md +++ /dev/null @@ -1,14 +0,0 @@ -Hey, I'm working on a new Python web framework, very slim and compact. - -The design idea is in DESIGN.md - -What I have currently: -- http/router.py — WIP on the router part, which is an slim layer (like Werkzeug or Starlette), to communicate with the underlying HTTP server -- http/openapi/app.py — WIP on the actual framework logic -- spec/openapi/__init__.py — code taken from defspec Python library, but we need to rework it completely - -Some random thoughts: -- if a op function returns something (like a dataclass), it means that it's Ok[this_dataclass] (because it should be OpResult in the end) -- - -Please take a look at it, take a look at the code, and plan the work need to make it working. diff --git a/localpost/spec/__init__.py b/localpost/spec/__init__.py new file mode 100644 index 0000000..e69de29 From 222617dfcfe6c483206196b2fddd631e366620c6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Mar 2026 17:16:37 +0000 Subject: [PATCH 031/286] WIP on examples --- examples/http/openapi_app_server.py | 20 ++++++++++++++++---- localpost/http/openapi/app.py | 2 ++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/examples/http/openapi_app_server.py b/examples/http/openapi_app_server.py index d1ca5f7..65b6654 100644 --- a/examples/http/openapi_app_server.py +++ b/examples/http/openapi_app_server.py @@ -1,18 +1,30 @@ +from dataclasses import dataclass from wsgiref.simple_server import make_server -from localpost.http.openapi.app import HttpApp +from localpost.http.openapi.app import BadRequest, HttpApp + + +@dataclass() +class Book: + id: str + title: str + author: str + page: int def main(): app = HttpApp() @app.get("/hello/{name}") - def hello(name: str) -> str: + def hello(name: str) -> str | BadRequest[str]: + if name == "Donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" @app.get("/books/{book_id}") - def get_book(book_id: str, page_number: int = 1) -> dict: - return {"id": book_id, "title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "page": page_number} + def get_book(book_id: str, page_number: int = 1) -> Book: + return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien", page=page_number) print("Starting server on http://localhost:8000") print("Try: curl http://localhost:8000/hello/world") diff --git a/localpost/http/openapi/app.py b/localpost/http/openapi/app.py index f12ac0a..60b1004 100644 --- a/localpost/http/openapi/app.py +++ b/localpost/http/openapi/app.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import inspect import json import threading From a279417ad9b1299b1adfc0da8887f53645ed00e1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 5 Mar 2026 17:32:10 +0000 Subject: [PATCH 032/286] feat: OpenAPI viewer --- examples/http/openapi_app_server.py | 5 ++- localpost/http/openapi/_docs.py | 59 +++++++++++++++++++++++++++++ localpost/http/openapi/app.py | 26 +++++++++++-- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 localpost/http/openapi/_docs.py diff --git a/examples/http/openapi_app_server.py b/examples/http/openapi_app_server.py index 65b6654..1aef0ea 100644 --- a/examples/http/openapi_app_server.py +++ b/examples/http/openapi_app_server.py @@ -17,7 +17,7 @@ def main(): @app.get("/hello/{name}") def hello(name: str) -> str | BadRequest[str]: - if name == "Donald": + if name.lower() == "donald": return BadRequest("Sorry, you are not welcome here") return f"Hello, {name}!" @@ -29,6 +29,9 @@ def get_book(book_id: str, page_number: int = 1) -> Book: print("Starting server on http://localhost:8000") print("Try: curl http://localhost:8000/hello/world") print("Try: curl http://localhost:8000/openapi.json") + print("Docs: http://localhost:8000/docs (Swagger UI)") + print("Docs: http://localhost:8000/docs/redoc (ReDoc)") + print("Docs: http://localhost:8000/docs/scalar (Scalar)") with make_server("", 8000, app.wsgi) as server: server.serve_forever() diff --git a/localpost/http/openapi/_docs.py b/localpost/http/openapi/_docs.py new file mode 100644 index 0000000..b153f2b --- /dev/null +++ b/localpost/http/openapi/_docs.py @@ -0,0 +1,59 @@ +""" +Built-in doc UI HTML templates (loaded from CDN). +""" + +SWAGGER_HTML = """\ + + + + + + Swagger UI + + + +
+ + + +""" + +REDOC_HTML = """\ + + + + + + ReDoc + + + + + + +""" + +SCALAR_HTML = """\ + + + + + + Scalar API Reference + + +
+ + + +""" diff --git a/localpost/http/openapi/app.py b/localpost/http/openapi/app.py index 60b1004..c0b4f43 100644 --- a/localpost/http/openapi/app.py +++ b/localpost/http/openapi/app.py @@ -11,6 +11,7 @@ import msgspec import localpost.spec.openapi as openapi_spec +from localpost.http.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML from localpost.http.router import RequestCtx, RequestHandler, Response, Router from localpost.http.uritemplate import URITemplate @@ -353,8 +354,7 @@ def _create_router(self) -> Router: else: paths[target_key][op.method] = op.as_handler() - # Built-in /openapi.json route - openapi_template = URITemplate.parse("/openapi.json") + # Built-in routes app_ref = self def openapi_handler(ctx: RequestCtx) -> Response: @@ -364,7 +364,27 @@ def openapi_handler(ctx: RequestCtx) -> Response: body=[app_ref.docs.to_json()], ) - paths[openapi_template] = {HTTPMethod.GET: openapi_handler} + def _html_response(html: str) -> Response: + body = html.encode() + return Response( + status_code=200, + headers={"Content-Type": "text/html; charset=utf-8", "Content-Length": str(len(body))}, + body=[body], + ) + + def swagger_handler(_: RequestCtx) -> Response: + return _html_response(SWAGGER_HTML) + + def redoc_handler(_: RequestCtx) -> Response: + return _html_response(REDOC_HTML) + + def scalar_handler(_: RequestCtx) -> Response: + return _html_response(SCALAR_HTML) + + paths[URITemplate.parse("/openapi.json")] = {HTTPMethod.GET: openapi_handler} + paths[URITemplate.parse("/docs")] = {HTTPMethod.GET: swagger_handler} + paths[URITemplate.parse("/docs/redoc")] = {HTTPMethod.GET: redoc_handler} + paths[URITemplate.parse("/docs/scalar")] = {HTTPMethod.GET: scalar_handler} return Router(paths=paths) def register(self, op: FluentPathOp) -> FluentPathOp: From a81f3da7038ca7ab2f1972987155efd0650fdadb Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Mar 2026 17:18:16 +0000 Subject: [PATCH 033/286] WIP --- examples/http/openapi_app_server.py | 40 -- .../http => examples}/openapi/__init__.py | 0 examples/openapi/app.py | 80 ++++ localpost/http/openrpc/__init__.py | 13 - localpost/http/router.py | 40 +- localpost/http/uritemplate.py | 41 --- localpost/{http => }/openapi/DESIGN.md | 0 localpost/{spec => openapi}/__init__.py | 0 localpost/{http => }/openapi/_docs.py | 0 localpost/{http => }/openapi/app.py | 165 ++++++++- localpost/openapi/converters.py | 5 + localpost/openapi/msgspec.py | 0 localpost/openapi/pydantic.py | 38 ++ .../openapi/__init__.py => openapi/spec.py} | 0 localpost/openapi/sse.py | 29 ++ localpost/scopes.py | 50 +++ pyproject.toml | 25 +- uv.lock | 347 +++++++++--------- 18 files changed, 569 insertions(+), 304 deletions(-) delete mode 100644 examples/http/openapi_app_server.py rename {localpost/http => examples}/openapi/__init__.py (100%) create mode 100644 examples/openapi/app.py delete mode 100644 localpost/http/openrpc/__init__.py delete mode 100644 localpost/http/uritemplate.py rename localpost/{http => }/openapi/DESIGN.md (100%) rename localpost/{spec => openapi}/__init__.py (100%) rename localpost/{http => }/openapi/_docs.py (100%) rename localpost/{http => }/openapi/app.py (73%) create mode 100644 localpost/openapi/converters.py create mode 100644 localpost/openapi/msgspec.py create mode 100644 localpost/openapi/pydantic.py rename localpost/{spec/openapi/__init__.py => openapi/spec.py} (100%) create mode 100644 localpost/openapi/sse.py create mode 100644 localpost/scopes.py diff --git a/examples/http/openapi_app_server.py b/examples/http/openapi_app_server.py deleted file mode 100644 index 1aef0ea..0000000 --- a/examples/http/openapi_app_server.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from wsgiref.simple_server import make_server - -from localpost.http.openapi.app import BadRequest, HttpApp - - -@dataclass() -class Book: - id: str - title: str - author: str - page: int - - -def main(): - app = HttpApp() - - @app.get("/hello/{name}") - def hello(name: str) -> str | BadRequest[str]: - if name.lower() == "donald": - return BadRequest("Sorry, you are not welcome here") - - return f"Hello, {name}!" - - @app.get("/books/{book_id}") - def get_book(book_id: str, page_number: int = 1) -> Book: - return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien", page=page_number) - - print("Starting server on http://localhost:8000") - print("Try: curl http://localhost:8000/hello/world") - print("Try: curl http://localhost:8000/openapi.json") - print("Docs: http://localhost:8000/docs (Swagger UI)") - print("Docs: http://localhost:8000/docs/redoc (ReDoc)") - print("Docs: http://localhost:8000/docs/scalar (Scalar)") - with make_server("", 8000, app.wsgi) as server: - server.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/localpost/http/openapi/__init__.py b/examples/openapi/__init__.py similarity index 100% rename from localpost/http/openapi/__init__.py rename to examples/openapi/__init__.py diff --git a/examples/openapi/app.py b/examples/openapi/app.py new file mode 100644 index 0000000..e3ed0b6 --- /dev/null +++ b/examples/openapi/app.py @@ -0,0 +1,80 @@ +import random +from collections.abc import Generator, Iterator +from dataclasses import dataclass +from typing import Annotated, Literal +from wsgiref.simple_server import make_server + +from localpost.http.openapi.app import BadRequest, FromPath, HttpApp, NotFound, TooManyRequests + + +@dataclass() +class Book: + id: str + title: str + author: str + + +@dataclass() +class BookPage: + book_id: str + number: int + content: str + + +@dataclass() +class User: + name: str + role: Literal["admin", "user"] + + +@op_filter +def limit_requests(client_id) -> None | TooManyRequests[str]: + if random.random() < 0.5: + return TooManyRequests("Too many requests", headers={"Retry-After": "1"}) + return None + + +def get_book(book_id: str, req, param) -> Book | NotFound[str]: + if book_id != "00a7a2d4-18e4-11f1-899b-d33838f3bef0": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien") + + +def main(): + app = HttpApp() + + @app.get("/hello/{name}") + def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + + return f"Hello, {name}!" + + @limit_requests() + @app.get("/books/{book_id}") + def get_book(book_id: str, page_number: int = 1) -> Book: + return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien") + + @app.get("/books/{book_id}/num_pages") + def count_pages(book_id: Annotated[Book, FromPath()]) -> int: + return 377 + + # A generator is automatically transformed into an EventStream response (SSE), + # so Generator[BookPage] = Ok(EventStream[BookPage]) + @app.get("/books/{book_id}/pages") + def get_book_pages(book_id: str) -> Generator[BookPage]: + for page_number in range(1, 378): + yield BookPage(book_id=book_id, number=page_number, content="...") + + print("Starting server on http://localhost:8000") + print("Try: curl http://localhost:8000/hello/world") + print("Try: curl http://localhost:8000/openapi.json") + print("Docs: http://localhost:8000/docs (Swagger UI)") + print("Docs: http://localhost:8000/docs/redoc (ReDoc)") + print("Docs: http://localhost:8000/docs/scalar (Scalar)") + with make_server("", 8000, app.wsgi) as server: + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/localpost/http/openrpc/__init__.py b/localpost/http/openrpc/__init__.py deleted file mode 100644 index ac055ff..0000000 --- a/localpost/http/openrpc/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# TODO https://www.open-rpc.org/ - -# TODO JSON-RPC over HTTP - -# See https://github.com/microsoft/vs-streamjsonrpc?tab=readme-ov-file#streamjsonrpc - -# See https://github.com/unum-cloud/UCall - -# See https://github.com/smagafurov/fastapi-jsonrpc - -# ??? — multiple apps on the same server... -# OK, just mount to HTTPContext - diff --git a/localpost/http/router.py b/localpost/http/router.py index 46a420f..091686b 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -1,15 +1,51 @@ from __future__ import annotations +import re from collections.abc import Callable, Iterable, Mapping from contextlib import ExitStack from dataclasses import dataclass, field from http import HTTPMethod -from typing import final +from typing import Self, final from urllib.parse import parse_qs from localpost._utils import NOT_SET from localpost.http.config import DEFAULT_BUFFER_SIZE -from localpost.http.uritemplate import URITemplate + +_VAR_PATTERN = re.compile(r"\{([^}]+)\}") + + +@final +@dataclass(frozen=True, slots=True) +class URITemplate: + """Basic URI Template (RFC 6570) implementation, just the first level from the spec.""" + + template: str + variable_names: tuple[str, ...] + _regex: re.Pattern[str] + + @classmethod + def parse(cls, template: str) -> Self: + variable_names: list[str] = [] + regex_parts: list[str] = [] + last_end = 0 + for m in _VAR_PATTERN.finditer(template): + # Escape literal text between variables + regex_parts.append(re.escape(template[last_end : m.start()])) + var_name = m.group(1) + variable_names.append(var_name) + regex_parts.append(f"(?P<{var_name}>[^/]+)") + last_end = m.end() + regex_parts.append(re.escape(template[last_end:])) + pattern = re.compile("^" + "".join(regex_parts) + "$") + return cls( + template=template, + variable_names=tuple(variable_names), + _regex=pattern, + ) + + def match(self, uri: str) -> dict[str, str] | None: + m = self._regex.match(uri) + return m.groupdict() if m else None @final diff --git a/localpost/http/uritemplate.py b/localpost/http/uritemplate.py deleted file mode 100644 index c8c1ac0..0000000 --- a/localpost/http/uritemplate.py +++ /dev/null @@ -1,41 +0,0 @@ -import re -from dataclasses import dataclass -from typing import Self, final - -_VAR_PATTERN = re.compile(r"\{([^}]+)\}") - - -@final -@dataclass(frozen=True, slots=True) -class URITemplate: - """Basic URI Template (RFC 6570) implementation, just the first level from the spec.""" - - template: str - variable_names: tuple[str, ...] - _regex: re.Pattern[str] - - @classmethod - def parse(cls, template: str) -> Self: - variable_names: list[str] = [] - regex_parts: list[str] = [] - last_end = 0 - for m in _VAR_PATTERN.finditer(template): - # Escape literal text between variables - regex_parts.append(re.escape(template[last_end : m.start()])) - var_name = m.group(1) - variable_names.append(var_name) - regex_parts.append(f"(?P<{var_name}>[^/]+)") - last_end = m.end() - regex_parts.append(re.escape(template[last_end:])) - pattern = re.compile("^" + "".join(regex_parts) + "$") - return cls( - template=template, - variable_names=tuple(variable_names), - _regex=pattern, - ) - - def match(self, uri: str) -> dict[str, str] | None: - m = self._regex.match(uri) - if m is None: - return None - return m.groupdict() diff --git a/localpost/http/openapi/DESIGN.md b/localpost/openapi/DESIGN.md similarity index 100% rename from localpost/http/openapi/DESIGN.md rename to localpost/openapi/DESIGN.md diff --git a/localpost/spec/__init__.py b/localpost/openapi/__init__.py similarity index 100% rename from localpost/spec/__init__.py rename to localpost/openapi/__init__.py diff --git a/localpost/http/openapi/_docs.py b/localpost/openapi/_docs.py similarity index 100% rename from localpost/http/openapi/_docs.py rename to localpost/openapi/_docs.py diff --git a/localpost/http/openapi/app.py b/localpost/openapi/app.py similarity index 73% rename from localpost/http/openapi/app.py rename to localpost/openapi/app.py index c0b4f43..9ed4eaa 100644 --- a/localpost/http/openapi/app.py +++ b/localpost/openapi/app.py @@ -6,26 +6,82 @@ from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass from http import HTTPMethod -from typing import Annotated, ParamSpec, Protocol, Self, TypeVar, final, get_args, get_origin +from types import UnionType +from typing import Annotated, Any, ParamSpec, Protocol, Self, TypeVar, Union, final, get_args, get_origin import msgspec -import localpost.spec.openapi as openapi_spec -from localpost.http.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML -from localpost.http.router import RequestCtx, RequestHandler, Response, Router -from localpost.http.uritemplate import URITemplate +import localpost.openapi.spec as openapi_spec +from localpost.http.router import RequestCtx, RequestHandler, Response, Router, URITemplate +from localpost.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML P = ParamSpec("P") R = TypeVar("R") FluentOpDecorator = Callable[[Callable[P, R]], Callable[P, R]] +class OpFilter(Protocol): + # def __call__(...) -> None | OpResult: ... + # def __call__(...) -> None | OpResult: ... + + def update_doc(self, doc: openapi_spec.OpenAPI, op: openapi_spec.Operation | None = None): ... + + +class HttpBasicAuth(OpFilter): + def handle_req(auth_details: Annotated[bytes, FromHeader("Authorization")]) -> None | Unauthorized: + pass # TODO Implement + + def update_doc(self, doc: openapi_spec.OpenAPI, op: openapi_spec.Operation | None = None): + pass # TODO Add details to app's OpenAPI spec + + +class HttpBearerAuth(OpFilter): + def __init__(self, format: str = "JWT"): + self.format = format + + # So it can be used as a decorator + def __call__(self, f): + pass + + def handle_req(auth_details: Annotated[bytes, FromHeader("Authorization")]) -> None | Unauthorized: + pass # TODO Implement + + def update_doc(self, doc: openapi_spec.OpenAPI, op: openapi_spec.Operation | None = None): + pass # TODO Add details to app's OpenAPI spec + + +class OpenIDConnectAuth: + pass # TODO Implement, later + + +class BodyConverter(Protocol): + def __call__(self, req: RequestCtx) -> Any: ... + + def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI): ... + + +class ResultConverter(Protocol): + def __call__(self, res: Any) -> Iterable[bytes]: ... + + def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI): ... + + +# Params: +# - cache req body? Def NO +class BodyConverterFactory(Protocol): + def __call__(self, param: inspect.Parameter) -> None | BodyConverter: ... + + +class ResultConverterFactory(Protocol): + def __call__(self, return_annotation: type[Any]) -> None | ResultConverter: ... + + # --- OpResult hierarchy --- -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, eq=False, slots=True) class OpResult: - code: int + status_code: int headers: Mapping[str, str] body: object @@ -36,30 +92,69 @@ def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | N class Ok[T](OpResult): + _status_code = 200 + _description = "Successful response" + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): OpResult.__init__(self, 200, body, headers=headers) class Created[T](OpResult): + _status_code = 201 + _description = "Created" + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): OpResult.__init__(self, 201, body, headers=headers) class NoContent(OpResult): + _status_code = 204 + _description = "No Content" + def __init__(self, /, *, headers: dict[str, str] | None = None): OpResult.__init__(self, 204, None, headers=headers) class BadRequest[T](OpResult): + _status_code = 400 + _description = "Bad Request" + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): OpResult.__init__(self, 400, body, headers=headers) +class Unauthorized[T](OpResult): + _status_code = 401 + _description = "Unauthorized" + + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 401, body, headers=headers) + + +class Forbidden[T](OpResult): + _status_code = 403 + _description = "Forbidden" + + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 403, body, headers=headers) + + class NotFound[T](OpResult): + _status_code = 404 + _description = "Not Found" + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): OpResult.__init__(self, 404, body, headers=headers) +class TooManyRequests[T](OpResult): + _status_code = 429 + _description = "Too Many Requests" + + def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): + OpResult.__init__(self, 429, body, headers=headers) + + # --- Protocols --- @@ -84,7 +179,7 @@ def __call__(self, result: OpResult, /) -> Response: headers: dict[str, str] = dict(result.headers) if result.body is None: - return Response(status_code=result.code, headers=headers, body=[]) + return Response(status_code=result.status_code, headers=headers, body=[]) if isinstance(result.body, bytes): body_bytes = result.body @@ -100,7 +195,7 @@ def __call__(self, result: OpResult, /) -> Response: headers.setdefault("Content-Type", "application/json") headers["Content-Length"] = str(len(body_bytes)) - return Response(status_code=result.code, headers=headers, body=[body_bytes]) + return Response(status_code=result.status_code, headers=headers, body=[body_bytes]) @final @@ -212,6 +307,49 @@ def resolve(request: RequestCtx) -> object: return resolve +def _extract_responses(return_annotation) -> dict[str, openapi_spec.Response]: + """Build OpenAPI responses dict from a function's return type annotation.""" + if return_annotation is inspect.Parameter.empty: + return {"200": openapi_spec.Response(description="Successful response")} + + # Decompose Union types (str | BadRequest[str], Union[str, BadRequest[str]]) + origin = get_origin(return_annotation) + if origin is Union or origin is UnionType: + members = get_args(return_annotation) + else: + members = (return_annotation,) + + responses: dict[str, openapi_spec.Response] = {} + has_success = False + + for member in members: + # Unwrap generic OpResult subclasses like BadRequest[str] → BadRequest + member_origin = get_origin(member) + cls = member_origin if member_origin is not None else member + + if isinstance(cls, type) and issubclass(cls, OpResult): + code = str(cls._status_code) + description = cls._description + responses[code] = openapi_spec.Response( + description=description, + content={"application/json": openapi_spec.MediaType(schema={"type": "string"})}, + ) + if cls._status_code < 400: + has_success = True + else: + # Plain return type (str, Book, etc.) → 200 + has_success = True + responses["200"] = openapi_spec.Response( + description="Successful response", + content={"application/json": openapi_spec.MediaType(schema={"type": "string"})}, + ) + + if not has_success: + responses["200"] = openapi_spec.Response(description="Successful response") + + return responses + + def _extract_resolver_factory(annotation) -> ArgResolverFactory | None: """Extract an ArgResolverFactory from an Annotated type, if present.""" if get_origin(annotation) is Annotated: @@ -315,16 +453,13 @@ def docs(self) -> openapi_spec.OpenAPI: name=arg_name, location="query", required=True, schema={"type": "string"} ) ) + return_annotation = inspect.signature(op.target).return_annotation + responses = _extract_responses(return_annotation) operation = openapi_spec.Operation( summary=op.target.__doc__ or f"{op.method.value} {op.path}", operation_id=f"{op.method.value.lower()}_{op.path.replace('/', '_').strip('_')}", parameters=tuple(params), - responses={ - "200": openapi_spec.Response( - description="Successful response", - content={"application/json": openapi_spec.MediaType(schema={"type": "string"})}, - ) - }, + responses=responses, ) spec = spec.add_operation(op.path, op.method.value, operation) return spec diff --git a/localpost/openapi/converters.py b/localpost/openapi/converters.py new file mode 100644 index 0000000..b40947e --- /dev/null +++ b/localpost/openapi/converters.py @@ -0,0 +1,5 @@ +""" +Converters for built-in types: numbers, strings, UUIDs, datatime types. +""" + +# TODO diff --git a/localpost/openapi/msgspec.py b/localpost/openapi/msgspec.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/openapi/pydantic.py b/localpost/openapi/pydantic.py new file mode 100644 index 0000000..e33de6a --- /dev/null +++ b/localpost/openapi/pydantic.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import inspect +from collections.abc import Collection, Iterable +from typing import Any, Protocol + +from pydantic import BaseModel, Field, TypeAdapter + +import localpost.spec.openapi as openapi_spec +from localpost.http.openapi.app import BadRequest, OpResult +from localpost.http.router import RequestCtx, Response + + +class PydanticBodyConverter: + def __init__(self, model: type[BaseModel]): + self.model = model + + def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI) -> None: + pass # TODO Implement, add schema + + def __call__(self, req: RequestCtx) -> BaseModel | BadRequest[str]: + return self.model.model_validate_json(req.body()) + + +class PydanticResultConverter: + def __init__(self, model: type[BaseModel]): + self.model = model + + def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI) -> None: + pass # TODO Implement, add schema + + def __call__(self, res: OpResult, req: RequestCtx) -> Response: + body = res.body + assert isinstance(body, BaseModel), f"Expected {self.model.__name__}, got {type(body).__name__}" + # TODO Handle PydanticSerializationError + # TODO Do it via TypeAdapter, to get bytes directly + # return [res.model_dump_json().encode()] + return Response(res.status_code, res.headers, [body.model_dump_json().encode()]) diff --git a/localpost/spec/openapi/__init__.py b/localpost/openapi/spec.py similarity index 100% rename from localpost/spec/openapi/__init__.py rename to localpost/openapi/spec.py diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py new file mode 100644 index 0000000..5fd0768 --- /dev/null +++ b/localpost/openapi/sse.py @@ -0,0 +1,29 @@ +from collections.abc import Callable, Iterator +from dataclasses import dataclass + +import msgspec + +import localpost.spec.openapi as openapi_spec +from localpost.http.openapi.app import OpResult +from localpost.http.router import RequestCtx, Response + + +class Event[T](msgspec.Struct, eq=False, omit_defaults=True): + data: T + type: str | None = None + id: str | None = None + retry: int | None = None + + +@dataclass(frozen=True, eq=False, slots=True) +class EventStream[T]: + source: Iterator[T] | Iterator[Event[T]] + event_data_converter: Callable[[T], str] + + +class EventStreamResultConverter: + def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI) -> None: + pass # TODO Implement, add response content-type, etc. + + def __call__(self, res: OpResult, req: RequestCtx) -> Response: + pass # TODO Implement (set SSE headers, run the iterator, etc.) diff --git a/localpost/scopes.py b/localpost/scopes.py new file mode 100644 index 0000000..f0e5d30 --- /dev/null +++ b/localpost/scopes.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass, field +from typing import cast, final + + +@contextmanager +def create_scope(): + with ExitStack() as exit_stack: + yield Scope(exit_stack) + + +@dataclass(frozen=True, eq=False, slots=True) +class NamedTypeEntry: + type: type + name: str | None + value: object + + def __call__(self, key: type, name: str | None = None) -> None | object: + if self.type == key and self.name == name: + return self.value + return None + + +@final +@dataclass(frozen=True, eq=False, slots=True) +class Scope: + exit_stack: ExitStack + resolvers: list[Callable[[type, str | None], None | object]] = field(default_factory=list) + + # Kinda like "deffer" in Go or Zig + def add(self, val, /, *, name: str | None = None) -> object: + res = self.exit_stack.enter_context(val) if inspect.isgenerator(val) else val + key = type(res) + self.resolvers.append(NamedTypeEntry(key, name, res)) + return res + + def get[T](self, key: type[T], /, *, name: str | None = None, default: T | None = None) -> T | None: + for resolver in self.resolvers: + if result := resolver(key, name) is not None: + return cast(T, result) + return default + + def __getitem__[T](self, key: type[T]) -> T: + if result := self.get(key): + return cast(T, result) + raise KeyError(f"{key} cannot be resolved") diff --git a/pyproject.toml b/pyproject.toml index fdd80ee..43904eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,12 +55,13 @@ scheduler = [ # Better name?.. "humanize >=3.0,<5.0", "pytimeparse2 ~=1.6", ] -http-rest = [ +http-server = [ "h11 ~=0.16", -# "uritemplate ~=4.2", # "werkzeug ~=3.1", # "multipart", -# "python-multipart", +] +http-openapi = [ + "msgspec ~=0.19", ] sqs = [ "botocore ~=1.38", # Sync SQS client (default) @@ -72,10 +73,6 @@ kafka = [ nats = [ "nats-py ~=2.8", ] -#rabbitmq = [ -# "aio-pika ~=9.1", -# "pika ~=1.3", -#] pubsub = [ "google-cloud-pubsub ~=2.28", ] @@ -108,10 +105,8 @@ dev-consumers = [ "grpcio-tools ~=1.68", ] dev-http = [ - "punq ~=0.7", "flask ~=3.1", # "defspec ~=0.5", # Replaced by our own implementation - "msgspec ~=0.20", "pydantic ~=2.11", "a2wsgi", ] @@ -142,12 +137,12 @@ tests = [ "pytest-xdist", "setproctitle", # For pytest-xdist, to have descriptive process names ] -unit-tests = [ +tests-unit = [ "pytest-mock ~=3.14", "pytest-cov ~=7.0", "coverage[toml] ~=7.6", ] -integration-tests = [ +tests-integration = [ "boto3 ~=1.38", "testcontainers[google,localstack,nats] ~=4.10", ] @@ -215,7 +210,6 @@ select = [ "LOG", # flake8-logging "RUF", # Ruff-specific ] - ignore = [ "ASYNC109", # Async function definition with a `timeout` parameter "RUF022", # `__all__` is not sorted @@ -239,6 +233,11 @@ ignore = [ "TRY400", # Use `logging.exception` instead of `logging.error` "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` ] +per-file-ignores = { + "examples/*" = [ + "T201", + ] +} [tool.uv] build-backend.module-root = "" @@ -254,5 +253,5 @@ python_files = [ "*.py", ] markers = [ - "integration: Consumers (SQS, Kafka, NATS, RabbitMQ) integration tests", + "integration: Consumers (SQS, Kafka,..) integration tests", ] diff --git a/uv.lock b/uv.lock index 924fe66..0e66e2e 100644 --- a/uv.lock +++ b/uv.lock @@ -67,27 +67,27 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.8" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] name = "aws-lambda-powertools" -version = "3.24.0" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/33/d8666a4fc8bae7c783e3dafa9bd4ca463080a8ef83d264a2379662e1313f/aws_lambda_powertools-3.24.0.tar.gz", hash = "sha256:9f86959c4aeac9669da799999aae5feac7a3a86e642b52473892eaa4273d3cc3", size = 704516, upload-time = "2026-01-05T12:30:38.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/ac/683ec02ceb4cc05bbb4bbef84f719508d02f9648de5d3055a25c133cfd14/aws_lambda_powertools-3.25.0.tar.gz", hash = "sha256:5d9c4bdfad1de7976e4ccf26410725aba17c47f081c84311eb2da16a00f75efb", size = 770414, upload-time = "2026-03-04T11:02:23.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/11/0602c8c31fc48e77ed370279ccef1a258dedcbfad1a9dba8e39b7c5df367/aws_lambda_powertools-3.24.0-py3-none-any.whl", hash = "sha256:9c9002856f61b86f49271a9d7efa0dad322ecd22719ddc1c6bb373e57ee0421a", size = 849835, upload-time = "2026-01-05T12:30:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/e7ef654154454c4ce45a535308895336a5540972e1608808446b0ab0cbb7/aws_lambda_powertools-3.25.0-py3-none-any.whl", hash = "sha256:295467bfbc546b7b6a26d298cedcd06b04eb2cf96eb32e138126a47d761b7de1", size = 917936, upload-time = "2026-03-04T11:02:21.675Z" }, ] [[package]] @@ -159,30 +159,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.59" +version = "1.42.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/4e/499cb52aaee9468c346bcc1158965e24e72b4e2a20052725b680e0ac949b/boto3-1.42.59.tar.gz", hash = "sha256:6c4a14a4eb37b58a9048901bdeefbe1c529638b73e8f55413319a25f010ca211", size = 112725, upload-time = "2026-02-27T20:25:33.228Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/2a/33d5d4b16fd97dfd629421ebed2456392eae1553cc401d9f86010c18065e/boto3-1.42.63.tar.gz", hash = "sha256:cd008cfd0d7ea30f1c5e22daf0998c55b7c6c68cb68eea05110e33fe641173d5", size = 112778, upload-time = "2026-03-06T22:47:55.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/c0/22d868b9408dc5a33935a72896ec8d638b2766c459668d1b37c3e5ac2066/boto3-1.42.59-py3-none-any.whl", hash = "sha256:7a66e3e8e2087ea4403e135e9de592e6d63fc9a91080d8dac415bb74df873a72", size = 140557, upload-time = "2026-02-27T20:25:31.774Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/f1d8d2b24871d3d0ccb2cbd0b0cb64a3396d439384bd9643d2c25c641b84/boto3-1.42.63-py3-none-any.whl", hash = "sha256:d502a89a0acc701692ae020d15981f2a82e9eb3485acc651cfd0cf1a3afe79ee", size = 140554, upload-time = "2026-03-06T22:47:53.463Z" }, ] [[package]] name = "botocore" -version = "1.42.59" +version = "1.42.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/ae/50fb33bdf1911c216d50f98d989dd032a506f054cf829ebd737c6fa7e3e6/botocore-1.42.59.tar.gz", hash = "sha256:5314f19e1da8fc0ebc41bdb8bbe17c9a7397d87f4d887076ac8bdef972a34138", size = 14950271, upload-time = "2026-02-27T20:25:20.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/eb/a1c042f6638ada552399a9977335a6de2668a85bf80bece193c953531236/botocore-1.42.63.tar.gz", hash = "sha256:1fdfc33cff58d21e8622cf620ba2bba3cff324557932aaf935b5374e4610f059", size = 14965362, upload-time = "2026-03-06T22:47:44.158Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/df/9d52819e0d804ead073d53ab1823bc0f0cb172a250fba31107b0b43fbb04/botocore-1.42.59-py3-none-any.whl", hash = "sha256:d2f2ff7ecc31e86ef46b5daee112cfbca052c13801285fb23af909f7bff5b657", size = 14619293, upload-time = "2026-02-27T20:25:17.455Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/17a2d3b94658bb999c6aee7bba6c76b271905debf0c8c8e6ac63ca8491bc/botocore-1.42.63-py3-none-any.whl", hash = "sha256:83f39d04f2b316bdfc59a3cac2d12238bde7126ac99d9a57d910dbd86d58c528", size = 14639889, upload-time = "2026-03-06T22:47:39.347Z" }, ] [[package]] @@ -199,11 +199,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.1" +version = "7.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/5c/3b882b82e9af737906539a2eafb62f96a229f1fa80255bede0c7b554cbc4/cachetools-7.0.3.tar.gz", hash = "sha256:8c246313b95849964e54a909c03b327a87ab0428b068fac10da7b105ca275ef6", size = 37187, upload-time = "2026-03-05T21:00:57.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/573185481c50a8841331f54ddae44e4a3469c46aa0b397731c53a004369a/cachetools-7.0.3-py3-none-any.whl", hash = "sha256:c128ffca156eef344c25fcd08a96a5952803786fa33097f5f2d49edf76f79d53", size = 13907, upload-time = "2026-03-05T21:00:56.486Z" }, ] [[package]] @@ -274,59 +274,59 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, ] [[package]] @@ -352,29 +352,25 @@ wheels = [ [[package]] name = "confluent-kafka" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/d0/1f5055331fa660225de6829b143e6f083913f0a96481134a91390bad62c1/confluent_kafka-2.13.0.tar.gz", hash = "sha256:eff7a4391a9e6d4a33f0c05d0935b200a7463834f1f5d6e6253be318f910babd", size = 273621, upload-time = "2026-01-05T10:25:08.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/cc/6bf9e5b3ee4bfdb39d3fcc7efabc9f577aa51e9e20139adc8d10e61593a5/confluent_kafka-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a69263f22a8c53c7d55067e7795ed49d22c374b9473df91982816a0448e6d242", size = 3631367, upload-time = "2026-01-05T10:23:48.076Z" }, - { url = "https://files.pythonhosted.org/packages/10/3d/c299df69885be6fdfc8b105b8106b2b4c73c745fa608cf579eb7e14a3da9/confluent_kafka-2.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0945c7f529e66a18aa19135aa18bfdc239ca6c0f6df6ca9b05a793d9b76c1c4b", size = 3191073, upload-time = "2026-01-05T10:23:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/82/2e/854aedcd9c9042491c1fcafaadc03a3b0e357ddc23044781d02920b46ea2/confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:321037a64c02acb13b5bde193b461c0514dca236a9f5236c847a3240313c297f", size = 3720530, upload-time = "2026-01-05T10:23:51.957Z" }, - { url = "https://files.pythonhosted.org/packages/1d/4a/210f0e1f8e77956ed1296c8b9bac2093413c1669ea0e923f590f2827aa1a/confluent_kafka-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d7a71ca8fd42d3eefa22eb202e7fc8a419e0fd4e3b59862918c21f4d1074f0c5", size = 3977296, upload-time = "2026-01-05T10:23:56.743Z" }, - { url = "https://files.pythonhosted.org/packages/b0/bc/d51f48200bb7bec521289c0b70691ea73f91aa76529b1bff807bcbf89b12/confluent_kafka-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:37dddb1b92829b8862bc4fbce07789a79b73aa31eca413f3db187721a09975ff", size = 4093802, upload-time = "2026-01-05T10:23:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/77/bd/c2ab440b4c4847a37b21e9623bbeaf17982ca45595ad62b8435a26d680f7/confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:0af90b3c566786017a01693da0ec4a876ca14cf37bc6164872652a6cf2702453", size = 3195849, upload-time = "2026-01-05T10:24:00.854Z" }, - { url = "https://files.pythonhosted.org/packages/6a/40/a25a5895cf522bada81fc7ba6c0ad29219843206ede4e8d2138b7b095652/confluent_kafka-2.13.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f25d05604dd92e9de72707582dded53aeb4737ef2e2c097a3ca08650200fc446", size = 3634806, upload-time = "2026-01-05T10:24:05.496Z" }, - { url = "https://files.pythonhosted.org/packages/fd/46/db30d27184ac8fb673ca469d576ef582def0b613a69456631734f7a5e267/confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:74ddf5ec7fa6058221a619c850f44bdbe8d969d7ed6efe8abdc857d2e233df20", size = 3720848, upload-time = "2026-01-05T10:24:07.831Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f8/5b940a080ab71fc3c585839eae0c26341a749a7ebc001fca0276aff9df40/confluent_kafka-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f8d1d00397f3f32a1bcf4604d4164bf75838bd009e1e28282a7ae25e16814ea4", size = 3977677, upload-time = "2026-01-05T10:24:09.988Z" }, - { url = "https://files.pythonhosted.org/packages/e1/cf/f9f979c08cfe1b8fd9203b329fc5c8c410e948f01272434bc1ce0c6334a5/confluent_kafka-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9d1fd035e2c47c4db5fe9b0f59a28fe2f2f1012887290dd0ea7d46741f686e99", size = 4153481, upload-time = "2026-01-05T10:24:12.167Z" }, - { url = "https://files.pythonhosted.org/packages/06/3f/c2120c002f85c5401d8ea29558acf041ad968b33cfca83916a7c0a740d53/confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:d448537147a33dd8c17656732989ddfe1d4a25a40bcb5f59bc63dc0a5041dd83", size = 3195696, upload-time = "2026-01-05T10:24:14.422Z" }, - { url = "https://files.pythonhosted.org/packages/35/b9/da4ef8fca4cbc76b6040a97a92085c09c0f23c42424989a2d68cec42c8d6/confluent_kafka-2.13.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:1325585f9fc283c32c30df4226178dc89cf43d00f5c240e1f77ddedc94573690", size = 3634632, upload-time = "2026-01-05T10:24:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/09/ed/c7d5cc3c57aec126ffb12ffa5f4584acd264446a4117db3ad2dd69e47afe/confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:266bbea18ce99f6e77ce0e9a118f353447c8705792ef5745eabcc5c6db08794a", size = 3720650, upload-time = "2026-01-05T10:24:18.657Z" }, - { url = "https://files.pythonhosted.org/packages/c3/2b/0a93b63a46b2ceaac3de92d494e32aab21bec398b40ac89108bbaa6894c3/confluent_kafka-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7a373a1a3dd8e02dd218946583e951791480921fd777faeaf601c2834e2a6c0d", size = 3977364, upload-time = "2026-01-05T10:24:23.677Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/80ea6872421c4e30e033e035e5bdcf44c054993d5721623841c59b5f04c1/confluent_kafka-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:da956b2141d9f425dbfc3cf1c244ef6d0633b83fc5ceada6f496099258e63a68", size = 4271874, upload-time = "2026-01-05T10:24:26.265Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/e6fe016bea63343010f485a1ecebfdc00d9b6e95ef6fe52a1d7793c0339c/confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:fa354e9fb95e26545decd429477072ea3a98a2e7acac11d09156772e06f14680", size = 3194480, upload-time = "2026-01-05T10:24:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/fb/54/c9668f214f2e736e51e2874053fe1ebcb8784425fefcfd6fd0d384dc5dcf/confluent_kafka-2.13.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:e256dc3a993bf5fde0fa4a1b6a5b72e3521cedbc9dc4d7a65f159afa4b72e5b4", size = 3632866, upload-time = "2026-01-05T10:24:30.689Z" }, - { url = "https://files.pythonhosted.org/packages/13/f5/83e989962077003d91ba249158c7a1e21696604c301b3680fcbb427005d0/confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:eb7988038b7f13ea490af93b9165ed40a3f385fb91e99ce8dacee8890e36e48a", size = 3718956, upload-time = "2026-01-05T10:24:33.035Z" }, - { url = "https://files.pythonhosted.org/packages/b8/84/1aaa5cfe1695f02d11e02ef39f180567780348b1f3e1161575f002a207fe/confluent_kafka-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cfd8f011b0b0a109f8747312dba6cee45b39fb53fc7d53f28813bce84a91228d", size = 3976235, upload-time = "2026-01-05T10:24:35.122Z" }, +version = "2.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/38/f5855cae6d328fa66e689d068709f91cbbd4d72e7e03959998bd43ac6b26/confluent_kafka-2.13.2.tar.gz", hash = "sha256:619d10d1d77c9821ba913b3e42a33ade7f889f3573c7f3c17b57c3056e3310f5", size = 276068, upload-time = "2026-03-02T12:53:31.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/d3/a845c6993a728b8b6bdce9b500d15c3ec3663cd95d2bbf9c1b8cfd519b17/confluent_kafka-2.13.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e259c0d2b9a7e16211b45404f62869502246ac3d03e35a1f80720fd09d262457", size = 3635348, upload-time = "2026-03-02T12:52:50.927Z" }, + { url = "https://files.pythonhosted.org/packages/ab/22/1cb998f7b3ee613d5b29f4b98e4a7539776eb0819b89d7c3cdd19a685692/confluent_kafka-2.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:77ea4ceccdbb67498787b7c02cc329c32417bb730e9383f46c74eb9c5851763c", size = 3194667, upload-time = "2026-03-02T12:52:53.468Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/8a1b12321068e8ae126e62600a55d7a1872f969e1de5ec7f602e0dba8394/confluent_kafka-2.13.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a64a8967734f865f54b766553d63a40f17081cd3d2c6cfe6d3217aa7494d88fb", size = 3724453, upload-time = "2026-03-02T12:52:55.187Z" }, + { url = "https://files.pythonhosted.org/packages/5c/06/3effa66c59a69e17cc48c69ae2533699f4321fac1b46741f2e4b1aefb1e7/confluent_kafka-2.13.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e4cb7d112463ec15a01a3f0e0d20392cda6e46156a6439fcaaad2267696f5cde", size = 3980919, upload-time = "2026-03-02T12:52:56.852Z" }, + { url = "https://files.pythonhosted.org/packages/98/22/f76a8b85fad652b4d5c0a0259c8f7bb66393d2d9f277631c754c9ebe5092/confluent_kafka-2.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:44496777ff0104421b8f4bb269728e8a5e772c09f34ae813bc47110e0172ebe0", size = 4097817, upload-time = "2026-03-02T12:52:58.831Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/ae9a7f21ba49e55b1be18362cefd7648e4aceb588e254f9ee5edb97fcf44/confluent_kafka-2.13.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:02702808dd3cfd91f117fbf17181da2a95392967e9f946b1cbdc5589b36e39d1", size = 3199459, upload-time = "2026-03-02T12:53:00.614Z" }, + { url = "https://files.pythonhosted.org/packages/12/94/ccd92f9a3bb685b265bc83ede699651aa526502e4988e906e710d3f24cd3/confluent_kafka-2.13.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:7dc3a2da92638c077bbabb07058f1938078b42a89f0bbfdcb852d4289c2de27e", size = 3638743, upload-time = "2026-03-02T12:53:01.951Z" }, + { url = "https://files.pythonhosted.org/packages/ba/66/048925a546a0f8e9134a89441aa4ae663892839004668d1039d5f9dd8d45/confluent_kafka-2.13.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f3e6d010ad38447a48e0f9fab81edd4d2fd0b5f5a79ab475c30347689e35c6e6", size = 3724788, upload-time = "2026-03-02T12:53:03.775Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a6/53faa22d52d8fc6f58424d4b6c2c32855198fcb776ea8b4404ee50b58c72/confluent_kafka-2.13.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9161865d8246eb77d1c30233a315bdad96145af783981877664532fa212f56be", size = 3981324, upload-time = "2026-03-02T12:53:05.339Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1578956d3721645b24c22b0e9ceeab794fffc197a32074a7572bfbc07ca7/confluent_kafka-2.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:931233798306b859f4870ec58e3951a2bd32d14ef29f944f56892851b0aafab0", size = 4157492, upload-time = "2026-03-02T12:53:06.977Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4c/46f09fcc1dedebb0a0884b072ddde74be8a8bcfb5e3fbc912bd2c8255e6f/confluent_kafka-2.13.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:9cb0d6820107deca1823d68b96831bd982d0a11c4e6bcf0a12e8040192c48a8f", size = 3199305, upload-time = "2026-03-02T12:53:08.351Z" }, + { url = "https://files.pythonhosted.org/packages/37/3c/56d052bdedb7d4bb56bf993dc017df4434e2eb5e73745f22d0beb3c32999/confluent_kafka-2.13.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:b31d94bca493d84927927d1bdd59e1b6d3d921019a657f99f0c8cc5da8c85311", size = 3638586, upload-time = "2026-03-02T12:53:10.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/7a/2bfc9e9341d50813674d3db6425ac4cb963764bffdf589774f94c0cbf852/confluent_kafka-2.13.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f09adb42fb898a0b3a88b02e77bee472e93f758258945386c77864016b4e4efc", size = 3724554, upload-time = "2026-03-02T12:53:11.682Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bb/0d0cdad1763044f3e06bea52c3332256b17f3e64c04a8214ee217fc68ab0/confluent_kafka-2.13.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:fa3be1fe231e06b2c7501fa3641b30ea90ea17be79ca89806eef22ff34ed106c", size = 3981002, upload-time = "2026-03-02T12:53:13.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/65/361ace93de20ab5d83dc0d108389b29f4549f478e0b8aa0f19baf597c0f0/confluent_kafka-2.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:a8d1e0721de378034ecc928b47238272b56bf20af5dd504233bcb93ce07a38a6", size = 4275836, upload-time = "2026-03-02T12:53:14.703Z" }, ] [package.optional-dependencies] @@ -579,20 +575,20 @@ wheels = [ [[package]] name = "fast-depends" -version = "3.0.6" +version = "3.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/63/3524df7f936537481fafbe9c5428cc26cc06a30cce2810ce6994331a9a19/fast_depends-3.0.6.tar.gz", hash = "sha256:6a2a11645db40d4850f1300f98ffad93dc09fc95750009359930e8ff03299cc5", size = 18283, upload-time = "2026-02-26T16:03:50.331Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/6d/787a21ca8043a8fdb737cf28f645e94a46fc30b44a31de54573299156bad/fast_depends-3.0.8.tar.gz", hash = "sha256:896b16f79a512b6ea1df721b0aa1708a192a06f964be6597e01fcf5412559101", size = 18382, upload-time = "2026-03-02T19:54:28.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/04/26ff5acc1804ecefd5ec8a351c43f23ba1c6315e9d4dbedd86bd16579e77/fast_depends-3.0.6-py3-none-any.whl", hash = "sha256:b793eee88061cf830679cdb42b5c26b39b0c5fd687d6112a17c46a9efb128a86", size = 25419, upload-time = "2026-02-26T16:03:49.165Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1d/e4843e4eeb65f51447b8c22d200d12d8f94f27c97e77bb7162515cc8d61f/fast_depends-3.0.8-py3-none-any.whl", hash = "sha256:4c52c8a3907bca46d43e70e4364d6d016872d9a3aae4bc0c1c85e72e0a6a21c7", size = 25507, upload-time = "2026-03-02T19:54:27.594Z" }, ] [[package]] name = "fastapi" -version = "0.134.0" +version = "0.135.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -601,9 +597,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] [[package]] @@ -659,16 +655,16 @@ grpc = [ [[package]] name = "google-auth" -version = "2.48.0" +version = "2.49.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/59/7371175bfd949abfb1170aa076352131d7281bd9449c0f978604fc4431c3/google_auth-2.49.0.tar.gz", hash = "sha256:9cc2d9259d3700d7a257681f81052db6737495a1a46b610597f4b8bafe5286ae", size = 333444, upload-time = "2026-03-06T21:53:06.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", size = 240676, upload-time = "2026-03-06T21:52:38.304Z" }, ] [[package]] @@ -723,14 +719,14 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.73.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] [package.optional-dependencies] @@ -1046,7 +1042,10 @@ azure-servicebus = [ cron = [ { name = "croniter" }, ] -http-rest = [ +http-openapi = [ + { name = "msgspec" }, +] +http-server = [ { name = "h11" }, ] kafka = [ @@ -1086,8 +1085,6 @@ dev-hosting-services = [ dev-http = [ { name = "a2wsgi" }, { name = "flask" }, - { name = "msgspec" }, - { name = "punq" }, { name = "pydantic" }, ] dev-otel = [ @@ -1110,16 +1107,16 @@ examples = [ { name = "fastapi-slim" }, { name = "psycopg", extra = ["binary", "pool"] }, ] -integration-tests = [ - { name = "boto3" }, - { name = "testcontainers", extra = ["google", "localstack", "nats"] }, -] tests = [ { name = "pytest" }, { name = "pytest-xdist" }, { name = "setproctitle" }, ] -unit-tests = [ +tests-integration = [ + { name = "boto3" }, + { name = "testcontainers", extra = ["google", "localstack", "nats"] }, +] +tests-unit = [ { name = "coverage" }, { name = "pytest-cov" }, { name = "pytest-mock" }, @@ -1136,12 +1133,13 @@ requires-dist = [ { name = "confluent-kafka", marker = "extra == 'kafka'", specifier = "~=2.4" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "google-cloud-pubsub", marker = "extra == 'pubsub'", specifier = "~=2.28" }, - { name = "h11", marker = "extra == 'http-rest'", specifier = "~=0.16" }, + { name = "h11", marker = "extra == 'http-server'", specifier = "~=0.16" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, + { name = "msgspec", marker = "extra == 'http-openapi'", specifier = "~=0.19" }, { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http-rest", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] +provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] [package.metadata.requires-dev] dev = [ @@ -1163,8 +1161,6 @@ dev-hosting-services = [ dev-http = [ { name = "a2wsgi" }, { name = "flask", specifier = "~=3.1" }, - { name = "msgspec", specifier = "~=0.20" }, - { name = "punq", specifier = "~=0.7" }, { name = "pydantic", specifier = "~=2.11" }, ] dev-otel = [ @@ -1185,16 +1181,16 @@ examples = [ { name = "fastapi-slim", specifier = "~=0.128" }, { name = "psycopg", extras = ["binary", "pool"], specifier = "~=3.2" }, ] -integration-tests = [ - { name = "boto3", specifier = "~=1.38" }, - { name = "testcontainers", extras = ["google", "localstack", "nats"], specifier = "~=4.10" }, -] tests = [ { name = "pytest", specifier = "~=9.0" }, { name = "pytest-xdist" }, { name = "setproctitle" }, ] -unit-tests = [ +tests-integration = [ + { name = "boto3", specifier = "~=1.38" }, + { name = "testcontainers", extras = ["google", "localstack", "nats"], specifier = "~=4.10" }, +] +tests-unit = [ { name = "coverage", extras = ["toml"], specifier = "~=7.6" }, { name = "pytest-cov", specifier = "~=7.0" }, { name = "pytest-mock", specifier = "~=3.14" }, @@ -1265,16 +1261,16 @@ wheels = [ [[package]] name = "msal" -version = "1.35.0" +version = "1.35.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/ec/52e6c9ad90ad7eb3035f5e511123e89d1ecc7617f0c94653264848623c12/msal-1.35.0.tar.gz", hash = "sha256:76ab7513dbdac88d76abdc6a50110f082b7ed3ff1080aca938c53fc88bc75b51", size = 164057, upload-time = "2026-02-24T10:58:28.415Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/aa/5a646093ac218e4a329391d5a31e5092a89db7d2ef1637a90b82cd0b6f94/msal-1.35.1.tar.gz", hash = "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656", size = 165658, upload-time = "2026-03-04T23:38:51.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/26/5463e615de18ad8b80d75d14c612ef3c866fcc07c1c52e8eac7948984214/msal-1.35.0-py3-none-any.whl", hash = "sha256:baf268172d2b736e5d409689424d2f321b4142cab231b4b96594c86762e7e01d", size = 120082, upload-time = "2026-02-24T10:58:27.219Z" }, + { url = "https://files.pythonhosted.org/packages/96/86/16815fddf056ca998853c6dc525397edf0b43559bb4073a80d2bc7fe8009/msal-1.35.1-py3-none-any.whl", hash = "sha256:8f4e82f34b10c19e326ec69f44dc6b30171f2f7098f3720ea8a9f0c11832caa3", size = 119909, upload-time = "2026-03-04T23:38:50.452Z" }, ] [[package]] @@ -1340,45 +1336,45 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/9c/3ab1db90f32da200dba332658f2bbe602369e3d19f6aba394031a42635be/opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c", size = 6147, upload-time = "2025-12-11T13:32:40.309Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/37/b6708e0eff5c5fb9aba2e0ea09f7f3bcbfd12a592d2a780241b5f6014df7/opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a", size = 6152, upload-time = "2026-03-04T14:17:23.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/6c/bdc82a066e6fb1dcf9e8cc8d4e026358fe0f8690700cc6369a6bf9bd17a7/opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe", size = 7019, upload-time = "2025-12-11T13:32:19.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fc/aea77c28d9f3ffef2fdafdc3f4a235aee4091d262ddabd25882f47ce5c5f/opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d", size = 7023, upload-time = "2026-03-04T14:17:03.612Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1389,14 +1385,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1407,14 +1403,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.60b1" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1422,14 +1418,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, ] [[package]] name = "opentelemetry-instrumentation-botocore" -version = "0.60b1" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1437,23 +1433,23 @@ dependencies = [ { name = "opentelemetry-propagator-aws-xray" }, { name = "opentelemetry-semantic-conventions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/52/f4f3ffae9b83fc388ea4eb520d32605a8047216b523ce75a5766510e4464/opentelemetry_instrumentation_botocore-0.60b1.tar.gz", hash = "sha256:198e7d74b78b1b19ea47a5f2191171227557c37bfc4f49076958d129f848a8ed", size = 120933, upload-time = "2025-12-11T13:36:51.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/7f/2acb6d4e8cc70726cfbb24885a653ef5fdc3ac2d0a26ca3e6bff58416c2e/opentelemetry_instrumentation_botocore-0.61b0.tar.gz", hash = "sha256:49dee5f48d133b3bfadaa29bcbff28225899dc495ca14a9c1bb60b74fb4cd84d", size = 121236, upload-time = "2026-03-04T14:20:26.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/6d/0d37f8756f6a27f3ecc6956d78813993bacce037aa799b447b6fdea5af96/opentelemetry_instrumentation_botocore-0.60b1-py3-none-any.whl", hash = "sha256:b5cb1e267545cdd96a81a1bef690b1d00c20497ac4d815ef7c79fb3c572d6fc6", size = 38136, upload-time = "2025-12-11T13:35:50.59Z" }, + { url = "https://files.pythonhosted.org/packages/69/43/0ed1ac34b52a62b3694c22aee5c7a9c5b6120938d927d79de53ad9edb0cf/opentelemetry_instrumentation_botocore-0.61b0-py3-none-any.whl", hash = "sha256:b019d2f60562265319e64a75c1c9e7bad31c00b2a568def3cc5c57eaf9a06057", size = 38359, upload-time = "2026-03-04T14:19:19.028Z" }, ] [[package]] name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.60b1" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/16/eb5aa8f3a1f155fbc132ceecb475645635e221a2542ac914df101aedb345/opentelemetry_instrumentation_confluent_kafka-0.60b1.tar.gz", hash = "sha256:8272ecfa6ee07fefb21bcddcb1942d05d903e2b1516cb1c463a3e9d7f8dcdad1", size = 11606, upload-time = "2025-12-11T13:36:54.099Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/0e/76db8f0f41678da1c50926367eaf7c3a984cb849235e83b699ad801341b0/opentelemetry_instrumentation_confluent_kafka-0.61b0.tar.gz", hash = "sha256:06c7a13b0ccfa77701d90df19e755e92f3ae3ca6f88c0b1cca64700b4ea1af78", size = 11900, upload-time = "2026-03-04T14:20:29.172Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/7a/ce8286a308d749e8060889a3f2ce8a49a69b44ac405937f3bc0a209606e8/opentelemetry_instrumentation_confluent_kafka-0.60b1-py3-none-any.whl", hash = "sha256:e25ca438f0a65266e5e580062a53e24df4e8d7e1902634f6c56122db8408e5bc", size = 12623, upload-time = "2025-12-11T13:35:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a2/0a79dcd319a7a8de6aaf91ba1ca1d995a51e63ca0fbf545ae0eeec993a81/opentelemetry_instrumentation_confluent_kafka-0.61b0-py3-none-any.whl", hash = "sha256:2ce36aa3287b870c30b62584090360d591d882f179da07d729f009d442799f16", size = 12810, upload-time = "2026-03-04T14:19:23.468Z" }, ] [[package]] @@ -1470,41 +1466,41 @@ wheels = [ [[package]] name = "opentelemetry-proto" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.39.1" +version = "1.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.60b1" +version = "0.61b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] [[package]] @@ -1646,15 +1642,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] -[[package]] -name = "punq" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/f1/c31e86ce1ab85837d8e6a6868163bae3afaef01c49d45f1bfa479c1a5b90/punq-0.7.0.tar.gz", hash = "sha256:bb7a6cc75a2e7d51b861b0e11f4830a12617b3ee33dbced9ce2be6a98ba39d63", size = 7254, upload-time = "2023-11-06T10:45:19.945Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/6a/c89602766d4416371eb801c3de89eda45fcea4435836ba7c01d1da88d47a/punq-0.7.0-py3-none-any.whl", hash = "sha256:7e0aca446c36a73674ec3fc078ec5e90e7a172ee349537d075ff20d73c3b1810", size = 7719, upload-time = "2023-11-06T10:45:18.118Z" }, -] - [[package]] name = "pyasn1" version = "0.6.2" @@ -1863,11 +1850,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -1881,11 +1868,11 @@ wheels = [ [[package]] name = "pytz" -version = "2025.2" +version = "2026.1.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] [[package]] @@ -1945,15 +1932,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, ] [[package]] @@ -2119,24 +2106,24 @@ wheels = [ [[package]] name = "types-awscrt" -version = "0.31.2" +version = "0.31.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/24/5497a611f32cbaf4b9e1af35f56463e8f02e198ec513b68cb59a63f5a446/types_awscrt-0.31.2.tar.gz", hash = "sha256:dc79705acd24094656b8105b8d799d7e273c8eac37c69137df580cd84beb54f6", size = 18190, upload-time = "2026-02-16T02:33:53.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/26/0aa563e229c269c528a3b8c709fc671ac2a5c564732fab0852ac6ee006cf/types_awscrt-0.31.3.tar.gz", hash = "sha256:09d3eaf00231e0f47e101bd9867e430873bc57040050e2a3bd8305cb4fc30865", size = 18178, upload-time = "2026-03-08T02:31:14.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/3d/21a2212b5fcef9e8e9f368403885dc567b7d31e50b2ce393efad3cd83572/types_awscrt-0.31.2-py3-none-any.whl", hash = "sha256:3d6a29c1cca894b191be408f4d985a8e3a14d919785652dd3fa4ee558143e4bf", size = 43340, upload-time = "2026-02-16T02:33:52.109Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" }, ] [[package]] name = "types-boto3-lite" -version = "1.42.59" +version = "1.42.63" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/18/5114672bd851492d342805deba9a794232ad8d9064508bd2e14fae37c8c7/types_boto3_lite-1.42.59.tar.gz", hash = "sha256:c27a43010d10fbe03a677cabc7426d2f116bff4cc7029f5b60d643fdd81b8a93", size = 73146, upload-time = "2026-02-27T20:47:45.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/b0/78bcd5d86a10758d3b33c91b6e29fcdd8a5e2a28145fd330e71d51c142e9/types_boto3_lite-1.42.63.tar.gz", hash = "sha256:ca94617a4a876373ac3c5d8d403dac75ecfcb782f927a94ece57ad2964d04a79", size = 73248, upload-time = "2026-03-06T22:54:49.448Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/e5/325adf7a61064315e9f7706e8afb118a2be157c94b6ced9ca05af526d62a/types_boto3_lite-1.42.59-py3-none-any.whl", hash = "sha256:4b18da86cbbbb440d9af1d3aac28650f66af0a068022f4be0357b71ed97c15ee", size = 42714, upload-time = "2026-02-27T20:47:39.184Z" }, + { url = "https://files.pythonhosted.org/packages/72/b5/48d12ffb4673ce03724a09f7b9e78c26989fffbdb097c6b6247afdc4238c/types_boto3_lite-1.42.63-py3-none-any.whl", hash = "sha256:bf8910de2037ec93f02d50ccc018ba4c8fc3f28c79ebbe50685bd939e6968138", size = 42751, upload-time = "2026-03-06T22:54:41.578Z" }, ] [package.optional-dependencies] From 8a40d99a339932fb4d12e866bda7605352b3c752 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Mar 2026 17:34:37 +0000 Subject: [PATCH 034/286] fix: scheduler --- localpost/scheduler/_scheduler.py | 94 ++++++++++++++++++------------- pyproject.toml | 7 +-- tests/scheduler/scheduler.py | 8 +-- 3 files changed, 62 insertions(+), 47 deletions(-) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 9b58e17..518956d 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -5,27 +5,20 @@ import logging import math from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, ExitStack +from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager from typing import Any, Generic, Protocol, TypeAlias, TypeVar, cast, final -from anyio import BrokenResourceError, WouldBlock, to_thread +from anyio import BrokenResourceError, WouldBlock, create_task_group, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( + Event, + EventView, MemoryStream, Result, def_full_name, is_async_callable, -) - -# from localpost.flow import AsyncHandlerManager, FlowHandlerManager, HandlerDecorator, ensure_async_handler_manager -from localpost.hosting import ( - AbstractHost, - ExposedService, - ExposedServiceBase, - HostedService, - HostedServiceSet, - ServiceLifetimeManager, + start_task_soon, ) T = TypeVar("T") @@ -34,6 +27,7 @@ DecF = TypeVar("DecF", bound=Callable[..., Any]) TaskHandler: TypeAlias = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] +HandlerDecorator: TypeAlias = Callable[[Any], Any] logger = logging.getLogger("localpost.scheduler") @@ -141,13 +135,9 @@ def __rshift__(self, decorator: HandlerDecorator) -> ScheduledTaskTemplate[T]: n._handler_decorators = self._handler_decorators + (decorator,) return n - def resolve_handler(self, task: Task[T, Any]) -> AsyncHandlerManager[T]: - if not self._handler_decorators: - return task - hm = FlowHandlerManager(lambda: task) - for decorator in self._handler_decorators: - hm = decorator(hm) - return ensure_async_handler_manager(hm) + def resolve_handler(self, task: Task[T, Any]) -> AbstractAsyncContextManager[Callable[[T], Awaitable[None]]]: + # TODO: Support handler decorators (flow module) + return task @property def tf(self) -> TriggerFactory[T]: @@ -157,38 +147,47 @@ def tf(self) -> TriggerFactory[T]: return tf -class ScheduledTask(ExposedService, Protocol[T, R]): +class ScheduledTask(Protocol[T, R]): + @property + def shutting_down(self) -> EventView: ... + @property def task(self) -> Task[T, R]: ... @final -class _ScheduledTask(Generic[T, R], ExposedServiceBase): +class _ScheduledTask(Generic[T, R]): def __init__(self, task: Task[T, R], tf: TriggerFactory[T]): - super().__init__() self.task = task self._trigger_factory = tf tpl = ScheduledTaskTemplate.ensure(tf) self._handler = tpl.resolve_handler(task) + self._shutting_down: EventView = Event() # Placeholder, resolved in run() def __repr__(self): return f"ScheduledTask({self.name!r})" + @property + def shutting_down(self) -> EventView: + return self._shutting_down + @property def name(self) -> str: return self.task.name - async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: - self._lifetime = service_lifetime + async def run(self, shutting_down: EventView) -> None: + self._shutting_down = shutting_down trigger = self._trigger_factory(self) async with trigger as t_events, self._handler as message_handler: - service_lifetime.set_started() async for t_event in t_events: await message_handler(t_event) logger.debug(f"{self!r} trigger is completed") logger.debug(f"{self!r} is done") + async def __call__(self, shutting_down: EventView) -> None: + return await self.run(shutting_down) + Trigger: TypeAlias = AbstractAsyncContextManager[AsyncIterator[T]] TriggerFactory: TypeAlias = Callable[ @@ -209,12 +208,12 @@ async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: def scheduled_task( tf: TriggerFactory[T], /, *, name: str | None = None -) -> Callable[[TaskHandler[T, R] | Task[T, R]], ScheduledTask[T, R]]: +) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: """ Schedule a task with the given trigger. """ - def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> ScheduledTask[T, R]: + def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> _ScheduledTask[T, R]: t = func if isinstance(func, Task) else Task(func) if name: t.name = name @@ -223,34 +222,30 @@ def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> ScheduledTask[T, R]: return _decorator -class Scheduler(AbstractHost): +class Scheduler: """ - Custom host type, tailored to schedule periodic tasks. + Manages a collection of periodic tasks. - If you need to combine multiple different services together (like a Kafka consumer and a scheduled task), use the - generic Host instead. + Can be used standalone via `aserve()`, or integrated with hosting via `as_service()`. """ def __init__(self, name: str = "scheduler"): - super().__init__() self._name = name - self._scheduled_tasks: list[ScheduledTask[Any, Any]] = [] + self._scheduled_tasks: list[_ScheduledTask[Any, Any]] = [] + self._shutting_down: Event | None = None @property def name(self) -> str: return self._name - def _prepare_for_run(self) -> HostedService: - return HostedService(HostedServiceSet(*self._scheduled_tasks)) - def task( self, tf: TriggerFactory[T], /, *, name: str | None = None - ) -> Callable[[TaskHandler[T, R] | Task[T, R] | ScheduledTask[T, R]], ScheduledTask[T, R]]: + ) -> Callable[[TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]], _ScheduledTask[T, R]]: """ Schedule a task with the given trigger. """ - def _decorator(func: TaskHandler[T, R] | Task[T, R] | ScheduledTask[T, R]): + def _decorator(func: TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]): if isinstance(func, _ScheduledTask): func = func.task st = scheduled_task(tf, name=name)(cast(TaskHandler[T, R] | Task[T, R], func)) @@ -258,3 +253,26 @@ def _decorator(func: TaskHandler[T, R] | Task[T, R] | ScheduledTask[T, R]): return st return _decorator + + def shutdown(self) -> None: + if self._shutting_down: + self._shutting_down.set() + + @asynccontextmanager + async def aserve(self): + """Run all scheduled tasks. Use `shutdown()` or exit the context to stop.""" + shutting_down = self._shutting_down = Event() + if not self._scheduled_tasks: + yield + return + async with create_task_group() as tg: + for st in self._scheduled_tasks: + start_task_soon(tg, lambda s=st: s.run(shutting_down)) + yield + shutting_down.set() + + async def as_service(self, lt) -> None: + """Run the scheduler as a hosting service (accepts a ServiceLifetime).""" + async with self.aserve(): + lt.set_started() + await lt.shutting_down.wait() diff --git a/pyproject.toml b/pyproject.toml index 43904eb..dc2df88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -233,11 +233,8 @@ ignore = [ "TRY400", # Use `logging.exception` instead of `logging.error` "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` ] -per-file-ignores = { - "examples/*" = [ - "T201", - ] -} +[tool.ruff.lint.per-file-ignores] +"examples/*" = ["T201"] [tool.uv] build-backend.module-root = "" diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py index 4b4156e..56ae4a8 100644 --- a/tests/scheduler/scheduler.py +++ b/tests/scheduler/scheduler.py @@ -5,7 +5,6 @@ import pytest from anyio import fail_after -from localpost.hosting import Host from localpost.scheduler import Scheduler, every, scheduled_task pytestmark = [pytest.mark.anyio, pytest.mark.integration] @@ -20,10 +19,11 @@ def sample_task(): assert callable(sample_task) - host = Host(sample_task) - async with host.aserve(): + scheduler = Scheduler() + scheduler._scheduled_tasks.append(sample_task) + async with scheduler.aserve(): await anyio.sleep(0.5) # "App is working" - host.shutdown() + scheduler.shutdown() assert len(results) == 1 # Only one initial run From 6f00fe8f976a21f78b4d17269387f69ed37dd93d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Mar 2026 17:35:02 +0000 Subject: [PATCH 035/286] chore: Claude --- CLAUDE.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ec22ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LocalPost is a Python async framework providing: +- **Hosting** - App lifecycle management, to orchestrate multiple services +- **In-process task scheduler** - Lightweight scheduler for background tasks +- **Consumers** - Lightweight consumer services for various message brokers (Kafka, SQS, PubSub, in-memory channels, etc.) +- **HTTP Server** - Lightweight HTTP server for sync apps + +Python 3.12+ required. Built on AnyIO for structured concurrency (works with asyncio and Trio). + +## Development Commands + +```bash +just deps # Install all dependencies (uses UV) +just format # Format code with ruff +just types # Check types (PyRight + MyPy) +just type-coverage # Verify public API types with pyright --verifytypes +just tests # Run all tests with coverage +just unit-tests # Unit tests only (exclude integration) +just integration-tests # Integration tests (parallel with pytest-xdist) +just check FILE # Check single file (ruff + pyright + mypy) +``` + +Run a single test: +```bash +pytest tests/path/to/test.py::test_function -v +``` + +## Architecture + +### Module Structure + +``` +localpost/ +├── __init__.py # Entry: Result, debug, __version__ +├── _utils.py # Core utilities (Result, Event, MemoryStream) +├── _debug.py # Debug utilities +├── _otel_utils.py # OpenTelemetry utilities +├── scopes.py # Scope/DI container for resource management +├── threadtools.py # Thread sync primitives (CancellableLock, Channel, Semaphore) +│ +├── hosting/ # App lifecycle management, service orchestration +│ ├── __init__.py # Exports: service, run, serve, current_service, ServiceState, etc. +│ ├── _host.py # Core: ServiceLifetime, ServiceLifetimeView +│ ├── middleware.py # Middleware (shutdown_on_signal, etc.) +│ └── services/ # Hosting adapters (ASGI, gRPC, Hypercorn, Uvicorn) +│ +├── scheduler/ # In-process task scheduler +│ ├── __init__.py # Exports: Scheduler, Task, ScheduledTask, every, after, etc. +│ ├── _scheduler.py # Task and Scheduler implementation +│ ├── _cond.py # Condition/trigger implementations (Every, After) +│ ├── _trigger.py # Trigger decorators (delay, take_first) +│ └── cond/cron.py # Cron expression support +│ +├── consumers/ # Message consumer services +│ ├── channel.py # In-memory channel consumer +│ ├── pubsub.py # Google Cloud Pub/Sub +│ ├── stream.py # AnyIO stream consumer +│ └── stdlib_queue.py # stdlib queue.Queue consumer +│ +├── http/ # HTTP server and routing +│ ├── server.py # WSGI server (h11-based) +│ ├── router.py # Routing (URITemplate, Router, RequestCtx) +│ ├── config.py # Server configuration +│ ├── wsgi.py # WSGI app wrapping +│ └── _service.py # HTTP service integration with hosting +│ +└── openapi/ # OpenAPI/Swagger support + ├── app.py # OpenAPI app builder + ├── spec.py # OpenAPI spec generation + ├── converters.py # Request/response converters + ├── msgspec.py # msgspec serialization + ├── pydantic.py # Pydantic validation + ├── sse.py # Server-Sent Events + └── _docs.py # API documentation UI +``` + +Files prefixed with `_` contain internal implementations. Public APIs are exported through `__init__.py`. + +### Key Patterns + +**Hosting/Services**: Services decorated with `@hosting.service` have lifecycle states (Starting → Running → ShuttingDown → Stopped). Middleware decorators for cross-cutting concerns. Context variable provides service lifetime. + +**Scheduler**: Tasks triggered by conditions (`every`, `after`, `cron`). Triggers composable with operators (`//`, `>>`). Results wrapped in `Result[T]` (Ok/Failure). + +**Consumers**: Generic handler interface (sync/async callables). Stream-based API with message broker abstraction (Kafka, SQS, Pub/Sub, in-memory channels). + +**HTTP/OpenAPI**: WSGI server on h11, URI template routing with path parameter extraction. OpenAPI layer for spec generation, validation (Pydantic/msgspec), and docs UI. + +### Conventions + +- Use AnyIO for async work (structured concurrency) +- **Public API**: Full type annotations, verified with PyRight (`just type-coverage`) +- **Internal API**: Types only where they improve readability + +## Testing + +- Unit tests: `tests/` directory, run with `pytest -m "not integration"` +- Integration tests: Marked with `@pytest.mark.integration`, use testcontainers +- Async tests use `anyio[test]` pytest plugin From 0b54d574f0b3bba93f576bad06e454b31d682481 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 9 Mar 2026 17:50:17 +0000 Subject: [PATCH 036/286] scheduler examples too --- examples/scheduler/app.py | 16 ++---- examples/scheduler/cancellation.py | 18 ++----- examples/scheduler/cron.py | 6 +-- examples/scheduler/failing_tasks.py | 6 +-- examples/scheduler/fastdepends.py | 6 +-- examples/scheduler/finite_task.py | 6 +-- examples/scheduler/serve_sync.py | 2 +- localpost/scheduler/__init__.py | 2 +- localpost/scheduler/_scheduler.py | 76 +++++++++++++++++++++++++++-- tests/scheduler/cond_cron.py | 4 +- 10 files changed, 93 insertions(+), 49 deletions(-) diff --git a/examples/scheduler/app.py b/examples/scheduler/app.py index bfcae25..b4a165b 100755 --- a/examples/scheduler/app.py +++ b/examples/scheduler/app.py @@ -3,16 +3,12 @@ import logging import random -from localpost.flow import skip_first -from localpost.scheduler import Scheduler, after, delay, every, scheduled_task +from localpost.scheduler import Scheduler, after, delay, every, run, take_first scheduler = Scheduler() @scheduler.task(every("3s") // delay((0, 1))) -# @scheduler.task -# @scheduled_task(every("3s") // delay((0, 1))) -# async def task1(_): async def task1(): """ A simple repeating task that returns a random number. @@ -21,21 +17,17 @@ async def task1(): return random.randint(1, 22) -# @scheduler.task(after(task1) >> skip_first(3)) -@scheduled_task(after(task1) >> skip_first(3)) -async def task2(task1_result: str): +@scheduler.task(after(task1) // take_first(3)) +async def task2(task1_result: int): """ A task that runs after task1 and prints its result. """ - task1.shutdown() print(f"task2 here! task1 result: {task1_result}") if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(scheduler)) + exit(run(scheduler)) diff --git a/examples/scheduler/cancellation.py b/examples/scheduler/cancellation.py index 47f852c..9385a73 100755 --- a/examples/scheduler/cancellation.py +++ b/examples/scheduler/cancellation.py @@ -6,19 +6,18 @@ import anyio -from localpost.hosting import hosted_service -from localpost.hosting.middlewares import shutdown_timeout -from localpost.scheduler import every, scheduled_task +from localpost.scheduler import every, run, scheduled_task @scheduled_task(every(timedelta(seconds=3))) async def long_async_task(): print("long_async_task is triggered!") try: - # On a normal shutdown (Ctrl+C), the scheduler will just wait for the current task execution to finish + # On a normal shutdown (Ctrl+C), the scheduler will wait for the trigger to signal stop, + # then the task loop ends gracefully. await anyio.sleep(15) except CancelledError: - # Only in case of forced shutdown (second Ctrl+C (results to Host.stop()) or the shutdown timeout) + # In case of forced shutdown (e.g. second Ctrl+C) print("Forced shutdown detected!") raise finally: @@ -26,16 +25,9 @@ async def long_async_task(): print("long_async_task has finished!") -# host = Host(long_async_task) -# host.root_service //= shutdown_timeout(1) -host = hosted_service(long_async_task) // shutdown_timeout(1) - - if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(host)) + exit(run(long_async_task)) diff --git a/examples/scheduler/cron.py b/examples/scheduler/cron.py index 2843024..a6e948f 100755 --- a/examples/scheduler/cron.py +++ b/examples/scheduler/cron.py @@ -2,7 +2,7 @@ import logging -from localpost.scheduler import delay, scheduled_task +from localpost.scheduler import delay, run, scheduled_task from localpost.scheduler.cond.cron import cron @@ -13,10 +13,8 @@ async def cron_job(): if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(cron_job)) + exit(run(cron_job)) diff --git a/examples/scheduler/failing_tasks.py b/examples/scheduler/failing_tasks.py index 8a10d67..02e8fc8 100755 --- a/examples/scheduler/failing_tasks.py +++ b/examples/scheduler/failing_tasks.py @@ -3,7 +3,7 @@ import logging from datetime import timedelta -from localpost.scheduler import Scheduler, delay, every, take_first +from localpost.scheduler import Scheduler, delay, every, run, take_first scheduler = Scheduler() @@ -21,10 +21,8 @@ def a_sync_task(): if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(scheduler)) + exit(run(scheduler)) diff --git a/examples/scheduler/fastdepends.py b/examples/scheduler/fastdepends.py index 83fa3b9..6ed1c95 100755 --- a/examples/scheduler/fastdepends.py +++ b/examples/scheduler/fastdepends.py @@ -6,7 +6,7 @@ from fast_depends import Depends, inject -from localpost.scheduler import delay, every, scheduled_task +from localpost.scheduler import delay, every, run, scheduled_task def roll_dice(): @@ -24,10 +24,8 @@ def print_task( if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(print_task)) + exit(run(print_task)) diff --git a/examples/scheduler/finite_task.py b/examples/scheduler/finite_task.py index 2f73c08..4e71ba8 100755 --- a/examples/scheduler/finite_task.py +++ b/examples/scheduler/finite_task.py @@ -4,7 +4,7 @@ import random from datetime import timedelta -from localpost.scheduler import delay, every, scheduled_task, take_first +from localpost.scheduler import delay, every, run, scheduled_task, take_first @scheduled_task(every(timedelta(seconds=3)) // take_first(3) // delay((0, 3))) @@ -17,10 +17,8 @@ async def task1(): if __name__ == "__main__": - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(task1)) + exit(run(task1)) diff --git a/examples/scheduler/serve_sync.py b/examples/scheduler/serve_sync.py index 69d1551..f9d39f4 100755 --- a/examples/scheduler/serve_sync.py +++ b/examples/scheduler/serve_sync.py @@ -20,7 +20,7 @@ def main(): time.sleep(1) print("Main thread is running...") except KeyboardInterrupt: - scheduler.shutdown() + pass print("Main thread is done, exiting the app") diff --git a/localpost/scheduler/__init__.py b/localpost/scheduler/__init__.py index 258c0bb..94b6cd0 100644 --- a/localpost/scheduler/__init__.py +++ b/localpost/scheduler/__init__.py @@ -1,5 +1,5 @@ from ._cond import after, after_all, every -from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Scheduler, Task, scheduled_task +from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Scheduler, Task, run, scheduled_task from ._trigger import delay, take_first, trigger_factory_middleware __all__ = [ diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 518956d..80679fc 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -4,21 +4,26 @@ import inspect import logging import math +import threading from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager +from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager, contextmanager from typing import Any, Generic, Protocol, TypeAlias, TypeVar, cast, final -from anyio import BrokenResourceError, WouldBlock, create_task_group, to_thread +import anyio +from anyio import BrokenResourceError, WouldBlock, create_task_group, open_signal_receiver, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( + HANDLED_SIGNALS, Event, EventView, MemoryStream, Result, + choose_anyio_backend, def_full_name, is_async_callable, start_task_soon, + wait_any, ) T = TypeVar("T") @@ -265,14 +270,77 @@ async def aserve(self): if not self._scheduled_tasks: yield return + # The done event is set when all tasks complete naturally (e.g. finite triggers) + done = Event() + + async def _run_task(st: _ScheduledTask[Any, Any], remaining: list[_ScheduledTask[Any, Any]]) -> None: + await st.run(shutting_down) + remaining.remove(st) + if not remaining: + done.set() + + remaining = list(self._scheduled_tasks) async with create_task_group() as tg: for st in self._scheduled_tasks: - start_task_soon(tg, lambda s=st: s.run(shutting_down)) - yield + start_task_soon(tg, lambda s=st: _run_task(s, remaining)) + yield done shutting_down.set() + @contextmanager + def serve(self): + """Run the scheduler in a background thread with its own event loop.""" + stop = threading.Event() + + async def _run_loop(): + async with self.aserve(): + await to_thread.run_sync(stop.wait) + + def _thread_target(): + anyio.run(_run_loop, **choose_anyio_backend()) + + t = threading.Thread(target=_thread_target, daemon=True) + t.start() + try: + yield + finally: + self.shutdown() + stop.set() + t.join() + async def as_service(self, lt) -> None: """Run the scheduler as a hosting service (accepts a ServiceLifetime).""" async with self.aserve(): lt.set_started() await lt.shutting_down.wait() + + +async def _arun(target: Scheduler | _ScheduledTask[Any, Any]) -> None: + """Run a scheduler or single scheduled task with signal handling.""" + if isinstance(target, _ScheduledTask): + scheduler = Scheduler() + scheduler._scheduled_tasks.append(target) + else: + scheduler = target + + async with scheduler.aserve() as done, create_task_group() as tg: + + async def _handle_signals(): + with open_signal_receiver(*HANDLED_SIGNALS) as signals: + async for _ in signals: + logger.info("Shutting down...") + scheduler.shutdown() + break + + tg.start_soon(_handle_signals) + # Wait for either shutdown signal or all tasks completing naturally + await wait_any(done, scheduler._shutting_down) # type: ignore[arg-type] + tg.cancel_scope.cancel() + + +def run(target: Scheduler | _ScheduledTask[Any, Any]) -> int: + """Run a scheduler or single scheduled task until completion or signal. Returns exit code.""" + try: + anyio.run(_arun, target, **choose_anyio_backend()) + except KeyboardInterrupt: + pass + return 0 diff --git a/tests/scheduler/cond_cron.py b/tests/scheduler/cond_cron.py index 75b3407..2debd5a 100644 --- a/tests/scheduler/cond_cron.py +++ b/tests/scheduler/cond_cron.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime from unittest.mock import AsyncMock, Mock, patch import anyio @@ -16,7 +16,7 @@ async def test_cron_trigger(): - base = datetime(2022, 1, 1) + base = datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC) schedule = croniter("*/5 * * * *", base) scheduled_task_tpl = cron(schedule) From a33eb416d7e71b13fe12922b10aba70eb5d0db98 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 09:37:23 +0000 Subject: [PATCH 037/286] WIP --- examples/app_host/__init__.py | 0 examples/app_host/app.py | 84 --------------- examples/app_host/failing_service.py | 46 --------- examples/app_host/http_app.py | 55 ---------- examples/host/channel.py | 41 ++++---- examples/host/finite_service.py | 25 ++--- examples/scheduler/cancellation.py | 4 +- localpost/hosting/__init__.py | 2 + localpost/hosting/_host.py | 26 ++++- sonar-project.properties | 2 +- tests/hosting/app_host.py | 11 -- tests/hosting/host.py | 149 ++++++++++++++++++--------- tests/hosting/hosted_service.py | 90 ++++++++++++++-- tests/hosting/service_middlewares.py | 32 ++++-- tests/hosting/service_ops.py | 46 --------- 15 files changed, 266 insertions(+), 347 deletions(-) delete mode 100644 examples/app_host/__init__.py delete mode 100755 examples/app_host/app.py delete mode 100755 examples/app_host/failing_service.py delete mode 100755 examples/app_host/http_app.py delete mode 100644 tests/hosting/app_host.py delete mode 100644 tests/hosting/service_ops.py diff --git a/examples/app_host/__init__.py b/examples/app_host/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/app_host/app.py b/examples/app_host/app.py deleted file mode 100755 index 96168e0..0000000 --- a/examples/app_host/app.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python - -import time - -import anyio -from anyio import CancelScope - -from localpost.hosting import AppHost, ServiceLifetimeManager -from localpost.hosting.middlewares import shutdown_timeout - -app = AppHost() -app.use(shutdown_timeout(5)) - - -@app.service -def a_sync_service(service_lifetime: ServiceLifetimeManager): - print(f"{a_sync_service.__name__} started") - service_lifetime.set_started() - while not service_lifetime.shutting_down: - print(f"{a_sync_service.__name__} running") - time.sleep(1) - print(f"{a_sync_service.__name__} stopped") - - -@app.service -async def an_async_func(_): - print(f"{an_async_func.__name__} started") - while True: - print(f"{an_async_func.__name__} running") - await anyio.sleep(1) - - -@app.service -async def an_async_service(service_lifetime: ServiceLifetimeManager): - print(f"{an_async_service.__name__} started") - service_lifetime.set_started() - while not service_lifetime.shutting_down: - print(f"{an_async_service.__name__} running") - await anyio.sleep(1) - - -@app.service -async def a_graceful_async_service(service_lifetime: ServiceLifetimeManager): - print(f"{a_graceful_async_service.__name__} started") - with CancelScope() as scope: - service_lifetime.set_started(graceful_shutdown_scope=scope) - while not scope.cancel_called: - print(f"{a_graceful_async_service.__name__} running") - await anyio.sleep(1) - print(f"{a_graceful_async_service.__name__} gracefully shut down") - - -# @host.service() -# async def an_anyio_func(*, task_status: TaskStatus[None] = TASK_STATUS_IGNORED): -# print(f"{an_anyio_func.__name__} started") -# task_status.started() -# while True: -# print(f"{an_anyio_func.__name__} running") -# await anyio.sleep(1) -# -# -# @host.service() -# async def a_graceful_anyio_func(*, task_status: TaskStatus[CancelScope] = TASK_STATUS_IGNORED): -# print(f"{a_graceful_anyio_func.__name__} started") -# with CancelScope() as scope: -# task_status.started(scope) -# while not scope.cancel_called: -# print(f"{a_graceful_anyio_func.__name__} running") -# await anyio.sleep(1) -# print(f"{a_graceful_anyio_func.__name__} gracefully shut down") - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - # app.root_service //= shutdown_timeout(5) - - exit(localpost.run(app)) diff --git a/examples/app_host/failing_service.py b/examples/app_host/failing_service.py deleted file mode 100755 index 12508af..0000000 --- a/examples/app_host/failing_service.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python - -import time - -import anyio - -from localpost.hosting import AppHost, ServiceLifetimeManager - -app = AppHost() - - -@app.service -def a_sync_service(service_lifetime: ServiceLifetimeManager): - print(f"{a_sync_service.__name__} started") - service_lifetime.set_started() - # Do not do infinite loops in a sync service function, as there is no way to interrupt it from the host. - # Always check the service_lifetime.shutting_down event to see if the service should stop. - while not service_lifetime.shutting_down: - print(f"{a_sync_service.__name__} running") - time.sleep(1) - raise RuntimeError("This is a test error from _sync_") - print(f"{a_sync_service.__name__} stopped") - - -@app.service -async def an_async_func(): - print(f"{an_async_func.__name__} started") - try: - while True: - print(f"{an_async_func.__name__} running") - await anyio.sleep(1) - # raise RuntimeError("This is a test error from _async_") - finally: - print(f"{an_async_func.__name__} done") - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - exit(localpost.run(app)) diff --git a/examples/app_host/http_app.py b/examples/app_host/http_app.py deleted file mode 100755 index ed4f283..0000000 --- a/examples/app_host/http_app.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python - -from datetime import timedelta - -import anyio -from fastapi import FastAPI -from starlette.responses import JSONResponse - -from localpost.hosting import AppHost -from localpost.hosting.http import UvicornService -from localpost.scheduler import delay, every, scheduled_task - -app = AppHost() - -http_api = FastAPI() -app.service(UvicornService.for_app(http_api)) - - -@app.service -async def background_job(): - print("Background job started") - try: - while True: - print("Background job running") - await anyio.sleep(1) - finally: - print("Background job done") - - -@app.service -@scheduled_task(every(timedelta(seconds=3)) // delay((1, 5))) -async def heavy_periodic_task(): - print("Some periodic work") - - -@http_api.get("/predict") -async def predict(): - return {"result": "some"} - - -@http_api.get("/health") -async def health_check() -> JSONResponse: - return JSONResponse(app.status) - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - exit(localpost.run(app)) diff --git a/examples/host/channel.py b/examples/host/channel.py index ebcd042..5805c59 100755 --- a/examples/host/channel.py +++ b/examples/host/channel.py @@ -2,39 +2,38 @@ import anyio -from localpost.hosting import Host, hosted_service -from localpost.scheduler import delay, every, scheduled_task, take_first +from localpost.hosting import ServiceLifetime, run_app, service -channel_writer, channel_reader = anyio.create_memory_object_stream[str]() +@service +def channel_example(): + channel_writer, channel_reader = anyio.create_memory_object_stream[str]() -@hosted_service -async def print_channel(): - """ - A hosted service, to read the channel in the background. - """ - async with channel_reader as channel: - async for message in channel: - print(f"Message received: {message}") + async def svc(lt: ServiceLifetime): + async def reader(): + async with channel_reader as ch: + async for message in ch: + print(f"Message received: {message}") + async def writer(): + for i in range(5): + await anyio.sleep(1) + await channel_writer.send(f"hello #{i}") + await channel_writer.aclose() -@scheduled_task(every("3s") // delay((1, 3)) // take_first(3)) -async def scheduled_background_task(): - print("Scheduled work here, writing to the channel...") - await channel_writer.send("hello from the background task!") + lt.tg.start_soon(reader) + lt.tg.start_soon(writer) + lt.set_started() + await lt.shutting_down.wait() - -# Stop printing after the scheduler is done -host = Host(print_channel >> scheduled_background_task) + return svc if __name__ == "__main__": import logging - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(host)) + exit(run_app(channel_example())) diff --git a/examples/host/finite_service.py b/examples/host/finite_service.py index bbfee0f..264583b 100755 --- a/examples/host/finite_service.py +++ b/examples/host/finite_service.py @@ -2,26 +2,27 @@ import time -from localpost.hosting import ServiceLifetimeManager, hosted_service +from localpost.hosting import ServiceLifetime, run_app, service -@hosted_service -def a_sync_service(service_lifetime: ServiceLifetimeManager): - print("Service started") - service_lifetime.set_started() - print("Service running") - time.sleep(5) - print("Service is done") - # The host should also stop after this point, as all the services have stopped +@service +def a_sync_service(): + def svc(lt: ServiceLifetime): + print("Service started") + lt.set_started() + print("Service running") + time.sleep(5) + print("Service is done") + # The host should also stop after this point, as all the services have stopped + + return svc if __name__ == "__main__": import logging - import localpost - logging.basicConfig() logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(localpost.run(a_sync_service)) + exit(run_app(a_sync_service())) diff --git a/examples/scheduler/cancellation.py b/examples/scheduler/cancellation.py index 9385a73..9f847cf 100755 --- a/examples/scheduler/cancellation.py +++ b/examples/scheduler/cancellation.py @@ -1,6 +1,6 @@ #!/usr/bin/env python - import logging +import sys from asyncio import CancelledError from datetime import timedelta @@ -30,4 +30,4 @@ async def long_async_task(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run(long_async_task)) + sys.exit(run(long_async_task)) diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index bf2ff8b..aa77c1a 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -6,6 +6,7 @@ ServiceF, ServiceState, ServiceLifetime, + ServiceLifetimeView, ShuttingDown, Starting, Stopped, @@ -29,6 +30,7 @@ "Running", "ShuttingDown", "ServiceLifetime", + "ServiceLifetimeView", "Stopped", ] diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 4665429..a7fc90d 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -333,7 +333,18 @@ async def _serve_root(svc: ServiceF) -> AsyncIterator[ServiceLifetimeView]: child_lt = ServiceLifetime(portal) tg = portal._task_group tg.start_soon(_run, svc, child_lt) - await child_lt.started + # Wait for either started or stopped (service may fail before starting) + async with create_task_group() as wait_tg: + async def wait_started(): + await child_lt.started + wait_tg.cancel_scope.cancel() + + async def wait_stopped(): + await child_lt.stopped + wait_tg.cancel_scope.cancel() + + wait_tg.start_soon(wait_started) + wait_tg.start_soon(wait_stopped) yield child_lt.view child_lt.view.shutdown() await child_lt.stopped @@ -350,7 +361,18 @@ async def bind_parent_to(csl: ServiceLifetimeView): async with create_task_group() as observe_tg: # Bind the parent lifetime (if the child is stopped, shutdown the parent) observe_tg.start_soon(bind_parent_to, child_lt) - await child_lt.started + # Wait for either started or stopped (service may fail before starting) + async with create_task_group() as wait_tg: + async def wait_started(): + await child_lt.started + wait_tg.cancel_scope.cancel() + + async def wait_stopped(): + await child_lt.stopped + wait_tg.cancel_scope.cancel() + + wait_tg.start_soon(wait_started) + wait_tg.start_soon(wait_stopped) yield child_lt observe_tg.cancel_scope.cancel() child_lt.shutdown() diff --git a/sonar-project.properties b/sonar-project.properties index b9b4520..1c8a1e9 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.projectKey=alexeyshockov_localpost.py sonar.organization=alexeyshockov # Optional -sonar.python.version=3.10 +sonar.python.version=3.12 sonar.sources=localpost sonar.tests=tests sonar.python.coverage.reportPaths=coverage.xml diff --git a/tests/hosting/app_host.py b/tests/hosting/app_host.py deleted file mode 100644 index e4c35c4..0000000 --- a/tests/hosting/app_host.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -pytestmark = pytest.mark.anyio - - -def test_service_decorator_does_not_change_target(): - pass # TODO Implement - - -def test_multiple_middlewares(): - pass # TODO Implement diff --git a/tests/hosting/host.py b/tests/hosting/host.py index 05b0eb0..7579d72 100644 --- a/tests/hosting/host.py +++ b/tests/hosting/host.py @@ -1,77 +1,124 @@ import anyio import pytest -from anyio import CancelScope -import localpost -from localpost._utils import wait_any # noqa -from localpost.hosting import Host, ServiceLifetimeManager +from localpost.hosting import ServiceLifetime, ServiceLifetimeView, run, serve, Starting, Running, ShuttingDown, Stopped pytestmark = pytest.mark.anyio -async def test_host_status(): - async def test_service(lifetime): - shutdown_scope = CancelScope() - lifetime.set_started(graceful_shutdown_scope=shutdown_scope) - with shutdown_scope: - await anyio.sleep_forever() +async def test_serve_and_state_transitions(): + """Test the full lifecycle: starting → running → shutting_down → stopped.""" + states_seen = [] - host = Host(test_service) - assert not host.started - async with host.aserve(): - await host.started - assert host.status # TODO Check - host.shutdown() + async def test_service(lt: ServiceLifetime): + states_seen.append(type(lt.state)) + lt.set_started() + states_seen.append(type(lt.state)) + await lt.shutting_down.wait() + states_seen.append(type(lt.state)) + + async with serve(test_service) as lt: + await lt.started + assert isinstance(lt.state, Running) + lt.shutdown() + await lt.stopped + + assert isinstance(lt.state, Stopped) + assert states_seen == [Starting, Running, ShuttingDown] + + +async def test_serve_yields_lifetime_view(): + async def test_service(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + + async with serve(test_service) as lt: + assert isinstance(lt, ServiceLifetimeView) + lt.shutdown() + await lt.stopped async def test_exit_code_on_error(): - async def failing_service(_): + async def failing_service(lt: ServiceLifetime): + lt.set_started() raise Exception("Test error") - host = Host(failing_service) - async with host.aserve(): - pass + async with serve(failing_service) as lt: + await lt.stopped - assert host.exit_code == 1 + assert lt.exit_code == 1 -async def test_shutdown(): - shutdown_communicated = False - shutdown_reason = "Test shutdown" +async def test_exit_code_on_success(): + async def ok_service(lt: ServiceLifetime): + lt.set_started() - async def test_service(lifetime: ServiceLifetimeManager) -> None: - lifetime.set_started() - await wait_any(anyio.sleep_forever, lifetime.shutting_down) - nonlocal shutdown_communicated - shutdown_communicated = True + async with serve(ok_service) as lt: + await lt.stopped - host = Host(test_service) - async with host.aserve(): - await host.started.wait() - assert not host.shutting_down - host.shutdown(reason=shutdown_reason) - assert host.shutting_down + assert lt.exit_code == 0 - assert shutdown_communicated # Shutdown was communicated to the service - assert host.status # TODO Check +async def test_run(): + async def finite_service(lt: ServiceLifetime): + lt.set_started() + await anyio.sleep(0.05) -async def test_arun(): - async def a_service(_: ServiceLifetimeManager): - await anyio.sleep(0.1) + exit_code = await run(finite_service) + assert exit_code == 0 - with localpost.debug: - host = Host(a_service) - exit_code = await localpost.arun(host) - assert exit_code == 0 +async def test_run_failing(): + async def failing_service(lt: ServiceLifetime): + lt.set_started() + raise Exception("boom") + exit_code = await run(failing_service) + assert exit_code == 1 -def test_run(): - async def a_service(_: ServiceLifetimeManager): - await anyio.sleep(0.1) - with localpost.debug: - host = Host(a_service) - exit_code = localpost.run(host) - assert exit_code == 0 +async def test_shutdown(): + shutdown_communicated = False + + async def test_service(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + nonlocal shutdown_communicated + shutdown_communicated = True + + async with serve(test_service) as lt: + await lt.started + assert not lt.shutting_down + lt.shutdown(reason="Test shutdown") + await lt.stopped + + assert shutdown_communicated + assert isinstance(lt.state, Stopped) + + +async def test_child_service(): + child_started = False + child_stopped = False + + async def child(lt: ServiceLifetime): + nonlocal child_started, child_stopped + lt.set_started() + child_started = True + await lt.shutting_down.wait() + child_stopped = True + + async def parent(lt: ServiceLifetime): + child_lt = lt.start(child) + await child_lt.started + lt.set_started() + await lt.shutting_down.wait() + child_lt.shutdown() + await child_lt.stopped + + async with serve(parent) as lt: + await lt.started + assert child_started + lt.shutdown() + await lt.stopped + + assert child_stopped diff --git a/tests/hosting/hosted_service.py b/tests/hosting/hosted_service.py index 7cca367..1b3a32f 100644 --- a/tests/hosting/hosted_service.py +++ b/tests/hosting/hosted_service.py @@ -1,16 +1,92 @@ +import anyio import pytest -from localpost.hosting import HostedService, hosted_service +from localpost.hosting import ServiceLifetime, service, serve pytestmark = pytest.mark.anyio -async def test_decorator(): - @hosted_service - def simple_sync_service(): - pass +async def test_service_decorator_async(): + @service + def my_service(): + async def svc(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() - assert isinstance(simple_sync_service, HostedService) + return svc + resolved = my_service() + async with serve(resolved) as lt: + await lt.started + lt.shutdown() + await lt.stopped -# TODO Test attributes (name especially, separately) + assert lt.exit_code == 0 + + +async def test_service_decorator_sync(): + work_done = False + + @service + def my_sync_service(): + def svc(lt: ServiceLifetime): + nonlocal work_done + lt.set_started() + lt.view.wait_shutting_down() + work_done = True + + return svc + + resolved = my_sync_service() + async with serve(resolved) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert work_done + + +async def test_service_decorator_context_manager(): + entered = False + exited = False + + @service + async def my_cm_service(): + nonlocal entered, exited + entered = True + yield + exited = True + + resolved = my_cm_service() + async with serve(resolved) as lt: + await lt.started + assert entered + lt.shutdown() + await lt.stopped + + assert exited + + +async def test_service_as_context_manager(): + """_ResolvedService can be used as an async context manager directly.""" + + @service + def my_service(): + async def svc(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + + return svc + + # When used inside another service context + async def parent(lt: ServiceLifetime): + async with my_service() as child_lt: + lt.set_started() + await lt.shutting_down.wait() + child_lt.shutdown() + await child_lt.stopped + + async with serve(parent) as lt: + await lt.started + lt.shutdown() + await lt.stopped diff --git a/tests/hosting/service_middlewares.py b/tests/hosting/service_middlewares.py index 248f79f..28b45bd 100644 --- a/tests/hosting/service_middlewares.py +++ b/tests/hosting/service_middlewares.py @@ -1,18 +1,32 @@ +import anyio import pytest +from localpost.hosting import ServiceLifetime, serve +from localpost.hosting.middleware import start_timeout + pytestmark = pytest.mark.anyio -async def test_shutdown_timeout(): - # HostedService(service_func).use(shutdown_timeout(5)) - pass # TODO Implement +async def test_start_timeout_ok(): + @start_timeout(1.0) + async def fast_starter(lt: ServiceLifetime): + lt.set_started() + await lt.shutting_down.wait() + + async with serve(fast_starter) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 -async def test_start_timeout(): - # HostedService(service_func).use(start_timeout(5)) - pass # TODO Implement +async def test_start_timeout_exceeded(): + @start_timeout(0.1) + async def slow_starter(lt: ServiceLifetime): + await anyio.sleep(10) # Never calls set_started() + async with serve(slow_starter) as lt: + await lt.stopped -async def test_lifespan(): - # HostedService(service_func).use(lifespan(...)) - pass # TODO Implement + assert lt.exit_code == 1 diff --git a/tests/hosting/service_ops.py b/tests/hosting/service_ops.py deleted file mode 100644 index ea115e0..0000000 --- a/tests/hosting/service_ops.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest -from anyio import sleep - -from localpost.hosting import Host, HostedService - -pytestmark = pytest.mark.anyio - - -async def test_combine(): - async def service1(lifetime): - lifetime.set_started() - await lifetime.shutting_down.wait() - - async def service2(lifetime): - lifetime.set_started() - await lifetime.shutting_down.wait() - - async def service3(lifetime): - lifetime.set_started() - await lifetime.shutting_down.wait() - - combined = HostedService(service1) + service2 + service3 - assert isinstance(combined, HostedService) - - host = Host(combined) - assert not host.started - async with host.aserve(): - await host.started - await sleep(0.1) - assert host.status # TODO Check - host.shutdown() - - -async def test_wrap_service(): - # Like HostedService(service_func1) >> service_func2 - pass # TODO Implement - - -async def test_wrap_multiple_services(): - # Like HostedService(service_func1) >> [service_func2, service_func3] - pass # TODO Implement - - -async def test_wrap_empty_set(): - # Like HostedService(service_func1) >> [] - pass # TODO Implement From bfe63e7145579a5345ce74d9d2a5c453e44f07c0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 13:07:04 +0000 Subject: [PATCH 038/286] WIP on DI --- examples/di/__init__.py | 0 examples/di/basic.py | 14 +++++ localpost/_utils.py | 4 +- localpost/di/README.md | 3 + localpost/di/__init__.py | 0 localpost/di/_services.py | 114 ++++++++++++++++++++++++++++++++++++++ localpost/di/flask.py | 24 ++++++++ localpost/di/quart.py | 12 ++++ 8 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 examples/di/__init__.py create mode 100644 examples/di/basic.py create mode 100644 localpost/di/README.md create mode 100644 localpost/di/__init__.py create mode 100644 localpost/di/_services.py create mode 100644 localpost/di/flask.py create mode 100644 localpost/di/quart.py diff --git a/examples/di/__init__.py b/examples/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/di/basic.py b/examples/di/basic.py new file mode 100644 index 0000000..782fa0c --- /dev/null +++ b/examples/di/basic.py @@ -0,0 +1,14 @@ +def configure(): + pass + + +def run(): + pass + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/localpost/_utils.py b/localpost/_utils.py index 7920ce3..6786fef 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -94,11 +94,11 @@ def __aexit__(self, exc_type, exc_value, traceback): class _SupportsClose(Protocol): - def close(self) -> object: ... + def close(self) -> Any: ... class _SupportsAsyncClose(Protocol): - async def aclose(self) -> object: ... + def aclose(self) -> Awaitable[Any]: ... # TODO Remove diff --git a/localpost/di/README.md b/localpost/di/README.md new file mode 100644 index 0000000..f18c013 --- /dev/null +++ b/localpost/di/README.md @@ -0,0 +1,3 @@ +# Inversion of Control, made simple + +Inspired by .NET DependencyInjection diff --git a/localpost/di/__init__.py b/localpost/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localpost/di/_services.py b/localpost/di/_services.py new file mode 100644 index 0000000..68f5c35 --- /dev/null +++ b/localpost/di/_services.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from collections.abc import Callable +from contextlib import AbstractContextManager, ExitStack, closing +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Final, Protocol, cast, final + +from localpost._utils import _SupportsClose + + +class ResolutionContext(Protocol): + def enter[T](self, cm: AbstractContextManager[T]) -> T: ... + + +@final +@dataclass(frozen=True, eq=False, slots=True) +class AppContext(ResolutionContext): + ctx: ExitStack = field(default_factory=ExitStack) + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + return self.ctx.enter_context(cm) + + +class ServiceProvider(Protocol): + """Service provider, scoped to the current resolution context.""" + + def resolve[T](self, service_type: type[T], /) -> T: ... + + def __getitem__[T](self, service_type: type[T], /) -> T: + return self.resolve(service_type) + + def enter[T](self, cm: AbstractContextManager[T]) -> T: ... + + def defer(self, resource: _SupportsClose, /): + return self.enter(closing(resource)) + + +@dataclass(frozen=True, slots=True) +class _ServiceProvider(ServiceProvider): + parent: ServiceProvider + registry: ServiceRegistry + scope: ResolutionContext + """Scope stack, from outermost to innermost.""" + services: dict[type, object] = field(default_factory=dict) + """Resolved services, keyed by service type.""" + + def resolve[T](self, service_type: type[T], /) -> T: + return cast(T, None) # FIXME + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + return self.scope.enter(cm) + + +class NullServiceProvider(ServiceProvider): + def resolve[T](self, service_type: type[T], /) -> T: + raise RuntimeError(f"No service of type {service_type} is registered") + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + raise RuntimeError("No active DI scope") + + +_provider: ContextVar[ServiceProvider] = ContextVar("_provider") + + +def current_provider() -> ServiceProvider: + if provider := _provider.get(None): + return provider + raise RuntimeError("No active DI scope") + + +def scope(self, scope: ResolutionContext, /) -> AbstractContextManager[ServiceProvider]: + pass # TODO Implement + + +class CurrentServiceProvider(ServiceProvider): + def resolve[T](self, service_type: type[T], /) -> T: + return current_provider().resolve(service_type) + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + return current_provider().enter(cm) + + +service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() + + +@dataclass(frozen=True, slots=True) +class ServiceDescriptor[T]: + service_type: type[T] + scope: type[ResolutionContext] + factory: Callable[[ServiceProvider], T] + + +# Kinda like IServiceCollection in .NET, or svcs.Registry +@final +class ServiceRegistry: + def __init__(self): + self.descriptors: dict[type, ServiceDescriptor] = {} + + def bind[T]( + self, + service_type: type[T], + factory: Callable[[ServiceProvider], T] | None = None, + scope: type[ResolutionContext] | None = None, + ) -> None: + sd = ServiceDescriptor(service_type, scope or AppContext, factory or default_factory_for(service_type)) + self.descriptors[service_type] = sd + + def app_scope(self) -> AbstractContextManager[ServiceProvider]: + pass # TODO Implement + + +def default_factory_for[T](service_type: type[T]) -> Callable[[ServiceProvider], T]: + pass # TODO Inspect the type constructor, extract all the params and create a factory function, to bind them from the provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py new file mode 100644 index 0000000..814aab5 --- /dev/null +++ b/localpost/di/flask.py @@ -0,0 +1,24 @@ +"""Flask integration""" + +from __future__ import annotations + +from contextlib import AbstractContextManager, ExitStack +from dataclasses import dataclass, field +from typing import final + +from localpost.di._services import ResolutionContext + + +@final +@dataclass(frozen=True, eq=False, slots=True) +class AppContext(ResolutionContext): + ctx: ExitStack = field(default_factory=ExitStack) + + def enter[T](self, cm: AbstractContextManager[T]) -> T: + return self.ctx.enter_context(cm) + + +# TODO Implement, enter a new RequestContext scope for each request + + +# TODO Register Flask Request in the DI container, with RequestContext scope diff --git a/localpost/di/quart.py b/localpost/di/quart.py new file mode 100644 index 0000000..80639f1 --- /dev/null +++ b/localpost/di/quart.py @@ -0,0 +1,12 @@ +"""Quart (async Flask) integration""" + +from __future__ import annotations + +from localpost.di._services import AsyncResolutionContext, ResolutionContext + + +class RequestContext(AsyncResolutionContext): + pass + + +# TODO Implement, enter a new RequestContext scope for each request From 377750eb19dd96597c0fc576f64d7cdde74bff21 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 15:01:30 +0000 Subject: [PATCH 039/286] WIP on DI --- localpost/di/README.md | 7 ++++++ localpost/di/_services.py | 9 ++++--- localpost/scopes.py | 50 --------------------------------------- 3 files changed, 13 insertions(+), 53 deletions(-) delete mode 100644 localpost/scopes.py diff --git a/localpost/di/README.md b/localpost/di/README.md index f18c013..3edde49 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -1,3 +1,10 @@ # Inversion of Control, made simple Inspired by .NET DependencyInjection + +## Design goals (and non-goals) + +- no async for now (maybe in the future) +- Scope is defined by it's type +- Only one registration per type +- \ No newline at end of file diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 68f5c35..df8cc06 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -97,18 +97,21 @@ class ServiceRegistry: def __init__(self): self.descriptors: dict[type, ServiceDescriptor] = {} - def bind[T]( + def register_value[T](self, service_type: type[T], value: T, scope: type[ResolutionContext] | None = None) -> None: + self.register(service_type, lambda _: value, scope) + + def register[T]( self, service_type: type[T], factory: Callable[[ServiceProvider], T] | None = None, scope: type[ResolutionContext] | None = None, ) -> None: - sd = ServiceDescriptor(service_type, scope or AppContext, factory or default_factory_for(service_type)) + sd = ServiceDescriptor(service_type, scope or AppContext, factory or factory_for(service_type)) self.descriptors[service_type] = sd def app_scope(self) -> AbstractContextManager[ServiceProvider]: pass # TODO Implement -def default_factory_for[T](service_type: type[T]) -> Callable[[ServiceProvider], T]: +def factory_for[T](service_type: type[T]) -> Callable[[ServiceProvider], T]: pass # TODO Inspect the type constructor, extract all the params and create a factory function, to bind them from the provider diff --git a/localpost/scopes.py b/localpost/scopes.py deleted file mode 100644 index f0e5d30..0000000 --- a/localpost/scopes.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import inspect -from collections.abc import Callable -from contextlib import ExitStack, contextmanager -from dataclasses import dataclass, field -from typing import cast, final - - -@contextmanager -def create_scope(): - with ExitStack() as exit_stack: - yield Scope(exit_stack) - - -@dataclass(frozen=True, eq=False, slots=True) -class NamedTypeEntry: - type: type - name: str | None - value: object - - def __call__(self, key: type, name: str | None = None) -> None | object: - if self.type == key and self.name == name: - return self.value - return None - - -@final -@dataclass(frozen=True, eq=False, slots=True) -class Scope: - exit_stack: ExitStack - resolvers: list[Callable[[type, str | None], None | object]] = field(default_factory=list) - - # Kinda like "deffer" in Go or Zig - def add(self, val, /, *, name: str | None = None) -> object: - res = self.exit_stack.enter_context(val) if inspect.isgenerator(val) else val - key = type(res) - self.resolvers.append(NamedTypeEntry(key, name, res)) - return res - - def get[T](self, key: type[T], /, *, name: str | None = None, default: T | None = None) -> T | None: - for resolver in self.resolvers: - if result := resolver(key, name) is not None: - return cast(T, result) - return default - - def __getitem__[T](self, key: type[T]) -> T: - if result := self.get(key): - return cast(T, result) - raise KeyError(f"{key} cannot be resolved") From 3dbcd68730fc2bbed8411cccceb816ba618cc96a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 15:53:02 +0000 Subject: [PATCH 040/286] WIP on DI --- examples/di/basic.py | 26 +++++++++++--- localpost/di/README.md | 8 ++++- localpost/di/_services.py | 72 +++++++++++++++++++++++++++++++++------ 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/examples/di/basic.py b/examples/di/basic.py index 782fa0c..6cc8d8b 100644 --- a/examples/di/basic.py +++ b/examples/di/basic.py @@ -1,13 +1,29 @@ -def configure(): - pass +from dataclasses import dataclass +from icecream import ic -def run(): - pass +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + +class Server: + def __init__(self, config: Config): + self.config = config def main(): - pass + services = ServiceRegistry() + services.register_value(Config(host="127.0.0.1", port=8080)) + services.register(Server) + + with services.app_scope() as service_provider: + server = service_provider.resolve(Server) + ic(server) if __name__ == "__main__": diff --git a/localpost/di/README.md b/localpost/di/README.md index 3edde49..2c611f6 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -7,4 +7,10 @@ Inspired by .NET DependencyInjection - no async for now (maybe in the future) - Scope is defined by it's type - Only one registration per type -- \ No newline at end of file +- Service Locator, no @inject decorator as in other DI-oriented frameworks + - But constructor wiring is supported + +## TODO + +- multiple services for the same type... Like IHostedService in .NET ? + - it creates confusion also, as you now can resolve first or list... diff --git a/localpost/di/_services.py b/localpost/di/_services.py index df8cc06..bd37ace 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -1,10 +1,11 @@ from __future__ import annotations -from collections.abc import Callable -from contextlib import AbstractContextManager, ExitStack, closing +import inspect +from collections.abc import Callable, Generator +from contextlib import AbstractContextManager, ExitStack, closing, contextmanager from contextvars import ContextVar from dataclasses import dataclass, field -from typing import Final, Protocol, cast, final +from typing import Final, Protocol, cast, final, get_type_hints from localpost._utils import _SupportsClose @@ -46,7 +47,26 @@ class _ServiceProvider(ServiceProvider): """Resolved services, keyed by service type.""" def resolve[T](self, service_type: type[T], /) -> T: - return cast(T, None) # FIXME + # Return cached instance if already resolved in this scope + if service_type in self.services: + return cast(T, self.services[service_type]) + + # Look up the descriptor + descriptor = self.registry.descriptors.get(service_type) + if descriptor is None: + # Delegate to parent scope + return self.parent.resolve(service_type) + + # Check if this service belongs to this scope + if not isinstance(self.scope, descriptor.scope): + # Delegate to parent scope (the service belongs to an outer scope) + return self.parent.resolve(service_type) + + # Create the service instance via its factory + instance = descriptor.factory(self) + # Cache for future resolutions within this scope + self.services[service_type] = instance + return instance def enter[T](self, cm: AbstractContextManager[T]) -> T: return self.scope.enter(cm) @@ -60,6 +80,9 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: raise RuntimeError("No active DI scope") +NULL_PROVIDER: Final[ServiceProvider] = NullServiceProvider() + + _provider: ContextVar[ServiceProvider] = ContextVar("_provider") @@ -69,8 +92,14 @@ def current_provider() -> ServiceProvider: raise RuntimeError("No active DI scope") -def scope(self, scope: ResolutionContext, /) -> AbstractContextManager[ServiceProvider]: - pass # TODO Implement +@contextmanager +def scope(registry: ServiceRegistry, ctx: ResolutionContext, /) -> Generator[ServiceProvider]: + provider = _ServiceProvider(parent=_provider.get(NULL_PROVIDER), registry=registry, scope=ctx) + contextvar_token = _provider.set(provider) + try: + yield provider + finally: + _provider.reset(contextvar_token) class CurrentServiceProvider(ServiceProvider): @@ -97,8 +126,10 @@ class ServiceRegistry: def __init__(self): self.descriptors: dict[type, ServiceDescriptor] = {} - def register_value[T](self, service_type: type[T], value: T, scope: type[ResolutionContext] | None = None) -> None: - self.register(service_type, lambda _: value, scope) + def register_value[T]( + self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None + ) -> None: + self.register(service_type or type(value), lambda _: value, scope) def register[T]( self, @@ -109,9 +140,28 @@ def register[T]( sd = ServiceDescriptor(service_type, scope or AppContext, factory or factory_for(service_type)) self.descriptors[service_type] = sd - def app_scope(self) -> AbstractContextManager[ServiceProvider]: - pass # TODO Implement + @contextmanager + def app_scope(self) -> Generator[ServiceProvider]: + with ExitStack() as ctx, scope(self, AppContext(ctx)) as provider: + yield provider def factory_for[T](service_type: type[T]) -> Callable[[ServiceProvider], T]: - pass # TODO Inspect the type constructor, extract all the params and create a factory function, to bind them from the provider + """Inspect the type's __init__ and create a factory that resolves all parameters from the provider.""" + hints = get_type_hints(service_type.__init__) + params = inspect.signature(service_type).parameters + + # Collect (name, type) for each constructor parameter + deps: list[tuple[str, type]] = [] + for name, param in params.items(): + if name == "self": + continue + if name not in hints: + raise TypeError(f"Cannot auto-wire {service_type.__name__}: parameter '{name}' has no type annotation") + deps.append((name, hints[name])) + + def factory(provider: ServiceProvider) -> T: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + return service_type(**kwargs) + + return factory From 1d77944a47984917e26c5b90fc170657a4bc4b47 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 16:37:16 +0000 Subject: [PATCH 041/286] WIP on DI --- localpost/di/README.md | 4 ++-- localpost/di/_services.py | 30 +++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/localpost/di/README.md b/localpost/di/README.md index 2c611f6..171e2ca 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -7,8 +7,8 @@ Inspired by .NET DependencyInjection - no async for now (maybe in the future) - Scope is defined by it's type - Only one registration per type -- Service Locator, no @inject decorator as in other DI-oriented frameworks - - But constructor wiring is supported +- Service Locator (no @inject decorator as in other DI-oriented frameworks) +- Automatic wiring, if a service is resolved via ServiceProvider ## TODO diff --git a/localpost/di/_services.py b/localpost/di/_services.py index bd37ace..d2a4a68 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -62,8 +62,8 @@ def resolve[T](self, service_type: type[T], /) -> T: # Delegate to parent scope (the service belongs to an outer scope) return self.parent.resolve(service_type) - # Create the service instance via its factory - instance = descriptor.factory(self) + # Create the service instance via its factory (returns a CM), and enter it in the scope + instance = self.enter(descriptor.factory(self)) # Cache for future resolutions within this scope self.services[service_type] = instance return instance @@ -113,11 +113,30 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() +# A factory that returns a context manager — the provider enters it to get the instance and manage its lifecycle. +type ServiceFactory[T] = Callable[[ServiceProvider], AbstractContextManager[T]] + + @dataclass(frozen=True, slots=True) class ServiceDescriptor[T]: service_type: type[T] scope: type[ResolutionContext] - factory: Callable[[ServiceProvider], T] + factory: ServiceFactory[T] + + +def _wrap_factory[T](factory: Callable[[ServiceProvider], T | Generator[T]]) -> ServiceFactory[T]: + """Wrap a plain or generator factory into one that always returns a context manager.""" + if inspect.isgeneratorfunction(factory): + # Generator factory: wrap with contextmanager so yield produces a CM + cm_factory = contextmanager(factory) + return cm_factory # type: ignore[return-value] + + # Plain factory: wrap in a no-op context manager + @contextmanager + def wrapper(sp: ServiceProvider) -> Generator[T]: + yield factory(sp) # type: ignore[arg-type] + + return wrapper # Kinda like IServiceCollection in .NET, or svcs.Registry @@ -134,10 +153,11 @@ def register_value[T]( def register[T]( self, service_type: type[T], - factory: Callable[[ServiceProvider], T] | None = None, + factory: Callable[[ServiceProvider], T | Generator[T]] | None = None, scope: type[ResolutionContext] | None = None, ) -> None: - sd = ServiceDescriptor(service_type, scope or AppContext, factory or factory_for(service_type)) + wrapped = _wrap_factory(factory) if factory else _wrap_factory(factory_for(service_type)) + sd = ServiceDescriptor(service_type, scope or AppContext, wrapped) self.descriptors[service_type] = sd @contextmanager From 02517c79077086aba735f394f92d719925a50720 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 18:49:29 +0000 Subject: [PATCH 042/286] WIP on DI --- localpost/di/_services.py | 39 ++++++++++++++++++++++++++++++++++++++- localpost/di/quart.py | 11 ----------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index d2a4a68..774518d 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -46,6 +46,9 @@ class _ServiceProvider(ServiceProvider): services: dict[type, object] = field(default_factory=dict) """Resolved services, keyed by service type.""" + def create_instance[T](self, service_type: type[T]) -> T: + pass # TODO Implement + def resolve[T](self, service_type: type[T], /) -> T: # Return cached instance if already resolved in this scope if service_type in self.services: @@ -124,6 +127,40 @@ class ServiceDescriptor[T]: factory: ServiceFactory[T] +def _auto_wire[T](factory: Callable[..., T | Generator[T]]) -> Callable[[ServiceProvider], T | Generator[T]]: + """Wrap a callable so its parameters are resolved from the service provider.""" + hints = get_type_hints(factory) + params = inspect.signature(factory).parameters + + # Check if the factory already accepts a single ServiceProvider parameter (or untyped single param) + param_list = list(params.values()) + if len(param_list) == 1: + param_hint = hints.get(param_list[0].name) + if param_hint is ServiceProvider or param_hint is None: + return factory # type: ignore[return-value] + + # Collect (name, type) for each parameter + deps: list[tuple[str, type]] = [] + for name, param in params.items(): + if name not in hints: + raise TypeError(f"Cannot auto-wire factory {factory.__name__}: parameter '{name}' has no type annotation") + deps.append((name, hints[name])) + + if inspect.isgeneratorfunction(factory): + + def gen_wrapper(provider: ServiceProvider) -> Generator[T]: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + yield from factory(**kwargs) # type: ignore[arg-type] + + return gen_wrapper # type: ignore[return-value] + + def wrapper(provider: ServiceProvider) -> T: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + return factory(**kwargs) # type: ignore[return-value] + + return wrapper # type: ignore[return-value] + + def _wrap_factory[T](factory: Callable[[ServiceProvider], T | Generator[T]]) -> ServiceFactory[T]: """Wrap a plain or generator factory into one that always returns a context manager.""" if inspect.isgeneratorfunction(factory): @@ -156,7 +193,7 @@ def register[T]( factory: Callable[[ServiceProvider], T | Generator[T]] | None = None, scope: type[ResolutionContext] | None = None, ) -> None: - wrapped = _wrap_factory(factory) if factory else _wrap_factory(factory_for(service_type)) + wrapped = _wrap_factory(_auto_wire(factory)) if factory else _wrap_factory(factory_for(service_type)) sd = ServiceDescriptor(service_type, scope or AppContext, wrapped) self.descriptors[service_type] = sd diff --git a/localpost/di/quart.py b/localpost/di/quart.py index 80639f1..45cefd2 100644 --- a/localpost/di/quart.py +++ b/localpost/di/quart.py @@ -1,12 +1 @@ """Quart (async Flask) integration""" - -from __future__ import annotations - -from localpost.di._services import AsyncResolutionContext, ResolutionContext - - -class RequestContext(AsyncResolutionContext): - pass - - -# TODO Implement, enter a new RequestContext scope for each request From 2b213ebbe08977a1e736a405e91fdeca25e6ef0d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:01:35 +0000 Subject: [PATCH 043/286] chore: pyright to ty --- CLAUDE.md | 7 +++---- justfile | 13 ++++++------- localpost/di/_services.py | 3 --- pyproject.toml | 8 ++------ 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ec22ff..6f1d7c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,12 +17,11 @@ Python 3.12+ required. Built on AnyIO for structured concurrency (works with asy ```bash just deps # Install all dependencies (uses UV) just format # Format code with ruff -just types # Check types (PyRight + MyPy) -just type-coverage # Verify public API types with pyright --verifytypes +just types # Check types (using ty) just tests # Run all tests with coverage just unit-tests # Unit tests only (exclude integration) just integration-tests # Integration tests (parallel with pytest-xdist) -just check FILE # Check single file (ruff + pyright + mypy) +just check FILE # Check single file (ruff + ty) ``` Run a single test: @@ -94,7 +93,7 @@ Files prefixed with `_` contain internal implementations. Public APIs are export ### Conventions - Use AnyIO for async work (structured concurrency) -- **Public API**: Full type annotations, verified with PyRight (`just type-coverage`) +- **Public API**: Full type annotations, checked with ty (`just types`) - **Internal API**: Types only where they improve readability ## Testing diff --git a/justfile b/justfile index b0c6808..fd6d2a4 100755 --- a/justfile +++ b/justfile @@ -10,18 +10,17 @@ deps-upgrade: uv lock --upgrade uv sync --all-groups --all-extras -[doc("Check types (using both PyRight and MyPy)")] +[doc("Check types (using ty)")] types: - -basedpyright --pythonpath $(which python) localpost + -ty check localpost -[doc("Check types (using both PyRight and MyPy)")] +[doc("Check types strictly (using ty)")] types-strict: - -basedpyright --pythonpath $(which python) localpost + -ty check localpost [doc("Check types (including examples and tests)")] types-all: types - -basedpyright --pythonpath $(which python) \ - examples tests + -ty check examples tests [doc("Check that the public API is correctly typed")] type-coverage: @@ -37,7 +36,7 @@ format-all: format check file: -ruff check --fix {{ file }} - -basedpyright --pythonpath $(which python) {{ file }} + -ty check {{ file }} tests: pytest --cov-report=term --cov-report=xml --cov-branch --cov -v diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 774518d..1518b70 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -46,9 +46,6 @@ class _ServiceProvider(ServiceProvider): services: dict[type, object] = field(default_factory=dict) """Resolved services, keyed by service type.""" - def create_instance[T](self, service_type: type[T]) -> T: - pass # TODO Implement - def resolve[T](self, service_type: type[T], /) -> T: # Return cached instance if already resolved in this scope if service_type in self.services: diff --git a/pyproject.toml b/pyproject.toml index dc2df88..b4bd009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,12 +150,8 @@ tests-integration = [ [tool.coverage.run] omit = ["tests/*"] -[tool.pyright] -include = ["localpost"] -reportAny = false -reportExplicitAny = false -reportUnusedCallResult = false -reportIncompatibleMethodOverride = false +[tool.ty] +src = ["localpost"] [[tool.mypy.overrides]] module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] From 2bfd57a90fc6fc33fba22a2d4141c86696f9a480 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:09:34 +0000 Subject: [PATCH 044/286] WIP on DI --- examples/di/basic_cleanup.py | 53 ++++++++++++++++++++++++++++++++++++ localpost/di/_services.py | 18 ++++++------ pyproject.toml | 3 -- 3 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 examples/di/basic_cleanup.py diff --git a/examples/di/basic_cleanup.py b/examples/di/basic_cleanup.py new file mode 100644 index 0000000..75e92d9 --- /dev/null +++ b/examples/di/basic_cleanup.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +from icecream import ic + +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + db_dsn: str + + +def create_db_conn_pool(conf: Config): + db = DBConnectionPool(conf.db_dsn) + try: + yield db + finally: + db.close() + + +class DBConnectionPool: + def __init__(self, dsn: str): + self.dsn = dsn + + def close(self): + pass + + +class Server: + def __init__(self, config: Config, db: DBConnectionPool): + self.config = config + self.db = db + + def close(self): + pass + + +def main(): + services = ServiceRegistry() + services.register_value(Config(host="127.0.0.1", port=8080, db_dsn=":memory:")) + services.register(DBConnectionPool, create_db_conn_pool) + services.register(Server) + + with services.app_scope() as service_provider: + server = service_provider.resolve(Server) + ic(server, server.db) + + +if __name__ == "__main__": + main() diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 1518b70..b1118b2 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -134,28 +134,28 @@ def _auto_wire[T](factory: Callable[..., T | Generator[T]]) -> Callable[[Service if len(param_list) == 1: param_hint = hints.get(param_list[0].name) if param_hint is ServiceProvider or param_hint is None: - return factory # type: ignore[return-value] + return factory # Collect (name, type) for each parameter deps: list[tuple[str, type]] = [] for name, param in params.items(): if name not in hints: - raise TypeError(f"Cannot auto-wire factory {factory.__name__}: parameter '{name}' has no type annotation") + raise TypeError(f"Cannot auto-wire factory {getattr(factory, '__name__', repr(factory))}: parameter '{name}' has no type annotation") deps.append((name, hints[name])) if inspect.isgeneratorfunction(factory): def gen_wrapper(provider: ServiceProvider) -> Generator[T]: kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} - yield from factory(**kwargs) # type: ignore[arg-type] + yield from factory(**kwargs) - return gen_wrapper # type: ignore[return-value] + return gen_wrapper def wrapper(provider: ServiceProvider) -> T: kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} - return factory(**kwargs) # type: ignore[return-value] + return cast(T, factory(**kwargs)) - return wrapper # type: ignore[return-value] + return wrapper def _wrap_factory[T](factory: Callable[[ServiceProvider], T | Generator[T]]) -> ServiceFactory[T]: @@ -163,12 +163,12 @@ def _wrap_factory[T](factory: Callable[[ServiceProvider], T | Generator[T]]) -> if inspect.isgeneratorfunction(factory): # Generator factory: wrap with contextmanager so yield produces a CM cm_factory = contextmanager(factory) - return cm_factory # type: ignore[return-value] + return cm_factory # Plain factory: wrap in a no-op context manager @contextmanager def wrapper(sp: ServiceProvider) -> Generator[T]: - yield factory(sp) # type: ignore[arg-type] + yield factory(sp) return wrapper @@ -187,7 +187,7 @@ def register_value[T]( def register[T]( self, service_type: type[T], - factory: Callable[[ServiceProvider], T | Generator[T]] | None = None, + factory: Callable[..., T | Generator[T]] | None = None, scope: type[ResolutionContext] | None = None, ) -> None: wrapped = _wrap_factory(_auto_wire(factory)) if factory else _wrap_factory(factory_for(service_type)) diff --git a/pyproject.toml b/pyproject.toml index b4bd009..cde951d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,9 +150,6 @@ tests-integration = [ [tool.coverage.run] omit = ["tests/*"] -[tool.ty] -src = ["localpost"] - [[tool.mypy.overrides]] module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] follow_untyped_imports = true From 06cad709e79bf251a4d2339faf84b320ade8ce43 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:16:23 +0000 Subject: [PATCH 045/286] WIP on DI --- localpost/di/_services.py | 107 +++++++------ tests/di/__init__.py | 0 tests/di/services.py | 312 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 370 insertions(+), 49 deletions(-) create mode 100644 tests/di/__init__.py create mode 100644 tests/di/services.py diff --git a/localpost/di/_services.py b/localpost/di/_services.py index b1118b2..27d229f 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -124,53 +124,83 @@ class ServiceDescriptor[T]: factory: ServiceFactory[T] -def _auto_wire[T](factory: Callable[..., T | Generator[T]]) -> Callable[[ServiceProvider], T | Generator[T]]: - """Wrap a callable so its parameters are resolved from the service provider.""" +def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> list[tuple[str, type]]: + """Inspect a callable's parameters and collect (name, type) pairs for auto-wiring.""" hints = get_type_hints(factory) params = inspect.signature(factory).parameters - # Check if the factory already accepts a single ServiceProvider parameter (or untyped single param) - param_list = list(params.values()) - if len(param_list) == 1: - param_hint = hints.get(param_list[0].name) - if param_hint is ServiceProvider or param_hint is None: - return factory - - # Collect (name, type) for each parameter + factory_name = getattr(factory, "__name__", repr(factory)) deps: list[tuple[str, type]] = [] - for name, param in params.items(): + for name in params: + if skip_self and name == "self": + continue if name not in hints: - raise TypeError(f"Cannot auto-wire factory {getattr(factory, '__name__', repr(factory))}: parameter '{name}' has no type annotation") + raise TypeError(f"Cannot auto-wire {factory_name}: parameter '{name}' has no type annotation") deps.append((name, hints[name])) + return deps + + +def _is_sp_factory(factory: Callable[..., object]) -> bool: + """Check if a callable already accepts a single ServiceProvider parameter (no auto-wiring needed).""" + hints = get_type_hints(factory) + params = list(inspect.signature(factory).parameters.values()) + if len(params) == 1: + param_hint = hints.get(params[0].name) + return param_hint is ServiceProvider or param_hint is None + return False + + +def _make_service_factory[T](factory: Callable[..., T | Generator[T]]) -> ServiceFactory[T]: + """Turn any callable (plain, generator, or already-wired) into a ServiceFactory.""" + if _is_sp_factory(factory): + # Already accepts a single ServiceProvider param — just wrap for lifecycle + if inspect.isgeneratorfunction(factory): + return contextmanager(factory) + + @contextmanager + def cm_passthrough(sp: ServiceProvider) -> Generator[T]: + yield cast(T, factory(sp)) + + return cm_passthrough + + # Auto-wire: resolve deps from the provider, then call the factory + deps = _collect_deps(factory) + if inspect.isgeneratorfunction(factory): - def gen_wrapper(provider: ServiceProvider) -> Generator[T]: + @contextmanager + def cm_gen(provider: ServiceProvider) -> Generator[T]: kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} yield from factory(**kwargs) - return gen_wrapper + return cm_gen - def wrapper(provider: ServiceProvider) -> T: + @contextmanager + def cm_plain(provider: ServiceProvider) -> Generator[T]: kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} - return cast(T, factory(**kwargs)) + yield cast(T, factory(**kwargs)) - return wrapper + return cm_plain -def _wrap_factory[T](factory: Callable[[ServiceProvider], T | Generator[T]]) -> ServiceFactory[T]: - """Wrap a plain or generator factory into one that always returns a context manager.""" - if inspect.isgeneratorfunction(factory): - # Generator factory: wrap with contextmanager so yield produces a CM - cm_factory = contextmanager(factory) - return cm_factory +def _factory_for_type[T](service_type: type[T]) -> ServiceFactory[T]: + """Create a ServiceFactory that auto-wires a type's constructor.""" + # Classes without a custom __init__ inherit object.__init__(*args, **kwargs) — no deps to wire + deps = _collect_deps(service_type.__init__, skip_self=True) if "__init__" in service_type.__dict__ else [] + has_close = callable(getattr(service_type, "close", None)) - # Plain factory: wrap in a no-op context manager @contextmanager - def wrapper(sp: ServiceProvider) -> Generator[T]: - yield factory(sp) + def cm(provider: ServiceProvider) -> Generator[T]: + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + instance = service_type(**kwargs) + try: + yield instance + finally: + if has_close: + instance.close() # type: ignore[union-attr] - return wrapper + return cm # Kinda like IServiceCollection in .NET, or svcs.Registry @@ -190,7 +220,7 @@ def register[T]( factory: Callable[..., T | Generator[T]] | None = None, scope: type[ResolutionContext] | None = None, ) -> None: - wrapped = _wrap_factory(_auto_wire(factory)) if factory else _wrap_factory(factory_for(service_type)) + wrapped = _make_service_factory(factory) if factory else _factory_for_type(service_type) sd = ServiceDescriptor(service_type, scope or AppContext, wrapped) self.descriptors[service_type] = sd @@ -198,24 +228,3 @@ def register[T]( def app_scope(self) -> Generator[ServiceProvider]: with ExitStack() as ctx, scope(self, AppContext(ctx)) as provider: yield provider - - -def factory_for[T](service_type: type[T]) -> Callable[[ServiceProvider], T]: - """Inspect the type's __init__ and create a factory that resolves all parameters from the provider.""" - hints = get_type_hints(service_type.__init__) - params = inspect.signature(service_type).parameters - - # Collect (name, type) for each constructor parameter - deps: list[tuple[str, type]] = [] - for name, param in params.items(): - if name == "self": - continue - if name not in hints: - raise TypeError(f"Cannot auto-wire {service_type.__name__}: parameter '{name}' has no type annotation") - deps.append((name, hints[name])) - - def factory(provider: ServiceProvider) -> T: - kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} - return service_type(**kwargs) - - return factory diff --git a/tests/di/__init__.py b/tests/di/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/di/services.py b/tests/di/services.py new file mode 100644 index 0000000..b233845 --- /dev/null +++ b/tests/di/services.py @@ -0,0 +1,312 @@ +from collections.abc import Generator +from dataclasses import dataclass + +import pytest + +from localpost.di._services import ( + AppContext, + ServiceProvider, + ServiceRegistry, + current_provider, + _factory_for_type, + scope, + service_provider, +) + +# --- Test fixtures (service classes) --- + + +@dataclass +class Config: + host: str + port: int + + +class Database: + def __init__(self, config: Config): + self.config = config + + +class UserRepository: + def __init__(self, db: Database): + self.db = db + + +class NoAnnotations: + def __init__(self, something): + self.something = something + + +# --- Tests --- + + +class TestFactoryFor: + def test_resolves_constructor_params(self): + registry = ServiceRegistry() + registry.register_value(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + db = provider.resolve(Database) + assert isinstance(db, Database) + assert db.config.host == "localhost" + assert db.config.port == 5432 + + def test_raises_on_missing_annotation(self): + with pytest.raises(TypeError, match="has no type annotation"): + _factory_for_type(NoAnnotations) + + def test_no_params(self): + """A class with no __init__ params (besides self) should just be instantiated.""" + + class Simple: + pass + + factory = _factory_for_type(Simple) + registry = ServiceRegistry() + with registry.app_scope() as provider: + with factory(provider) as instance: + assert isinstance(instance, Simple) + + +class TestServiceProvider: + def test_resolve_registered_value(self): + registry = ServiceRegistry() + config = Config(host="127.0.0.1", port=8080) + registry.register_value(config) + + with registry.app_scope() as provider: + resolved = provider.resolve(Config) + assert resolved is config + + def test_resolve_with_auto_wiring(self): + registry = ServiceRegistry() + registry.register_value(Config(host="127.0.0.1", port=8080)) + registry.register(Database) + + with registry.app_scope() as provider: + db = provider.resolve(Database) + assert isinstance(db, Database) + assert db.config.host == "127.0.0.1" + + def test_resolve_transitive_dependencies(self): + registry = ServiceRegistry() + registry.register_value(Config(host="localhost", port=5432)) + registry.register(Database) + registry.register(UserRepository) + + with registry.app_scope() as provider: + repo = provider.resolve(UserRepository) + assert isinstance(repo, UserRepository) + assert isinstance(repo.db, Database) + assert repo.db.config.host == "localhost" + + def test_resolve_caches_within_scope(self): + registry = ServiceRegistry() + registry.register_value(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + db1 = provider.resolve(Database) + db2 = provider.resolve(Database) + assert db1 is db2 + + def test_resolve_unregistered_raises(self): + registry = ServiceRegistry() + + with registry.app_scope() as provider: + with pytest.raises(RuntimeError, match="No service of type"): + provider.resolve(Config) + + def test_getitem(self): + registry = ServiceRegistry() + config = Config(host="localhost", port=5432) + registry.register_value(config) + + with registry.app_scope() as provider: + assert provider[Config] is config + + def test_register_with_custom_factory(self): + registry = ServiceRegistry() + registry.register(Config, lambda _: Config(host="custom", port=9999)) + + with registry.app_scope() as provider: + config = provider.resolve(Config) + assert config.host == "custom" + assert config.port == 9999 + + +class TestScope: + def test_sets_context_var(self): + registry = ServiceRegistry() + registry.register_value(Config(host="localhost", port=5432)) + + with registry.app_scope(): + # current_provider() should work inside the scope + provider = current_provider() + config = provider.resolve(Config) + assert config.host == "localhost" + + def test_resets_context_var_after_exit(self): + registry = ServiceRegistry() + + with registry.app_scope(): + pass + + with pytest.raises(RuntimeError, match="No active DI scope"): + current_provider() + + def test_service_provider_proxy(self): + """The module-level `service_provider` proxy should delegate to the current scope.""" + registry = ServiceRegistry() + config = Config(host="proxy-test", port=1234) + registry.register_value(config) + + with registry.app_scope(): + assert service_provider.resolve(Config) is config + + def test_nested_scopes(self): + """Inner scope should shadow the outer one in the context var.""" + registry = ServiceRegistry() + outer_config = Config(host="outer", port=1) + inner_config = Config(host="inner", port=2) + + registry.register_value(outer_config) + + with registry.app_scope() as outer_provider: + assert outer_provider.resolve(Config).host == "outer" + + # Create a new registry for the inner scope with a different value + inner_registry = ServiceRegistry() + inner_registry.register_value(inner_config) + + inner_ctx = AppContext() + with inner_ctx.ctx: + with scope(inner_registry, inner_ctx) as inner_provider: + assert inner_provider.resolve(Config).host == "inner" + assert current_provider().resolve(Config).host == "inner" + + # After exiting inner scope, outer is restored + assert current_provider().resolve(Config).host == "outer" + + +class TestAppScopeLifecycle: + def test_enter_context_manager(self): + """Resources entered via provider.enter() should be cleaned up when the scope exits.""" + closed = False + + class Resource: + def __enter__(self): + return self + + def __exit__(self, *args): + nonlocal closed + closed = True + + registry = ServiceRegistry() + + with registry.app_scope() as provider: + provider.enter(Resource()) + assert not closed + + assert closed + + def test_defer_closeable(self): + """provider.defer() should close the resource when the scope exits.""" + closed = False + + class Closeable: + def close(self): + nonlocal closed + closed = True + + registry = ServiceRegistry() + + with registry.app_scope() as provider: + provider.defer(Closeable()) + assert not closed + + assert closed + + def test_auto_close_on_scope_exit(self): + """Services with close() registered without a factory should be auto-closed on scope exit.""" + closed = [] + + class Pool: + def __init__(self, config: Config): + self.config = config + + def close(self): + closed.append("pool") + + class Server: + def __init__(self, config: Config): + self.config = config + + def close(self): + closed.append("server") + + registry = ServiceRegistry() + registry.register_value(Config(host="localhost", port=5432)) + registry.register(Pool) + registry.register(Server) + + with registry.app_scope() as provider: + provider.resolve(Pool) + provider.resolve(Server) + assert closed == [] + + assert "pool" in closed + assert "server" in closed + + def test_generator_factory_cleanup(self): + """A generator factory should run cleanup code after scope exit.""" + events: list[str] = [] + + class Pool: + def __init__(self, name: str): + self.name = name + + def pool_factory(sp: ServiceProvider) -> Generator[Pool]: + events.append("create") + pool = Pool("test-pool") + yield pool + events.append("cleanup") + + registry = ServiceRegistry() + registry.register(Pool, pool_factory) + + with registry.app_scope() as provider: + pool = provider.resolve(Pool) + assert pool.name == "test-pool" + assert events == ["create"] + + assert events == ["create", "cleanup"] + + def test_generator_factory_with_dependencies(self): + """A generator factory should be able to resolve dependencies from the provider.""" + closed = False + + class DBPool: + def __init__(self, config: Config): + self.config = config + + def close(self): + nonlocal closed + closed = True + + def db_pool_factory(sp: ServiceProvider) -> Generator[DBPool]: + pool = DBPool(sp[Config]) + yield pool + pool.close() + + registry = ServiceRegistry() + registry.register_value(Config(host="localhost", port=5432)) + registry.register(DBPool, db_pool_factory) + + with registry.app_scope() as provider: + pool = provider.resolve(DBPool) + assert pool.config.host == "localhost" + assert not closed + + assert closed From d6f2d5b4c6ad282043433d23783b3d1c3c85543e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:30:04 +0000 Subject: [PATCH 046/286] WIP on DI --- examples/di/basic_cleanup.py | 5 ++-- localpost/di/_services.py | 57 ++++++++++++------------------------ tests/di/services.py | 42 +++----------------------- 3 files changed, 25 insertions(+), 79 deletions(-) diff --git a/examples/di/basic_cleanup.py b/examples/di/basic_cleanup.py index 75e92d9..5320c7c 100644 --- a/examples/di/basic_cleanup.py +++ b/examples/di/basic_cleanup.py @@ -2,7 +2,7 @@ from icecream import ic -from localpost.di._services import ServiceRegistry +from localpost.di._services import AppContext, ServiceRegistry, ServiceProvider @dataclass @@ -13,7 +13,8 @@ class Config: db_dsn: str -def create_db_conn_pool(conf: Config): +# ctx and sp are just to show how to use things +def create_db_conn_pool(conf: Config, ctx: AppContext, sp: ServiceProvider): db = DBConnectionPool(conf.db_dsn) try: yield db diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 27d229f..0e57734 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -2,13 +2,11 @@ import inspect from collections.abc import Callable, Generator -from contextlib import AbstractContextManager, ExitStack, closing, contextmanager +from contextlib import AbstractContextManager, ExitStack, contextmanager from contextvars import ContextVar from dataclasses import dataclass, field from typing import Final, Protocol, cast, final, get_type_hints -from localpost._utils import _SupportsClose - class ResolutionContext(Protocol): def enter[T](self, cm: AbstractContextManager[T]) -> T: ... @@ -31,11 +29,6 @@ def resolve[T](self, service_type: type[T], /) -> T: ... def __getitem__[T](self, service_type: type[T], /) -> T: return self.resolve(service_type) - def enter[T](self, cm: AbstractContextManager[T]) -> T: ... - - def defer(self, resource: _SupportsClose, /): - return self.enter(closing(resource)) - @dataclass(frozen=True, slots=True) class _ServiceProvider(ServiceProvider): @@ -63,14 +56,11 @@ def resolve[T](self, service_type: type[T], /) -> T: return self.parent.resolve(service_type) # Create the service instance via its factory (returns a CM), and enter it in the scope - instance = self.enter(descriptor.factory(self)) + instance = self.scope.enter(descriptor.factory(self)) # Cache for future resolutions within this scope self.services[service_type] = instance return instance - def enter[T](self, cm: AbstractContextManager[T]) -> T: - return self.scope.enter(cm) - class NullServiceProvider(ServiceProvider): def resolve[T](self, service_type: type[T], /) -> T: @@ -106,9 +96,6 @@ class CurrentServiceProvider(ServiceProvider): def resolve[T](self, service_type: type[T], /) -> T: return current_provider().resolve(service_type) - def enter[T](self, cm: AbstractContextManager[T]) -> T: - return current_provider().enter(cm) - service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() @@ -141,44 +128,31 @@ def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> return deps -def _is_sp_factory(factory: Callable[..., object]) -> bool: - """Check if a callable already accepts a single ServiceProvider parameter (no auto-wiring needed).""" - hints = get_type_hints(factory) - params = list(inspect.signature(factory).parameters.values()) - if len(params) == 1: - param_hint = hints.get(params[0].name) - return param_hint is ServiceProvider or param_hint is None - return False +def _resolve_dep(provider: _ServiceProvider, dep_type: type) -> object: + """Resolve a dependency, handling well-known DI types specially.""" + if dep_type is ServiceProvider: + return provider + if dep_type is ResolutionContext or dep_type is AppContext: + return provider.scope + return provider.resolve(dep_type) def _make_service_factory[T](factory: Callable[..., T | Generator[T]]) -> ServiceFactory[T]: """Turn any callable (plain, generator, or already-wired) into a ServiceFactory.""" - if _is_sp_factory(factory): - # Already accepts a single ServiceProvider param — just wrap for lifecycle - if inspect.isgeneratorfunction(factory): - return contextmanager(factory) - - @contextmanager - def cm_passthrough(sp: ServiceProvider) -> Generator[T]: - yield cast(T, factory(sp)) - - return cm_passthrough - - # Auto-wire: resolve deps from the provider, then call the factory deps = _collect_deps(factory) if inspect.isgeneratorfunction(factory): @contextmanager def cm_gen(provider: ServiceProvider) -> Generator[T]: - kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + kwargs = {name: _resolve_dep(cast(_ServiceProvider, provider), dep_type) for name, dep_type in deps} yield from factory(**kwargs) return cm_gen @contextmanager def cm_plain(provider: ServiceProvider) -> Generator[T]: - kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + kwargs = {name: _resolve_dep(cast(_ServiceProvider, provider), dep_type) for name, dep_type in deps} yield cast(T, factory(**kwargs)) return cm_plain @@ -192,7 +166,7 @@ def _factory_for_type[T](service_type: type[T]) -> ServiceFactory[T]: @contextmanager def cm(provider: ServiceProvider) -> Generator[T]: - kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} + kwargs = {name: _resolve_dep(cast(_ServiceProvider, provider), dep_type) for name, dep_type in deps} instance = service_type(**kwargs) try: yield instance @@ -212,7 +186,12 @@ def __init__(self): def register_value[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None ) -> None: - self.register(service_type or type(value), lambda _: value, scope) + @contextmanager + def value_factory(_: ServiceProvider) -> Generator[T]: + yield value + + sd = ServiceDescriptor(service_type or type(value), scope or AppContext, value_factory) + self.descriptors[sd.service_type] = sd def register[T]( self, diff --git a/tests/di/services.py b/tests/di/services.py index b233845..63c75ef 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -127,8 +127,11 @@ def test_getitem(self): assert provider[Config] is config def test_register_with_custom_factory(self): + def custom_factory() -> Config: + return Config(host="custom", port=9999) + registry = ServiceRegistry() - registry.register(Config, lambda _: Config(host="custom", port=9999)) + registry.register(Config, custom_factory) with registry.app_scope() as provider: config = provider.resolve(Config) @@ -191,43 +194,6 @@ def test_nested_scopes(self): class TestAppScopeLifecycle: - def test_enter_context_manager(self): - """Resources entered via provider.enter() should be cleaned up when the scope exits.""" - closed = False - - class Resource: - def __enter__(self): - return self - - def __exit__(self, *args): - nonlocal closed - closed = True - - registry = ServiceRegistry() - - with registry.app_scope() as provider: - provider.enter(Resource()) - assert not closed - - assert closed - - def test_defer_closeable(self): - """provider.defer() should close the resource when the scope exits.""" - closed = False - - class Closeable: - def close(self): - nonlocal closed - closed = True - - registry = ServiceRegistry() - - with registry.app_scope() as provider: - provider.defer(Closeable()) - assert not closed - - assert closed - def test_auto_close_on_scope_exit(self): """Services with close() registered without a factory should be auto-closed on scope exit.""" closed = [] From 33b3d6d5302e2afdd74bcc835f50e53e255353be Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:32:25 +0000 Subject: [PATCH 047/286] WIP on DI --- localpost/di/_services.py | 3 +-- tests/di/services.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 0e57734..9f47c2d 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -47,8 +47,7 @@ def resolve[T](self, service_type: type[T], /) -> T: # Look up the descriptor descriptor = self.registry.descriptors.get(service_type) if descriptor is None: - # Delegate to parent scope - return self.parent.resolve(service_type) + raise RuntimeError(f"No service of type {service_type} is registered") # Check if this service belongs to this scope if not isinstance(self.scope, descriptor.scope): diff --git a/tests/di/services.py b/tests/di/services.py index 63c75ef..02bd1ca 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -118,6 +118,20 @@ def test_resolve_unregistered_raises(self): with pytest.raises(RuntimeError, match="No service of type"): provider.resolve(Config) + def test_resolve_unregistered_in_nested_scope_raises(self): + """Unregistered type should raise even when a parent scope exists.""" + outer_registry = ServiceRegistry() + outer_registry.register_value(Config(host="outer", port=1)) + + inner_registry = ServiceRegistry() + # Database is not registered in inner_registry + + with outer_registry.app_scope(): + inner_ctx = AppContext() + with inner_ctx.ctx, scope(inner_registry, inner_ctx) as inner_provider: + with pytest.raises(RuntimeError, match="No service of type"): + inner_provider.resolve(Database) + def test_getitem(self): registry = ServiceRegistry() config = Config(host="localhost", port=5432) From 7946adf2be90a6396e67409b64bbc82b13d04745 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:39:03 +0000 Subject: [PATCH 048/286] WIP on DI --- localpost/di/_services.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 9f47c2d..82305dc 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -30,6 +30,10 @@ def __getitem__[T](self, service_type: type[T], /) -> T: return self.resolve(service_type) +class ServiceNotRegisteredError(ValueError): + """Raised when resolving a service that is not registered.""" + + @dataclass(frozen=True, slots=True) class _ServiceProvider(ServiceProvider): parent: ServiceProvider @@ -47,7 +51,7 @@ def resolve[T](self, service_type: type[T], /) -> T: # Look up the descriptor descriptor = self.registry.descriptors.get(service_type) if descriptor is None: - raise RuntimeError(f"No service of type {service_type} is registered") + raise ServiceNotRegisteredError(f"{service_type} is not registered") # Check if this service belongs to this scope if not isinstance(self.scope, descriptor.scope): From 9e4856ccbced7460b03b267c13c44b9bece8f2cc Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:41:44 +0000 Subject: [PATCH 049/286] WIP on DI --- localpost/di/_services.py | 21 +++++++++------------ tests/di/services.py | 7 ++++--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 82305dc..d6bbcd0 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -44,6 +44,12 @@ class _ServiceProvider(ServiceProvider): """Resolved services, keyed by service type.""" def resolve[T](self, service_type: type[T], /) -> T: + # Well-known DI types + if service_type is ServiceProvider: + return cast(T, self) + if service_type is ResolutionContext or service_type is AppContext: + return cast(T, self.scope) + # Return cached instance if already resolved in this scope if service_type in self.services: return cast(T, self.services[service_type]) @@ -131,15 +137,6 @@ def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> return deps -def _resolve_dep(provider: _ServiceProvider, dep_type: type) -> object: - """Resolve a dependency, handling well-known DI types specially.""" - if dep_type is ServiceProvider: - return provider - if dep_type is ResolutionContext or dep_type is AppContext: - return provider.scope - return provider.resolve(dep_type) - - def _make_service_factory[T](factory: Callable[..., T | Generator[T]]) -> ServiceFactory[T]: """Turn any callable (plain, generator, or already-wired) into a ServiceFactory.""" deps = _collect_deps(factory) @@ -148,14 +145,14 @@ def _make_service_factory[T](factory: Callable[..., T | Generator[T]]) -> Servic @contextmanager def cm_gen(provider: ServiceProvider) -> Generator[T]: - kwargs = {name: _resolve_dep(cast(_ServiceProvider, provider), dep_type) for name, dep_type in deps} + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} yield from factory(**kwargs) return cm_gen @contextmanager def cm_plain(provider: ServiceProvider) -> Generator[T]: - kwargs = {name: _resolve_dep(cast(_ServiceProvider, provider), dep_type) for name, dep_type in deps} + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} yield cast(T, factory(**kwargs)) return cm_plain @@ -169,7 +166,7 @@ def _factory_for_type[T](service_type: type[T]) -> ServiceFactory[T]: @contextmanager def cm(provider: ServiceProvider) -> Generator[T]: - kwargs = {name: _resolve_dep(cast(_ServiceProvider, provider), dep_type) for name, dep_type in deps} + kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} instance = service_type(**kwargs) try: yield instance diff --git a/tests/di/services.py b/tests/di/services.py index 02bd1ca..46e5f65 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -5,10 +5,11 @@ from localpost.di._services import ( AppContext, + ServiceNotRegisteredError, ServiceProvider, ServiceRegistry, - current_provider, _factory_for_type, + current_provider, scope, service_provider, ) @@ -115,7 +116,7 @@ def test_resolve_unregistered_raises(self): registry = ServiceRegistry() with registry.app_scope() as provider: - with pytest.raises(RuntimeError, match="No service of type"): + with pytest.raises(ServiceNotRegisteredError): provider.resolve(Config) def test_resolve_unregistered_in_nested_scope_raises(self): @@ -129,7 +130,7 @@ def test_resolve_unregistered_in_nested_scope_raises(self): with outer_registry.app_scope(): inner_ctx = AppContext() with inner_ctx.ctx, scope(inner_registry, inner_ctx) as inner_provider: - with pytest.raises(RuntimeError, match="No service of type"): + with pytest.raises(ServiceNotRegisteredError): inner_provider.resolve(Database) def test_getitem(self): From 71e94083aeee8ac590bd7a7d17481a704d9abb50 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 19 Mar 2026 19:56:34 +0000 Subject: [PATCH 050/286] WIP on DI, Flask --- examples/di/flask_app.py | 62 ++++++++++++++++++++++++++++++++++++++++ localpost/di/flask.py | 48 ++++++++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 examples/di/flask_app.py diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py new file mode 100644 index 0000000..270a723 --- /dev/null +++ b/examples/di/flask_app.py @@ -0,0 +1,62 @@ +from collections.abc import Generator +from dataclasses import dataclass + +from flask import Flask, Request + +from localpost.di._services import ServiceRegistry, service_provider +from localpost.di.flask import RequestContext, init_app + + +@dataclass +class Config: + db_dsn: str + + +class DBConnectionPool: + def __init__(self, dsn: str): + self.dsn = dsn + + def close(self): + print(f" Closing DB pool ({self.dsn})") + + +def create_db_pool(config: Config) -> Generator[DBConnectionPool]: + pool = DBConnectionPool(config.db_dsn) + print(f" Created DB pool ({pool.dsn})") + try: + yield pool + finally: + pool.close() + + +class UserRepository: + """Request-scoped: depends on the DB pool and the current Flask request.""" + + def __init__(self, db: DBConnectionPool, req: Request): + self.db = db + self.request_path = req.path + + def get_current_user(self) -> str: + return f"user (from {self.request_path}, db={self.db.dsn})" + + +app = Flask(__name__) + +# Set up services +services = ServiceRegistry() +services.register_value(Config(db_dsn=":memory:")) +services.register(DBConnectionPool, create_db_pool) +services.register(UserRepository, scope=RequestContext) + +# Initialize the Flask DI extension +init_app(app, services) + + +@app.get("/") +def index() -> str: + repo = service_provider.resolve(UserRepository) + return f"Hello, {repo.get_current_user()}!\n" + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/localpost/di/flask.py b/localpost/di/flask.py index 814aab5..a3c6902 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -6,19 +6,59 @@ from dataclasses import dataclass, field from typing import final -from localpost.di._services import ResolutionContext +from flask import Flask, Request, g, request + +from localpost.di._services import ( + AppContext, + ResolutionContext, + ServiceRegistry, + scope, +) @final @dataclass(frozen=True, eq=False, slots=True) -class AppContext(ResolutionContext): +class RequestContext(ResolutionContext): ctx: ExitStack = field(default_factory=ExitStack) def enter[T](self, cm: AbstractContextManager[T]) -> T: return self.ctx.enter_context(cm) -# TODO Implement, enter a new RequestContext scope for each request +def init_app(app: Flask, registry: ServiceRegistry) -> None: + """Initialize DI for a Flask app. + + Opens an app-level scope (lazily, on first request) and a request-level scope per request. + Flask's Request object is automatically available for injection in request-scoped services. + """ + # Register Flask Request with RequestContext scope + registry.register(Request, lambda: request, RequestContext) + + app_scope_cm: AbstractContextManager | None = None + + @app.before_request + def _open_scopes() -> None: + nonlocal app_scope_cm + + # Lazily open the app scope on first request + if app_scope_cm is None: + app_ctx = AppContext() + app_scope_cm = scope(registry, app_ctx) + app_scope_cm.__enter__() # noqa: PLC2801 + + # Open a request-level scope (nested under the app scope) + req_ctx = RequestContext() + req_scope_cm = scope(registry, req_ctx) + req_scope_cm.__enter__() # noqa: PLC2801 + g._localpost_req_ctx = req_ctx + g._localpost_req_scope_cm = req_scope_cm -# TODO Register Flask Request in the DI container, with RequestContext scope + @app.teardown_request + def _close_request_scope(exc: BaseException | None) -> None: + req_scope_cm = getattr(g, "_localpost_req_scope_cm", None) + req_ctx: RequestContext | None = getattr(g, "_localpost_req_ctx", None) + if req_scope_cm is not None: + req_scope_cm.__exit__(type(exc) if exc else None, exc, None) + if req_ctx is not None: + req_ctx.ctx.close() From 33950bd72d656f322dea7470349b5914af9807d3 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 10:56:23 +0000 Subject: [PATCH 051/286] WIP on DI, Flask via Cheroot --- examples/di/flask_app.py | 15 ++++++++++----- localpost/di/flask.py | 31 +++++++------------------------ pyproject.toml | 1 + uv.lock | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py index 270a723..c46b1d8 100644 --- a/examples/di/flask_app.py +++ b/examples/di/flask_app.py @@ -1,6 +1,7 @@ from collections.abc import Generator from dataclasses import dataclass +from cheroot.wsgi import Server from flask import Flask, Request from localpost.di._services import ServiceRegistry, service_provider @@ -40,15 +41,12 @@ def get_current_user(self) -> str: return f"user (from {self.request_path}, db={self.db.dsn})" -app = Flask(__name__) - -# Set up services services = ServiceRegistry() services.register_value(Config(db_dsn=":memory:")) services.register(DBConnectionPool, create_db_pool) services.register(UserRepository, scope=RequestContext) -# Initialize the Flask DI extension +app = Flask(__name__) init_app(app, services) @@ -59,4 +57,11 @@ def index() -> str: if __name__ == "__main__": - app.run(debug=True) + server = Server(("127.0.0.1", 8080), app) + with services.app_scope(): + print("Listening on http://127.0.0.1:8080") + try: + server.start() + except KeyboardInterrupt: + server.stop() + print("App scope closed, all resources cleaned up.") diff --git a/localpost/di/flask.py b/localpost/di/flask.py index a3c6902..a0750d1 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -9,7 +9,6 @@ from flask import Flask, Request, g, request from localpost.di._services import ( - AppContext, ResolutionContext, ServiceRegistry, scope, @@ -26,39 +25,23 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: def init_app(app: Flask, registry: ServiceRegistry) -> None: - """Initialize DI for a Flask app. + """Initialize request-scoped DI for a Flask app. - Opens an app-level scope (lazily, on first request) and a request-level scope per request. - Flask's Request object is automatically available for injection in request-scoped services. + Opens a RequestContext scope per request. Flask's Request object is automatically + available for injection in request-scoped services. + + The app-level scope must be managed externally (e.g. via Gunicorn hooks). """ - # Register Flask Request with RequestContext scope registry.register(Request, lambda: request, RequestContext) - app_scope_cm: AbstractContextManager | None = None - @app.before_request - def _open_scopes() -> None: - nonlocal app_scope_cm - - # Lazily open the app scope on first request - if app_scope_cm is None: - app_ctx = AppContext() - app_scope_cm = scope(registry, app_ctx) - app_scope_cm.__enter__() # noqa: PLC2801 - - # Open a request-level scope (nested under the app scope) + def _open_request_scope() -> None: req_ctx = RequestContext() - req_scope_cm = scope(registry, req_ctx) - req_scope_cm.__enter__() # noqa: PLC2801 - + req_ctx.enter(scope(registry, req_ctx)) g._localpost_req_ctx = req_ctx - g._localpost_req_scope_cm = req_scope_cm @app.teardown_request def _close_request_scope(exc: BaseException | None) -> None: - req_scope_cm = getattr(g, "_localpost_req_scope_cm", None) req_ctx: RequestContext | None = getattr(g, "_localpost_req_ctx", None) - if req_scope_cm is not None: - req_scope_cm.__exit__(type(exc) if exc else None, exc, None) if req_ctx is not None: req_ctx.ctx.close() diff --git a/pyproject.toml b/pyproject.toml index cde951d..e8eea7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ dev-consumers = [ ] dev-http = [ "flask ~=3.1", + "cheroot ~=10.0", # "defspec ~=0.5", # Replaced by our own implementation "pydantic ~=2.11", "a2wsgi", diff --git a/uv.lock b/uv.lock index 0e66e2e..5d51081 100644 --- a/uv.lock +++ b/uv.lock @@ -329,6 +329,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, ] +[[package]] +name = "cheroot" +version = "10.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-functools" }, + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/e2/f85981a51281bd30525bf664309332faa7c81782bb49e331af603421dbd1/cheroot-10.0.1.tar.gz", hash = "sha256:e0b82f797658d26b8613ec8eb563c3b08e6bd6a7921e9d5089bd1175ad1b1740", size = 167586, upload-time = "2024-04-22T15:21:24.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/9e/111f4441bc0e4736251ee0e61094904432ab19292a4cc7bd7127ed94f81e/cheroot-10.0.1-py3-none-any.whl", hash = "sha256:6ea332f20bfcede14e66174d112b30e9807492320d737ca628badc924d997595", size = 104758, upload-time = "2024-04-22T15:21:14.74Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -1001,6 +1014,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1084,6 +1109,7 @@ dev-hosting-services = [ ] dev-http = [ { name = "a2wsgi" }, + { name = "cheroot" }, { name = "flask" }, { name = "pydantic" }, ] @@ -1160,6 +1186,7 @@ dev-hosting-services = [ ] dev-http = [ { name = "a2wsgi" }, + { name = "cheroot", specifier = "~=10.0" }, { name = "flask", specifier = "~=3.1" }, { name = "pydantic", specifier = "~=2.11" }, ] @@ -1259,6 +1286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "msal" version = "1.35.1" From 0e698feb35a4cde300f05f228c2badb01b0e055e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 10:56:31 +0000 Subject: [PATCH 052/286] WIP on DI, Flask via Cheroot --- examples/di/flask_app.py | 15 ++++++++++----- localpost/di/_services.py | 13 ++++++++++--- localpost/di/flask.py | 2 +- pyproject.toml | 2 +- uv.lock | 8 ++++---- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py index c46b1d8..e9c2685 100644 --- a/examples/di/flask_app.py +++ b/examples/di/flask_app.py @@ -1,3 +1,4 @@ +import signal from collections.abc import Generator from dataclasses import dataclass @@ -56,12 +57,16 @@ def index() -> str: return f"Hello, {repo.get_current_user()}!\n" -if __name__ == "__main__": +def main(): server = Server(("127.0.0.1", 8080), app) + signal.signal(signal.SIGINT, lambda *_: server.stop()) + signal.signal(signal.SIGTERM, lambda *_: server.stop()) + with services.app_scope(): print("Listening on http://127.0.0.1:8080") - try: - server.start() - except KeyboardInterrupt: - server.stop() + server.start() print("App scope closed, all resources cleaned up.") + + +if __name__ == "__main__": + main() diff --git a/localpost/di/_services.py b/localpost/di/_services.py index d6bbcd0..812a7ae 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -92,8 +92,10 @@ def current_provider() -> ServiceProvider: @contextmanager -def scope(registry: ServiceRegistry, ctx: ResolutionContext, /) -> Generator[ServiceProvider]: - provider = _ServiceProvider(parent=_provider.get(NULL_PROVIDER), registry=registry, scope=ctx) +def scope( + registry: ServiceRegistry, ctx: ResolutionContext, /, parent: ServiceProvider | None = None +) -> Generator[ServiceProvider]: + provider = _ServiceProvider(parent=parent or _provider.get(NULL_PROVIDER), registry=registry, scope=ctx) contextvar_token = _provider.set(provider) try: yield provider @@ -182,6 +184,7 @@ def cm(provider: ServiceProvider) -> Generator[T]: class ServiceRegistry: def __init__(self): self.descriptors: dict[type, ServiceDescriptor] = {} + self._app_provider: ServiceProvider | None = None def register_value[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None @@ -206,4 +209,8 @@ def register[T]( @contextmanager def app_scope(self) -> Generator[ServiceProvider]: with ExitStack() as ctx, scope(self, AppContext(ctx)) as provider: - yield provider + self._app_provider = provider + try: + yield provider + finally: + self._app_provider = None diff --git a/localpost/di/flask.py b/localpost/di/flask.py index a0750d1..243e584 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -37,7 +37,7 @@ def init_app(app: Flask, registry: ServiceRegistry) -> None: @app.before_request def _open_request_scope() -> None: req_ctx = RequestContext() - req_ctx.enter(scope(registry, req_ctx)) + req_ctx.enter(scope(registry, req_ctx, parent=registry._app_provider)) g._localpost_req_ctx = req_ctx @app.teardown_request diff --git a/pyproject.toml b/pyproject.toml index e8eea7b..75bf50c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ dev-consumers = [ ] dev-http = [ "flask ~=3.1", - "cheroot ~=10.0", + "cheroot ~=11.1", # "defspec ~=0.5", # Replaced by our own implementation "pydantic ~=2.11", "a2wsgi", diff --git a/uv.lock b/uv.lock index 5d51081..315ddaf 100644 --- a/uv.lock +++ b/uv.lock @@ -331,15 +331,15 @@ wheels = [ [[package]] name = "cheroot" -version = "10.0.1" +version = "11.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jaraco-functools" }, { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/e2/f85981a51281bd30525bf664309332faa7c81782bb49e331af603421dbd1/cheroot-10.0.1.tar.gz", hash = "sha256:e0b82f797658d26b8613ec8eb563c3b08e6bd6a7921e9d5089bd1175ad1b1740", size = 167586, upload-time = "2024-04-22T15:21:24.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/e4/5c2020b60a55aca8d79ed55b62ad1cd7fc47ea44ad6b584e83f5f1bf58b0/cheroot-11.1.2.tar.gz", hash = "sha256:bfb70c49663f63b0440f2b54dbc6b0d1650e56dfe4e2641f59b2c6f727b44aca", size = 185716, upload-time = "2025-11-07T17:26:54.818Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/9e/111f4441bc0e4736251ee0e61094904432ab19292a4cc7bd7127ed94f81e/cheroot-10.0.1-py3-none-any.whl", hash = "sha256:6ea332f20bfcede14e66174d112b30e9807492320d737ca628badc924d997595", size = 104758, upload-time = "2024-04-22T15:21:14.74Z" }, + { url = "https://files.pythonhosted.org/packages/41/99/af65511a10c4212438ac52bc5e45e486e7a04d292201ad84dfd9208fe9a8/cheroot-11.1.2-py3-none-any.whl", hash = "sha256:0f6c0ba05c00fbc869fb46b1de4ec2384e1d85418ae963d3bc10ae83b688dbfa", size = 109248, upload-time = "2025-11-07T17:26:53.393Z" }, ] [[package]] @@ -1186,7 +1186,7 @@ dev-hosting-services = [ ] dev-http = [ { name = "a2wsgi" }, - { name = "cheroot", specifier = "~=10.0" }, + { name = "cheroot", specifier = "~=11.1" }, { name = "flask", specifier = "~=3.1" }, { name = "pydantic", specifier = "~=2.11" }, ] From 3e3b05a9bde787491e3ed65aac27706221897d58 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 11:18:39 +0000 Subject: [PATCH 053/286] WIP on DI, Flask via Cheroot (proper contextvar middleware) --- examples/di/flask_app.py | 10 +++++----- localpost/di/_services.py | 13 +++---------- localpost/di/flask.py | 28 +++++++++++++++++++++++++--- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py index e9c2685..7de7dd7 100644 --- a/examples/di/flask_app.py +++ b/examples/di/flask_app.py @@ -6,7 +6,7 @@ from flask import Flask, Request from localpost.di._services import ServiceRegistry, service_provider -from localpost.di.flask import RequestContext, init_app +from localpost.di.flask import RequestContext, init_app, propagate_context @dataclass @@ -58,11 +58,11 @@ def index() -> str: def main(): - server = Server(("127.0.0.1", 8080), app) - signal.signal(signal.SIGINT, lambda *_: server.stop()) - signal.signal(signal.SIGTERM, lambda *_: server.stop()) - with services.app_scope(): + # Wrap after entering app scope so worker threads inherit the context + server = Server(("127.0.0.1", 8080), propagate_context(app)) + signal.signal(signal.SIGINT, lambda *_: server.stop()) + signal.signal(signal.SIGTERM, lambda *_: server.stop()) print("Listening on http://127.0.0.1:8080") server.start() print("App scope closed, all resources cleaned up.") diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 812a7ae..d6bbcd0 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -92,10 +92,8 @@ def current_provider() -> ServiceProvider: @contextmanager -def scope( - registry: ServiceRegistry, ctx: ResolutionContext, /, parent: ServiceProvider | None = None -) -> Generator[ServiceProvider]: - provider = _ServiceProvider(parent=parent or _provider.get(NULL_PROVIDER), registry=registry, scope=ctx) +def scope(registry: ServiceRegistry, ctx: ResolutionContext, /) -> Generator[ServiceProvider]: + provider = _ServiceProvider(parent=_provider.get(NULL_PROVIDER), registry=registry, scope=ctx) contextvar_token = _provider.set(provider) try: yield provider @@ -184,7 +182,6 @@ def cm(provider: ServiceProvider) -> Generator[T]: class ServiceRegistry: def __init__(self): self.descriptors: dict[type, ServiceDescriptor] = {} - self._app_provider: ServiceProvider | None = None def register_value[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None @@ -209,8 +206,4 @@ def register[T]( @contextmanager def app_scope(self) -> Generator[ServiceProvider]: with ExitStack() as ctx, scope(self, AppContext(ctx)) as provider: - self._app_provider = provider - try: - yield provider - finally: - self._app_provider = None + yield provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py index 243e584..9c682e3 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -2,9 +2,11 @@ from __future__ import annotations +import contextvars +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, ExitStack from dataclasses import dataclass, field -from typing import final +from typing import Any, final from flask import Flask, Request, g, request @@ -24,20 +26,40 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: return self.ctx.enter_context(cm) +type WSGIApp = Callable[[dict[str, Any], Callable[..., Any]], Iterable[bytes]] + + +def propagate_context(wsgi_app: WSGIApp) -> WSGIApp: + """WSGI middleware that propagates the calling thread's context vars to worker threads. + + Captures the context at the time of wrapping, then runs each request in a fresh copy. + This makes context vars (e.g. the DI app scope) visible in threaded servers like Cheroot. + """ + base_ctx = contextvars.copy_context() + + def middleware(environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: + # Each request gets its own copy so context var changes are isolated per request + request_ctx = base_ctx.run(contextvars.copy_context) + return request_ctx.run(wsgi_app, environ, start_response) + + return middleware + + def init_app(app: Flask, registry: ServiceRegistry) -> None: """Initialize request-scoped DI for a Flask app. Opens a RequestContext scope per request. Flask's Request object is automatically available for injection in request-scoped services. - The app-level scope must be managed externally (e.g. via Gunicorn hooks). + The app-level scope must be managed externally (e.g. wrapped around the WSGI server). + Use `propagate_context()` to make the app scope visible in threaded servers. """ registry.register(Request, lambda: request, RequestContext) @app.before_request def _open_request_scope() -> None: req_ctx = RequestContext() - req_ctx.enter(scope(registry, req_ctx, parent=registry._app_provider)) + req_ctx.enter(scope(registry, req_ctx)) g._localpost_req_ctx = req_ctx @app.teardown_request From 7f8c731207fdbc0bf9997ba1a50765ade79a771b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 12:04:49 +0000 Subject: [PATCH 054/286] WIP on DI --- examples/di/basic_cleanup.py | 2 +- localpost/di/flask.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/di/basic_cleanup.py b/examples/di/basic_cleanup.py index 5320c7c..b3672dc 100644 --- a/examples/di/basic_cleanup.py +++ b/examples/di/basic_cleanup.py @@ -2,7 +2,7 @@ from icecream import ic -from localpost.di._services import AppContext, ServiceRegistry, ServiceProvider +from localpost.di._services import AppContext, ServiceProvider, ServiceRegistry @dataclass diff --git a/localpost/di/flask.py b/localpost/di/flask.py index 9c682e3..5405f04 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -3,10 +3,11 @@ from __future__ import annotations import contextvars -from collections.abc import Callable, Iterable +from collections.abc import Iterable from contextlib import AbstractContextManager, ExitStack from dataclasses import dataclass, field -from typing import Any, final +from typing import final +from wsgiref.types import StartResponse, WSGIApplication, WSGIEnvironment from flask import Flask, Request, g, request @@ -26,10 +27,7 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: return self.ctx.enter_context(cm) -type WSGIApp = Callable[[dict[str, Any], Callable[..., Any]], Iterable[bytes]] - - -def propagate_context(wsgi_app: WSGIApp) -> WSGIApp: +def propagate_context(wsgi_app: WSGIApplication) -> WSGIApplication: """WSGI middleware that propagates the calling thread's context vars to worker threads. Captures the context at the time of wrapping, then runs each request in a fresh copy. @@ -37,7 +35,7 @@ def propagate_context(wsgi_app: WSGIApp) -> WSGIApp: """ base_ctx = contextvars.copy_context() - def middleware(environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: + def middleware(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: # Each request gets its own copy so context var changes are isolated per request request_ctx = base_ctx.run(contextvars.copy_context) return request_ctx.run(wsgi_app, environ, start_response) From 9f515b188d55eb2a56e042a622e2d543be1e84d5 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 12:05:04 +0000 Subject: [PATCH 055/286] format --- localpost/hosting/__init__.py | 2 +- localpost/http/_service.py | 4 ++-- localpost/openapi/pydantic.py | 6 +----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index aa77c1a..d5bc80b 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -4,9 +4,9 @@ from ._host import ( Running, ServiceF, - ServiceState, ServiceLifetime, ServiceLifetimeView, + ServiceState, ShuttingDown, Starting, Stopped, diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 8a0ece9..4e14576 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,12 +1,12 @@ from collections.abc import Awaitable from wsgiref.types import WSGIApplication -from anyio import create_task_group, to_thread, from_thread, CapacityLimiter +from anyio import CapacityLimiter, from_thread, to_thread from localpost import hosting from localpost.hosting import ServiceLifetime from localpost.http.config import ServerConfig -from localpost.http.server import start_http_server, RequestHandler +from localpost.http.server import RequestHandler, start_http_server from localpost.threadtools import create_executor diff --git a/localpost/openapi/pydantic.py b/localpost/openapi/pydantic.py index e33de6a..6b1e67d 100644 --- a/localpost/openapi/pydantic.py +++ b/localpost/openapi/pydantic.py @@ -1,10 +1,6 @@ from __future__ import annotations -import inspect -from collections.abc import Collection, Iterable -from typing import Any, Protocol - -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import BaseModel import localpost.spec.openapi as openapi_spec from localpost.http.openapi.app import BadRequest, OpResult From 368f372dbb5150ebce05a7be50abf39d97313cc3 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 22:53:07 +0000 Subject: [PATCH 056/286] WIP on DI (Flask) --- examples/di/flask_app.py | 14 +++++----- localpost/_utils.py | 14 +++++++++- localpost/di/_services.py | 53 +++++++++++++++++------------------ localpost/di/flask.py | 58 +++++++++++++++++---------------------- tests/di/services.py | 10 +++---- 5 files changed, 77 insertions(+), 72 deletions(-) diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py index 7de7dd7..7156f63 100644 --- a/examples/di/flask_app.py +++ b/examples/di/flask_app.py @@ -6,7 +6,7 @@ from flask import Flask, Request from localpost.di._services import ServiceRegistry, service_provider -from localpost.di.flask import RequestContext, init_app, propagate_context +from localpost.di.flask import RequestContext, init_app @dataclass @@ -48,7 +48,6 @@ def get_current_user(self) -> str: services.register(UserRepository, scope=RequestContext) app = Flask(__name__) -init_app(app, services) @app.get("/") @@ -58,11 +57,12 @@ def index() -> str: def main(): - with services.app_scope(): - # Wrap after entering app scope so worker threads inherit the context - server = Server(("127.0.0.1", 8080), propagate_context(app)) - signal.signal(signal.SIGINT, lambda *_: server.stop()) - signal.signal(signal.SIGTERM, lambda *_: server.stop()) + server = Server(("127.0.0.1", 8080), app) + signal.signal(signal.SIGINT, lambda *_: server.stop()) + signal.signal(signal.SIGTERM, lambda *_: server.stop()) + + with services.app_scope() as app_scope: + init_app(app, services, app_scope) print("Listening on http://127.0.0.1:8080") server.start() print("App scope closed, all resources cleaned up.") diff --git a/localpost/_utils.py b/localpost/_utils.py index 6786fef..52307b4 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -9,7 +9,8 @@ import sys import typing from collections.abc import Awaitable, Callable, Coroutine, Generator, Iterable -from contextlib import AbstractAsyncContextManager, AbstractContextManager +from contextlib import AbstractAsyncContextManager, AbstractContextManager, contextmanager +from contextvars import ContextVar from datetime import timedelta from functools import wraps from typing import ( @@ -41,6 +42,7 @@ from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from execnet.script.socketserver import old from typing_extensions import TypeVar T = TypeVar("T", default=Any) @@ -101,6 +103,16 @@ class _SupportsAsyncClose(Protocol): def aclose(self) -> Awaitable[Any]: ... +@contextmanager +def set_cvar[T](cvar: ContextVar[T], value: T) -> Generator[T]: + """To emulate what Python 3.14 cvar.set() does by default.""" + old_value = cvar.set(value) + try: + yield value + finally: + cvar.reset(old_value) + + # TODO Remove @final class ClosingContext[T](AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): diff --git a/localpost/di/_services.py b/localpost/di/_services.py index d6bbcd0..b3e07f8 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -5,7 +5,10 @@ from contextlib import AbstractContextManager, ExitStack, contextmanager from contextvars import ContextVar from dataclasses import dataclass, field -from typing import Final, Protocol, cast, final, get_type_hints +from dataclasses import dataclass as define +from typing import Final, Protocol, Self, cast, final, get_type_hints + +from localpost._utils import set_cvar class ResolutionContext(Protocol): @@ -13,7 +16,7 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: ... @final -@dataclass(frozen=True, eq=False, slots=True) +@define(frozen=True, eq=False, slots=True) class AppContext(ResolutionContext): ctx: ExitStack = field(default_factory=ExitStack) @@ -34,8 +37,9 @@ class ServiceNotRegisteredError(ValueError): """Raised when resolving a service that is not registered.""" -@dataclass(frozen=True, slots=True) -class _ServiceProvider(ServiceProvider): +@final +@define(frozen=True, slots=True) +class DefaultServiceProvider(ServiceProvider): parent: ServiceProvider registry: ServiceRegistry scope: ResolutionContext @@ -43,6 +47,12 @@ class _ServiceProvider(ServiceProvider): services: dict[type, object] = field(default_factory=dict) """Resolved services, keyed by service type.""" + @classmethod + def from_current(cls, scope: ResolutionContext) -> Self: + if parent := current_provider.get(None): + return cls(parent=parent, registry=parent.registry, scope=scope) + raise RuntimeError("No active DI scope") # Did you forget to enter the app scope? + def resolve[T](self, service_type: type[T], /) -> T: # Well-known DI types if service_type is ServiceProvider: @@ -79,34 +89,23 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: raise RuntimeError("No active DI scope") -NULL_PROVIDER: Final[ServiceProvider] = NullServiceProvider() - - -_provider: ContextVar[ServiceProvider] = ContextVar("_provider") - - -def current_provider() -> ServiceProvider: - if provider := _provider.get(None): - return provider - raise RuntimeError("No active DI scope") - - @contextmanager -def scope(registry: ServiceRegistry, ctx: ResolutionContext, /) -> Generator[ServiceProvider]: - provider = _ServiceProvider(parent=_provider.get(NULL_PROVIDER), registry=registry, scope=ctx) - contextvar_token = _provider.set(provider) - try: +def scope(ctx: ResolutionContext, /) -> Generator[ServiceProvider]: + with set_cvar(current_provider, DefaultServiceProvider.from_current(ctx)) as provider: yield provider - finally: - _provider.reset(contextvar_token) class CurrentServiceProvider(ServiceProvider): def resolve[T](self, service_type: type[T], /) -> T: - return current_provider().resolve(service_type) + if provider := current_provider.get(None): + return provider.resolve(service_type) + raise RuntimeError("No active DI scope") service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() +"""Proxy for the current DI service provider.""" +current_provider: ContextVar[DefaultServiceProvider] = ContextVar("current_provider") +NULL_PROVIDER: Final[ServiceProvider] = NullServiceProvider() # A factory that returns a context manager — the provider enters it to get the instance and manage its lifecycle. @@ -117,7 +116,7 @@ def resolve[T](self, service_type: type[T], /) -> T: class ServiceDescriptor[T]: service_type: type[T] scope: type[ResolutionContext] - factory: ServiceFactory[T] + factory: ServiceFactory[T] = field(compare=False, repr=False) def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> list[tuple[str, type]]: @@ -204,6 +203,8 @@ def register[T]( self.descriptors[service_type] = sd @contextmanager - def app_scope(self) -> Generator[ServiceProvider]: - with ExitStack() as ctx, scope(self, AppContext(ctx)) as provider: + def app_scope(self) -> Generator[DefaultServiceProvider]: + app_scope = AppContext() + provider = DefaultServiceProvider(NULL_PROVIDER, self, app_scope) + with app_scope.ctx, set_cvar(current_provider, provider): yield provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py index 5405f04..fee4e84 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -2,21 +2,24 @@ from __future__ import annotations -import contextvars -from collections.abc import Iterable -from contextlib import AbstractContextManager, ExitStack +from collections.abc import Generator +from contextlib import AbstractContextManager, ExitStack, contextmanager from dataclasses import dataclass, field -from typing import final -from wsgiref.types import StartResponse, WSGIApplication, WSGIEnvironment +from typing import Any, final from flask import Flask, Request, g, request +from localpost._utils import set_cvar from localpost.di._services import ( + DefaultServiceProvider, ResolutionContext, + ServiceProvider, ServiceRegistry, - scope, + current_provider, ) +_EXT_KEY = "localpost.di.provider" + @final @dataclass(frozen=True, eq=False, slots=True) @@ -27,41 +30,30 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: return self.ctx.enter_context(cm) -def propagate_context(wsgi_app: WSGIApplication) -> WSGIApplication: - """WSGI middleware that propagates the calling thread's context vars to worker threads. - - Captures the context at the time of wrapping, then runs each request in a fresh copy. - This makes context vars (e.g. the DI app scope) visible in threaded servers like Cheroot. - """ - base_ctx = contextvars.copy_context() - - def middleware(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: - # Each request gets its own copy so context var changes are isolated per request - request_ctx = base_ctx.run(contextvars.copy_context) - return request_ctx.run(wsgi_app, environ, start_response) - - return middleware - - -def init_app(app: Flask, registry: ServiceRegistry) -> None: - """Initialize request-scoped DI for a Flask app. +def init_app(app: Flask, registry: ServiceRegistry, provider: ServiceProvider) -> None: + """Initialize DI for a Flask app. Opens a RequestContext scope per request. Flask's Request object is automatically available for injection in request-scoped services. - - The app-level scope must be managed externally (e.g. wrapped around the WSGI server). - Use `propagate_context()` to make the app scope visible in threaded servers. """ registry.register(Request, lambda: request, RequestContext) + app.extensions[_EXT_KEY] = (registry, provider) + + @contextmanager + def flask_req_ctx() -> Generator[None]: + registry, parent = app.extensions[_EXT_KEY] + req_scope = RequestContext() + provider = DefaultServiceProvider(parent, registry, req_scope) + with req_scope.ctx, set_cvar(current_provider, provider): + yield @app.before_request def _open_request_scope() -> None: - req_ctx = RequestContext() - req_ctx.enter(scope(registry, req_ctx)) - g._localpost_req_ctx = req_ctx + g._localpost_di_scope = ctx = flask_req_ctx() + ctx.__enter__() @app.teardown_request def _close_request_scope(exc: BaseException | None) -> None: - req_ctx: RequestContext | None = getattr(g, "_localpost_req_ctx", None) - if req_ctx is not None: - req_ctx.ctx.close() + ctx: AbstractContextManager[Any] | None = getattr(g, "_localpost_di_scope", None) + if ctx is not None: + ctx.__exit__(exc) diff --git a/tests/di/services.py b/tests/di/services.py index 46e5f65..81fb74d 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -9,7 +9,7 @@ ServiceProvider, ServiceRegistry, _factory_for_type, - current_provider, + get_current_provider, scope, service_provider, ) @@ -161,7 +161,7 @@ def test_sets_context_var(self): with registry.app_scope(): # current_provider() should work inside the scope - provider = current_provider() + provider = get_current_provider() config = provider.resolve(Config) assert config.host == "localhost" @@ -172,7 +172,7 @@ def test_resets_context_var_after_exit(self): pass with pytest.raises(RuntimeError, match="No active DI scope"): - current_provider() + get_current_provider() def test_service_provider_proxy(self): """The module-level `service_provider` proxy should delegate to the current scope.""" @@ -202,10 +202,10 @@ def test_nested_scopes(self): with inner_ctx.ctx: with scope(inner_registry, inner_ctx) as inner_provider: assert inner_provider.resolve(Config).host == "inner" - assert current_provider().resolve(Config).host == "inner" + assert get_current_provider().resolve(Config).host == "inner" # After exiting inner scope, outer is restored - assert current_provider().resolve(Config).host == "outer" + assert get_current_provider().resolve(Config).host == "outer" class TestAppScopeLifecycle: From 78d2131208df521bd28404a17f87e9d9ee10d25e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 20 Mar 2026 23:11:08 +0000 Subject: [PATCH 057/286] fix: WIP on DI (Flask) --- localpost/_utils.py | 1 - localpost/di/flask.py | 2 +- tests/di/services.py | 38 +++++++++++++++++--------------------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/localpost/_utils.py b/localpost/_utils.py index 52307b4..3aebb2c 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -42,7 +42,6 @@ from anyio.abc import TaskGroup, TaskStatus from anyio.lowlevel import checkpoint from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from execnet.script.socketserver import old from typing_extensions import TypeVar T = TypeVar("T", default=Any) diff --git a/localpost/di/flask.py b/localpost/di/flask.py index fee4e84..d27cfc9 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -56,4 +56,4 @@ def _open_request_scope() -> None: def _close_request_scope(exc: BaseException | None) -> None: ctx: AbstractContextManager[Any] | None = getattr(g, "_localpost_di_scope", None) if ctx is not None: - ctx.__exit__(exc) + ctx.__exit__(type(exc) if exc else None, exc, None) diff --git a/tests/di/services.py b/tests/di/services.py index 81fb74d..2a8f837 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -3,14 +3,15 @@ import pytest +from localpost._utils import set_cvar from localpost.di._services import ( AppContext, + DefaultServiceProvider, ServiceNotRegisteredError, ServiceProvider, ServiceRegistry, _factory_for_type, - get_current_provider, - scope, + current_provider, service_provider, ) @@ -121,17 +122,13 @@ def test_resolve_unregistered_raises(self): def test_resolve_unregistered_in_nested_scope_raises(self): """Unregistered type should raise even when a parent scope exists.""" - outer_registry = ServiceRegistry() - outer_registry.register_value(Config(host="outer", port=1)) - - inner_registry = ServiceRegistry() - # Database is not registered in inner_registry + registry = ServiceRegistry() + registry.register_value(Config(host="outer", port=1)) + # Database is not registered - with outer_registry.app_scope(): - inner_ctx = AppContext() - with inner_ctx.ctx, scope(inner_registry, inner_ctx) as inner_provider: - with pytest.raises(ServiceNotRegisteredError): - inner_provider.resolve(Database) + with registry.app_scope() as provider: + with pytest.raises(ServiceNotRegisteredError): + provider.resolve(Database) def test_getitem(self): registry = ServiceRegistry() @@ -161,7 +158,7 @@ def test_sets_context_var(self): with registry.app_scope(): # current_provider() should work inside the scope - provider = get_current_provider() + provider = current_provider.get() config = provider.resolve(Config) assert config.host == "localhost" @@ -171,8 +168,7 @@ def test_resets_context_var_after_exit(self): with registry.app_scope(): pass - with pytest.raises(RuntimeError, match="No active DI scope"): - get_current_provider() + assert current_provider.get(None) is None def test_service_provider_proxy(self): """The module-level `service_provider` proxy should delegate to the current scope.""" @@ -194,18 +190,18 @@ def test_nested_scopes(self): with registry.app_scope() as outer_provider: assert outer_provider.resolve(Config).host == "outer" - # Create a new registry for the inner scope with a different value + # Override the value in the same registry for the inner scope inner_registry = ServiceRegistry() inner_registry.register_value(inner_config) inner_ctx = AppContext() - with inner_ctx.ctx: - with scope(inner_registry, inner_ctx) as inner_provider: - assert inner_provider.resolve(Config).host == "inner" - assert get_current_provider().resolve(Config).host == "inner" + inner_provider = DefaultServiceProvider(outer_provider, inner_registry, inner_ctx) + with inner_ctx.ctx, set_cvar(current_provider, inner_provider): + assert inner_provider.resolve(Config).host == "inner" + assert current_provider.get().resolve(Config).host == "inner" # After exiting inner scope, outer is restored - assert get_current_provider().resolve(Config).host == "outer" + assert current_provider.get().resolve(Config).host == "outer" class TestAppScopeLifecycle: From 91399b8c15f8017cef02f40fff6b48c1c0f1b74e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 21 Mar 2026 18:34:51 +0000 Subject: [PATCH 058/286] WIP on DI --- localpost/di/README.md | 12 +++++------ localpost/di/_services.py | 43 +++++++++++++++------------------------ localpost/di/flask.py | 2 +- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/localpost/di/README.md b/localpost/di/README.md index 171e2ca..6d4ae13 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -6,11 +6,9 @@ Inspired by .NET DependencyInjection - no async for now (maybe in the future) - Scope is defined by it's type -- Only one registration per type -- Service Locator (no @inject decorator as in other DI-oriented frameworks) +- Only one registration per type (oposite to .NET DependencyInjection, where it's common to register multiple implentation for an interface, like IHostedService) +- A type can be registered multiple times, once per scope type +- Service Locator (no built-in @inject decorator as in other DI-oriented frameworks) - Automatic wiring, if a service is resolved via ServiceProvider - -## TODO - -- multiple services for the same type... Like IHostedService in .NET ? - - it creates confusion also, as you now can resolve first or list... +- Instance factory can be a generator func, to do custom startup / shutdown +- An instance can be resolved on request (default) or when the scope is entered (create_on_enter=True) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index b3e07f8..66327a7 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -1,12 +1,12 @@ from __future__ import annotations import inspect -from collections.abc import Callable, Generator -from contextlib import AbstractContextManager, ExitStack, contextmanager +from collections.abc import Callable, Generator, Iterable, Iterator +from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext from contextvars import ContextVar from dataclasses import dataclass, field from dataclasses import dataclass as define -from typing import Final, Protocol, Self, cast, final, get_type_hints +from typing import Any, Final, Protocol, Self, cast, final, get_type_hints from localpost._utils import set_cvar @@ -27,7 +27,11 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: class ServiceProvider(Protocol): """Service provider, scoped to the current resolution context.""" - def resolve[T](self, service_type: type[T], /) -> T: ... + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + """Create an instance of the given type by calling its constructor, resolving any dependencies.""" + + def resolve[T](self, service_type: type[T], /) -> T: + """Create an instance (or take already created one) for the service type, resolving any dependencies.""" def __getitem__[T](self, service_type: type[T], /) -> T: return self.resolve(service_type) @@ -74,19 +78,16 @@ def resolve[T](self, service_type: type[T], /) -> T: # Delegate to parent scope (the service belongs to an outer scope) return self.parent.resolve(service_type) - # Create the service instance via its factory (returns a CM), and enter it in the scope - instance = self.scope.enter(descriptor.factory(self)) - # Cache for future resolutions within this scope - self.services[service_type] = instance + instance = self.services[service_type] = self.scope.enter(descriptor.factory(self)) return instance class NullServiceProvider(ServiceProvider): def resolve[T](self, service_type: type[T], /) -> T: - raise RuntimeError(f"No service of type {service_type} is registered") + raise ServiceNotRegisteredError(f"{service_type} is not registered") def enter[T](self, cm: AbstractContextManager[T]) -> T: - raise RuntimeError("No active DI scope") + raise RuntimeError("not in DI scope") @contextmanager @@ -112,13 +113,6 @@ def resolve[T](self, service_type: type[T], /) -> T: type ServiceFactory[T] = Callable[[ServiceProvider], AbstractContextManager[T]] -@dataclass(frozen=True, slots=True) -class ServiceDescriptor[T]: - service_type: type[T] - scope: type[ResolutionContext] - factory: ServiceFactory[T] = field(compare=False, repr=False) - - def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> list[tuple[str, type]]: """Inspect a callable's parameters and collect (name, type) pairs for auto-wiring.""" hints = get_type_hints(factory) @@ -180,17 +174,13 @@ def cm(provider: ServiceProvider) -> Generator[T]: @final class ServiceRegistry: def __init__(self): - self.descriptors: dict[type, ServiceDescriptor] = {} + # self.descriptors: dict[type, ServiceDescriptor] = {} + self.factories: dict[tuple[type, type[ResolutionContext]], ServiceFactory] = {} - def register_value[T]( + def register_instance[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None ) -> None: - @contextmanager - def value_factory(_: ServiceProvider) -> Generator[T]: - yield value - - sd = ServiceDescriptor(service_type or type(value), scope or AppContext, value_factory) - self.descriptors[sd.service_type] = sd + self.factories[(service_type or type(value), scope or AppContext)] = lambda _: nullcontext(value) def register[T]( self, @@ -199,8 +189,7 @@ def register[T]( scope: type[ResolutionContext] | None = None, ) -> None: wrapped = _make_service_factory(factory) if factory else _factory_for_type(service_type) - sd = ServiceDescriptor(service_type, scope or AppContext, wrapped) - self.descriptors[service_type] = sd + self.factories[(service_type, scope or AppContext)] = wrapped @contextmanager def app_scope(self) -> Generator[DefaultServiceProvider]: diff --git a/localpost/di/flask.py b/localpost/di/flask.py index d27cfc9..bdda9c6 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -30,7 +30,7 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: return self.ctx.enter_context(cm) -def init_app(app: Flask, registry: ServiceRegistry, provider: ServiceProvider) -> None: +def init_app(app: Flask, registry: ServiceRegistry, provider: ServiceProvider, /) -> None: """Initialize DI for a Flask app. Opens a RequestContext scope per request. Flask's Request object is automatically From 1811bcb2fcde11f9402f5520dc6130f422ad2286 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 21 Mar 2026 18:37:55 +0000 Subject: [PATCH 059/286] WIP on DI --- examples/di/basic.py | 2 +- examples/di/basic_cleanup.py | 2 +- examples/di/flask_app.py | 2 +- localpost/di/_services.py | 36 +++++++++++++-------- tests/di/services.py | 62 ++++++++++++++++++++++++++++-------- 5 files changed, 75 insertions(+), 29 deletions(-) diff --git a/examples/di/basic.py b/examples/di/basic.py index 6cc8d8b..ab3d2af 100644 --- a/examples/di/basic.py +++ b/examples/di/basic.py @@ -18,7 +18,7 @@ def __init__(self, config: Config): def main(): services = ServiceRegistry() - services.register_value(Config(host="127.0.0.1", port=8080)) + services.register_instance(Config(host="127.0.0.1", port=8080)) services.register(Server) with services.app_scope() as service_provider: diff --git a/examples/di/basic_cleanup.py b/examples/di/basic_cleanup.py index b3672dc..4dbb32c 100644 --- a/examples/di/basic_cleanup.py +++ b/examples/di/basic_cleanup.py @@ -41,7 +41,7 @@ def close(self): def main(): services = ServiceRegistry() - services.register_value(Config(host="127.0.0.1", port=8080, db_dsn=":memory:")) + services.register_instance(Config(host="127.0.0.1", port=8080, db_dsn=":memory:")) services.register(DBConnectionPool, create_db_conn_pool) services.register(Server) diff --git a/examples/di/flask_app.py b/examples/di/flask_app.py index 7156f63..8922439 100644 --- a/examples/di/flask_app.py +++ b/examples/di/flask_app.py @@ -43,7 +43,7 @@ def get_current_user(self) -> str: services = ServiceRegistry() -services.register_value(Config(db_dsn=":memory:")) +services.register_instance(Config(db_dsn=":memory:")) services.register(DBConnectionPool, create_db_pool) services.register(UserRepository, scope=RequestContext) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 66327a7..9651047 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -1,11 +1,11 @@ from __future__ import annotations import inspect -from collections.abc import Callable, Generator, Iterable, Iterator +from collections.abc import Callable, Generator from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext from contextvars import ContextVar -from dataclasses import dataclass, field from dataclasses import dataclass as define +from dataclasses import field from typing import Any, Final, Protocol, Self, cast, final, get_type_hints from localpost._utils import set_cvar @@ -57,6 +57,12 @@ def from_current(cls, scope: ResolutionContext) -> Self: return cls(parent=parent, registry=parent.registry, scope=scope) raise RuntimeError("No active DI scope") # Did you forget to enter the app scope? + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + """Create an instance of the given type, resolving constructor deps not provided in kwargs.""" + deps = _collect_deps(target_type.__init__, skip_self=True) if "__init__" in target_type.__dict__ else [] + resolved = {name: self.resolve(dep_type) for name, dep_type in deps if name not in kwargs} + return target_type(**resolved, **kwargs) + def resolve[T](self, service_type: type[T], /) -> T: # Well-known DI types if service_type is ServiceProvider: @@ -68,21 +74,20 @@ def resolve[T](self, service_type: type[T], /) -> T: if service_type in self.services: return cast(T, self.services[service_type]) - # Look up the descriptor - descriptor = self.registry.descriptors.get(service_type) - if descriptor is None: - raise ServiceNotRegisteredError(f"{service_type} is not registered") - - # Check if this service belongs to this scope - if not isinstance(self.scope, descriptor.scope): - # Delegate to parent scope (the service belongs to an outer scope) - return self.parent.resolve(service_type) + # Look up the factory for this scope + factory = self.registry.factories.get((service_type, type(self.scope))) + if factory is not None: + instance = self.services[service_type] = self.scope.enter(factory(self)) + return instance - instance = self.services[service_type] = self.scope.enter(descriptor.factory(self)) - return instance + # Delegate to parent scope + return self.parent.resolve(service_type) class NullServiceProvider(ServiceProvider): + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + raise RuntimeError("No active DI scope") + def resolve[T](self, service_type: type[T], /) -> T: raise ServiceNotRegisteredError(f"{service_type} is not registered") @@ -97,6 +102,11 @@ def scope(ctx: ResolutionContext, /) -> Generator[ServiceProvider]: class CurrentServiceProvider(ServiceProvider): + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + if provider := current_provider.get(None): + return provider.create(target_type, **kwargs) + raise RuntimeError("No active DI scope") + def resolve[T](self, service_type: type[T], /) -> T: if provider := current_provider.get(None): return provider.resolve(service_type) diff --git a/tests/di/services.py b/tests/di/services.py index 2a8f837..aa34045 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -45,7 +45,7 @@ def __init__(self, something): class TestFactoryFor: def test_resolves_constructor_params(self): registry = ServiceRegistry() - registry.register_value(Config(host="localhost", port=5432)) + registry.register_instance(Config(host="localhost", port=5432)) registry.register(Database) with registry.app_scope() as provider: @@ -75,7 +75,7 @@ class TestServiceProvider: def test_resolve_registered_value(self): registry = ServiceRegistry() config = Config(host="127.0.0.1", port=8080) - registry.register_value(config) + registry.register_instance(config) with registry.app_scope() as provider: resolved = provider.resolve(Config) @@ -83,7 +83,7 @@ def test_resolve_registered_value(self): def test_resolve_with_auto_wiring(self): registry = ServiceRegistry() - registry.register_value(Config(host="127.0.0.1", port=8080)) + registry.register_instance(Config(host="127.0.0.1", port=8080)) registry.register(Database) with registry.app_scope() as provider: @@ -93,7 +93,7 @@ def test_resolve_with_auto_wiring(self): def test_resolve_transitive_dependencies(self): registry = ServiceRegistry() - registry.register_value(Config(host="localhost", port=5432)) + registry.register_instance(Config(host="localhost", port=5432)) registry.register(Database) registry.register(UserRepository) @@ -105,7 +105,7 @@ def test_resolve_transitive_dependencies(self): def test_resolve_caches_within_scope(self): registry = ServiceRegistry() - registry.register_value(Config(host="localhost", port=5432)) + registry.register_instance(Config(host="localhost", port=5432)) registry.register(Database) with registry.app_scope() as provider: @@ -123,7 +123,7 @@ def test_resolve_unregistered_raises(self): def test_resolve_unregistered_in_nested_scope_raises(self): """Unregistered type should raise even when a parent scope exists.""" registry = ServiceRegistry() - registry.register_value(Config(host="outer", port=1)) + registry.register_instance(Config(host="outer", port=1)) # Database is not registered with registry.app_scope() as provider: @@ -133,7 +133,7 @@ def test_resolve_unregistered_in_nested_scope_raises(self): def test_getitem(self): registry = ServiceRegistry() config = Config(host="localhost", port=5432) - registry.register_value(config) + registry.register_instance(config) with registry.app_scope() as provider: assert provider[Config] is config @@ -151,10 +151,46 @@ def custom_factory() -> Config: assert config.port == 9999 +class TestCreate: + def test_create_resolves_deps(self): + """create() should resolve constructor dependencies from the provider.""" + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + repo = provider.create(UserRepository) + assert isinstance(repo, UserRepository) + assert isinstance(repo.db, Database) + assert repo.db.config.host == "localhost" + + def test_create_with_kwargs_override(self): + """create() should allow overriding specific dependencies via kwargs.""" + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + + custom_db = Database(Config(host="custom", port=9999)) + + with registry.app_scope() as provider: + repo = provider.create(UserRepository, db=custom_db) + assert repo.db is custom_db + + def test_create_no_caching(self): + """create() should return a new instance each time (not cached).""" + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Database) + + with registry.app_scope() as provider: + repo1 = provider.create(UserRepository) + repo2 = provider.create(UserRepository) + assert repo1 is not repo2 + + class TestScope: def test_sets_context_var(self): registry = ServiceRegistry() - registry.register_value(Config(host="localhost", port=5432)) + registry.register_instance(Config(host="localhost", port=5432)) with registry.app_scope(): # current_provider() should work inside the scope @@ -174,7 +210,7 @@ def test_service_provider_proxy(self): """The module-level `service_provider` proxy should delegate to the current scope.""" registry = ServiceRegistry() config = Config(host="proxy-test", port=1234) - registry.register_value(config) + registry.register_instance(config) with registry.app_scope(): assert service_provider.resolve(Config) is config @@ -185,14 +221,14 @@ def test_nested_scopes(self): outer_config = Config(host="outer", port=1) inner_config = Config(host="inner", port=2) - registry.register_value(outer_config) + registry.register_instance(outer_config) with registry.app_scope() as outer_provider: assert outer_provider.resolve(Config).host == "outer" # Override the value in the same registry for the inner scope inner_registry = ServiceRegistry() - inner_registry.register_value(inner_config) + inner_registry.register_instance(inner_config) inner_ctx = AppContext() inner_provider = DefaultServiceProvider(outer_provider, inner_registry, inner_ctx) @@ -224,7 +260,7 @@ def close(self): closed.append("server") registry = ServiceRegistry() - registry.register_value(Config(host="localhost", port=5432)) + registry.register_instance(Config(host="localhost", port=5432)) registry.register(Pool) registry.register(Server) @@ -278,7 +314,7 @@ def db_pool_factory(sp: ServiceProvider) -> Generator[DBPool]: pool.close() registry = ServiceRegistry() - registry.register_value(Config(host="localhost", port=5432)) + registry.register_instance(Config(host="localhost", port=5432)) registry.register(DBPool, db_pool_factory) with registry.app_scope() as provider: From ee9ab44fbe8430697e9756b61c3f93ffb16bcaeb Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 21 Mar 2026 18:43:47 +0000 Subject: [PATCH 060/286] WIP on DI (no magic around close()) --- localpost/di/_services.py | 15 ++++----------- tests/di/services.py | 17 +++-------------- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 9651047..d300841 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -165,19 +165,12 @@ def _factory_for_type[T](service_type: type[T]) -> ServiceFactory[T]: """Create a ServiceFactory that auto-wires a type's constructor.""" # Classes without a custom __init__ inherit object.__init__(*args, **kwargs) — no deps to wire deps = _collect_deps(service_type.__init__, skip_self=True) if "__init__" in service_type.__dict__ else [] - has_close = callable(getattr(service_type, "close", None)) - @contextmanager - def cm(provider: ServiceProvider) -> Generator[T]: + def factory(provider: ServiceProvider) -> AbstractContextManager[T]: kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} - instance = service_type(**kwargs) - try: - yield instance - finally: - if has_close: - instance.close() # type: ignore[union-attr] - - return cm + return nullcontext(service_type(**kwargs)) + + return factory # Kinda like IServiceCollection in .NET, or svcs.Registry diff --git a/tests/di/services.py b/tests/di/services.py index aa34045..ed3fc8f 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -241,8 +241,8 @@ def test_nested_scopes(self): class TestAppScopeLifecycle: - def test_auto_close_on_scope_exit(self): - """Services with close() registered without a factory should be auto-closed on scope exit.""" + def test_no_auto_close_on_scope_exit(self): + """Services registered without a factory should NOT be auto-closed on scope exit.""" closed = [] class Pool: @@ -252,25 +252,14 @@ def __init__(self, config: Config): def close(self): closed.append("pool") - class Server: - def __init__(self, config: Config): - self.config = config - - def close(self): - closed.append("server") - registry = ServiceRegistry() registry.register_instance(Config(host="localhost", port=5432)) registry.register(Pool) - registry.register(Server) with registry.app_scope() as provider: provider.resolve(Pool) - provider.resolve(Server) - assert closed == [] - assert "pool" in closed - assert "server" in closed + assert closed == [] def test_generator_factory_cleanup(self): """A generator factory should run cleanup code after scope exit.""" From 27c7080a52af308f287d559bbff77942c83342a5 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 21 Mar 2026 18:48:02 +0000 Subject: [PATCH 061/286] WIP on DI: create_on_enter --- localpost/di/_services.py | 16 +++++++++-- localpost/di/flask.py | 1 + tests/di/services.py | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index d300841..99f97e0 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -177,8 +177,8 @@ def factory(provider: ServiceProvider) -> AbstractContextManager[T]: @final class ServiceRegistry: def __init__(self): - # self.descriptors: dict[type, ServiceDescriptor] = {} self.factories: dict[tuple[type, type[ResolutionContext]], ServiceFactory] = {} + self._eager: set[tuple[type, type[ResolutionContext]]] = set() def register_instance[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None @@ -190,13 +190,25 @@ def register[T]( service_type: type[T], factory: Callable[..., T | Generator[T]] | None = None, scope: type[ResolutionContext] | None = None, + create_on_enter: bool = False, ) -> None: wrapped = _make_service_factory(factory) if factory else _factory_for_type(service_type) - self.factories[(service_type, scope or AppContext)] = wrapped + key = (service_type, scope or AppContext) + self.factories[key] = wrapped + if create_on_enter: + self._eager.add(key) + + def _resolve_eager(self, provider: DefaultServiceProvider) -> None: + """Eagerly resolve services marked with create_on_enter for the given scope type.""" + scope_type = type(provider.scope) + for svc_type, svc_scope in self._eager: + if svc_scope is scope_type: + provider.resolve(svc_type) @contextmanager def app_scope(self) -> Generator[DefaultServiceProvider]: app_scope = AppContext() provider = DefaultServiceProvider(NULL_PROVIDER, self, app_scope) with app_scope.ctx, set_cvar(current_provider, provider): + self._resolve_eager(provider) yield provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py index bdda9c6..feba96c 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -45,6 +45,7 @@ def flask_req_ctx() -> Generator[None]: req_scope = RequestContext() provider = DefaultServiceProvider(parent, registry, req_scope) with req_scope.ctx, set_cvar(current_provider, provider): + registry._resolve_eager(provider) yield @app.before_request diff --git a/tests/di/services.py b/tests/di/services.py index ed3fc8f..6dc0b7c 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -240,6 +240,63 @@ def test_nested_scopes(self): assert current_provider.get().resolve(Config).host == "outer" +class TestCreateOnEnter: + def test_eager_resolution(self): + """Services with create_on_enter=True should be resolved when the scope is entered.""" + created = [] + + class Pool: + def __init__(self, config: Config): + self.config = config + created.append("pool") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Pool, create_on_enter=True) + + assert created == [] + with registry.app_scope() as provider: + assert created == ["pool"] + # Should return the same cached instance + pool = provider.resolve(Pool) + assert isinstance(pool, Pool) + assert created == ["pool"] + + def test_eager_generator_factory(self): + """Generator factories with create_on_enter=True should run setup on scope entry and cleanup on exit.""" + events: list[str] = [] + + def create_pool(config: Config): + events.append("setup") + yield f"pool({config.host})" + events.append("cleanup") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(str, create_pool, create_on_enter=True) + + assert events == [] + with registry.app_scope() as provider: + assert events == ["setup"] + assert provider.resolve(str) == "pool(localhost)" + assert events == ["setup", "cleanup"] + + def test_non_eager_not_resolved(self): + """Services without create_on_enter should NOT be resolved on scope entry.""" + created = [] + + class Pool: + def __init__(self, config: Config): + created.append("pool") + + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(Pool) + + with registry.app_scope(): + assert created == [] + + class TestAppScopeLifecycle: def test_no_auto_close_on_scope_exit(self): """Services registered without a factory should NOT be auto-closed on scope exit.""" From 61684e124966352811282f2893c39cf828b530b6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 21 Mar 2026 19:13:10 +0000 Subject: [PATCH 062/286] WIP on DI --- localpost/di/_services.py | 47 +++++++++++++++++++++------------------ localpost/di/flask.py | 12 ++-------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 99f97e0..0591c20 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -74,10 +74,10 @@ def resolve[T](self, service_type: type[T], /) -> T: if service_type in self.services: return cast(T, self.services[service_type]) - # Look up the factory for this scope - factory = self.registry.factories.get((service_type, type(self.scope))) - if factory is not None: - instance = self.services[service_type] = self.scope.enter(factory(self)) + # Look up the descriptor for this scope + descriptor = self.registry.descriptors.get((service_type, type(self.scope))) + if descriptor is not None: + instance = self.services[service_type] = self.scope.enter(descriptor.factory(self)) return instance # Delegate to parent scope @@ -96,8 +96,13 @@ def enter[T](self, cm: AbstractContextManager[T]) -> T: @contextmanager -def scope(ctx: ResolutionContext, /) -> Generator[ServiceProvider]: - with set_cvar(current_provider, DefaultServiceProvider.from_current(ctx)) as provider: +def scope(provider: DefaultServiceProvider, /) -> Generator[ServiceProvider]: + with set_cvar(current_provider, provider): + # Eagerly resolve services marked with create_on_enter for this scope type + scope_type = type(provider.scope) + for desc in provider.registry.descriptors.values(): + if desc.create_on_enter and desc.scope is scope_type: + provider.resolve(desc.service_type) yield provider @@ -123,6 +128,14 @@ def resolve[T](self, service_type: type[T], /) -> T: type ServiceFactory[T] = Callable[[ServiceProvider], AbstractContextManager[T]] +@define(frozen=True, slots=True) +class ServiceDescriptor[T]: + service_type: type[T] + scope: type[ResolutionContext] + factory: ServiceFactory[T] = field(repr=False) + create_on_enter: bool = False + + def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> list[tuple[str, type]]: """Inspect a callable's parameters and collect (name, type) pairs for auto-wiring.""" hints = get_type_hints(factory) @@ -177,13 +190,13 @@ def factory(provider: ServiceProvider) -> AbstractContextManager[T]: @final class ServiceRegistry: def __init__(self): - self.factories: dict[tuple[type, type[ResolutionContext]], ServiceFactory] = {} - self._eager: set[tuple[type, type[ResolutionContext]]] = set() + self.descriptors: dict[tuple[type, type[ResolutionContext]], ServiceDescriptor] = {} def register_instance[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None ) -> None: - self.factories[(service_type or type(value), scope or AppContext)] = lambda _: nullcontext(value) + sd = ServiceDescriptor(service_type or type(value), scope or AppContext, lambda _: nullcontext(value)) + self.descriptors[(sd.service_type, sd.scope)] = sd def register[T]( self, @@ -193,22 +206,12 @@ def register[T]( create_on_enter: bool = False, ) -> None: wrapped = _make_service_factory(factory) if factory else _factory_for_type(service_type) - key = (service_type, scope or AppContext) - self.factories[key] = wrapped - if create_on_enter: - self._eager.add(key) - - def _resolve_eager(self, provider: DefaultServiceProvider) -> None: - """Eagerly resolve services marked with create_on_enter for the given scope type.""" - scope_type = type(provider.scope) - for svc_type, svc_scope in self._eager: - if svc_scope is scope_type: - provider.resolve(svc_type) + sd = ServiceDescriptor(service_type, scope or AppContext, wrapped, create_on_enter) + self.descriptors[(sd.service_type, sd.scope)] = sd @contextmanager def app_scope(self) -> Generator[DefaultServiceProvider]: app_scope = AppContext() provider = DefaultServiceProvider(NULL_PROVIDER, self, app_scope) - with app_scope.ctx, set_cvar(current_provider, provider): - self._resolve_eager(provider) + with app_scope.ctx, scope(provider): yield provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py index feba96c..9229501 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -9,14 +9,7 @@ from flask import Flask, Request, g, request -from localpost._utils import set_cvar -from localpost.di._services import ( - DefaultServiceProvider, - ResolutionContext, - ServiceProvider, - ServiceRegistry, - current_provider, -) +from localpost.di._services import DefaultServiceProvider, ResolutionContext, ServiceProvider, ServiceRegistry, scope _EXT_KEY = "localpost.di.provider" @@ -44,8 +37,7 @@ def flask_req_ctx() -> Generator[None]: registry, parent = app.extensions[_EXT_KEY] req_scope = RequestContext() provider = DefaultServiceProvider(parent, registry, req_scope) - with req_scope.ctx, set_cvar(current_provider, provider): - registry._resolve_eager(provider) + with req_scope.ctx, scope(provider): yield @app.before_request From be5da39581440da7be96fad704e3da4420208a8a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 21 Mar 2026 20:42:05 +0000 Subject: [PATCH 063/286] WIP on DI --- localpost/di/_services.py | 33 ++++++++++++++++++++++++--------- tests/di/services.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 0591c20..691c9a9 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -4,8 +4,9 @@ from collections.abc import Callable, Generator from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext from contextvars import ContextVar +from dataclasses import dataclass, field from dataclasses import dataclass as define -from dataclasses import field +from functools import partial from typing import Any, Final, Protocol, Self, cast, final, get_type_hints from localpost._utils import set_cvar @@ -59,7 +60,7 @@ def from_current(cls, scope: ResolutionContext) -> Self: def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: """Create an instance of the given type, resolving constructor deps not provided in kwargs.""" - deps = _collect_deps(target_type.__init__, skip_self=True) if "__init__" in target_type.__dict__ else [] + deps = _collect_deps(target_type) if "__init__" in target_type.__dict__ else [] resolved = {name: self.resolve(dep_type) for name, dep_type in deps if name not in kwargs} return target_type(**resolved, **kwargs) @@ -128,7 +129,7 @@ def resolve[T](self, service_type: type[T], /) -> T: type ServiceFactory[T] = Callable[[ServiceProvider], AbstractContextManager[T]] -@define(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class ServiceDescriptor[T]: service_type: type[T] scope: type[ResolutionContext] @@ -136,15 +137,29 @@ class ServiceDescriptor[T]: create_on_enter: bool = False -def _collect_deps(factory: Callable[..., object], *, skip_self: bool = False) -> list[tuple[str, type]]: - """Inspect a callable's parameters and collect (name, type) pairs for auto-wiring.""" - hints = get_type_hints(factory) +def _collect_deps(factory: Callable[..., object]) -> list[tuple[str, type]]: + """Inspect a callable's parameters and collect (name, type) pairs for auto-wiring. + + Handles classes, classmethods, partial functions, etc. Uses inspect.signature for the effective + parameter list (excludes self/cls, accounts for positional partial args) and get_type_hints on the + underlying callable for type annotations. + """ + # Unwrap to get the real callable for type hints + unwrapped = factory + bound_kwargs: set[str] = set() + while isinstance(unwrapped, partial): + bound_kwargs.update(unwrapped.keywords) + unwrapped = unwrapped.func + + # inspect.signature handles partial, classmethod, staticmethod, classes, etc. params = inspect.signature(factory).parameters + # get_type_hints needs the real callable; for classes, use __init__ to get param hints + hints = get_type_hints(unwrapped.__init__ if isinstance(unwrapped, type) else unwrapped) - factory_name = getattr(factory, "__name__", repr(factory)) + factory_name = getattr(unwrapped, "__name__", repr(factory)) deps: list[tuple[str, type]] = [] for name in params: - if skip_self and name == "self": + if name in bound_kwargs: continue if name not in hints: raise TypeError(f"Cannot auto-wire {factory_name}: parameter '{name}' has no type annotation") @@ -177,7 +192,7 @@ def cm_plain(provider: ServiceProvider) -> Generator[T]: def _factory_for_type[T](service_type: type[T]) -> ServiceFactory[T]: """Create a ServiceFactory that auto-wires a type's constructor.""" # Classes without a custom __init__ inherit object.__init__(*args, **kwargs) — no deps to wire - deps = _collect_deps(service_type.__init__, skip_self=True) if "__init__" in service_type.__dict__ else [] + deps = _collect_deps(service_type) if "__init__" in service_type.__dict__ else [] def factory(provider: ServiceProvider) -> AbstractContextManager[T]: kwargs = {name: provider.resolve(dep_type) for name, dep_type in deps} diff --git a/tests/di/services.py b/tests/di/services.py index 6dc0b7c..c4b5c75 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -1,5 +1,6 @@ from collections.abc import Generator from dataclasses import dataclass +from functools import partial import pytest @@ -39,6 +40,15 @@ def __init__(self, something): self.something = something +class DBPoolWithClassmethod: + def __init__(self, dsn: str): + self.dsn = dsn + + @classmethod + def from_config(cls, config: Config) -> "DBPoolWithClassmethod": + return cls(dsn=f"postgres://{config.host}:{config.port}") + + # --- Tests --- @@ -150,6 +160,30 @@ def custom_factory() -> Config: assert config.host == "custom" assert config.port == 9999 + def test_register_with_classmethod_factory(self): + registry = ServiceRegistry() + registry.register_instance(Config(host="localhost", port=5432)) + registry.register(DBPoolWithClassmethod, DBPoolWithClassmethod.from_config) + + with registry.app_scope() as provider: + pool = provider.resolve(DBPoolWithClassmethod) + assert pool.dsn == "postgres://localhost:5432" + + def test_register_with_partial_factory(self): + def make_config(host: str, port: int) -> Config: + return Config(host=host, port=port) + + factory = partial(make_config, host="partial-host") + + registry = ServiceRegistry() + registry.register_instance(4321, int) + registry.register(Config, factory) + + with registry.app_scope() as provider: + config = provider.resolve(Config) + assert config.host == "partial-host" + assert config.port == 4321 + class TestCreate: def test_create_resolves_deps(self): From 65a6cfa38ad7952179f0a592927b5a08c26eee0c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 22 Mar 2026 09:54:45 +0000 Subject: [PATCH 064/286] cleanup --- tests/consumers/RedpandaContainer.py | 35 ------- tests/consumers/conftest.py | 6 -- tests/consumers/kafka.py | 100 -------------------- tests/consumers/pubsub.py | 102 --------------------- tests/consumers/pubsub_async.py | 1 - tests/consumers/sqs_aioboto.py | 132 --------------------------- tests/consumers/sqs_boto.py | 120 ------------------------ tests/consumers/stream.py | 42 +++------ tests/flow/__init__.py | 0 tests/flow/custom_middlewares.py | 36 -------- tests/flow/flow.py | 47 ---------- tests/flow/middlewares.py | 28 ------ tests/flow/ops.py | 15 --- 13 files changed, 11 insertions(+), 653 deletions(-) delete mode 100644 tests/consumers/RedpandaContainer.py delete mode 100644 tests/consumers/conftest.py delete mode 100644 tests/consumers/kafka.py delete mode 100644 tests/consumers/pubsub.py delete mode 100644 tests/consumers/pubsub_async.py delete mode 100644 tests/consumers/sqs_aioboto.py delete mode 100644 tests/consumers/sqs_boto.py delete mode 100644 tests/flow/__init__.py delete mode 100644 tests/flow/custom_middlewares.py delete mode 100644 tests/flow/flow.py delete mode 100644 tests/flow/middlewares.py delete mode 100644 tests/flow/ops.py diff --git a/tests/consumers/RedpandaContainer.py b/tests/consumers/RedpandaContainer.py deleted file mode 100644 index 2b230bd..0000000 --- a/tests/consumers/RedpandaContainer.py +++ /dev/null @@ -1,35 +0,0 @@ -from textwrap import dedent - -from testcontainers.kafka import RedpandaContainer as BaseRedpandaContainer - - -# See: -# - https://github.com/abiosoft/colima/issues/71#issuecomment-1985144767 -# - https://stackoverflow.com/q/78149093/322079 -class RedpandaContainer(BaseRedpandaContainer): - def get_container_host_ip(self) -> str: - ip = super().get_container_host_ip() - if ip == "localhost": - # At least on macOS (with Lima/Colima), localhost resolves to IPv6 by default, which causes issues - # (see https://github.com/testcontainers/testcontainers-python/issues/553 and others) - return "127.0.0.1" - return ip - - def tc_start(self) -> None: - internal = f"internal://{self.get_docker_client().bridge_ip(self.get_wrapped_container().id)}:29092" - external = f"external://{self.get_container_host_ip()}:{self.get_exposed_port(self.redpanda_port)}" - data = ( - dedent( - f""" - #!/bin/bash - rpk redpanda start --mode dev-container --smp 1 \ - --kafka-addr internal://0.0.0.0:29092,external://0.0.0.0:{self.redpanda_port} \ - --advertise-kafka-addr {internal},{external} \ - --schema-registry-addr internal://0.0.0.0:28081,external://0.0.0.0:{self.schema_registry_port} - """ - ) - .strip() - .encode("utf-8") - ) - - self.create_file(data, RedpandaContainer.TC_START_SCRIPT) diff --git a/tests/consumers/conftest.py b/tests/consumers/conftest.py deleted file mode 100644 index 40f93dd..0000000 --- a/tests/consumers/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -import logging - -from testcontainers.core import utils - -# See https://github.com/testcontainers/testcontainers-python/pull/758 -utils.setup_logger = logging.getLogger diff --git a/tests/consumers/kafka.py b/tests/consumers/kafka.py deleted file mode 100644 index 1e46b7a..0000000 --- a/tests/consumers/kafka.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging -import random -import string - -import anyio -import pytest -from confluent_kafka import Producer - -from localpost import flow, debug -from localpost.consumers.kafka import KafkaMessage, KafkaMessages, kafka_consumer -from localpost.hosting import Host -from .RedpandaContainer import RedpandaContainer - -pytestmark = [pytest.mark.anyio, pytest.mark.integration] - -logger = logging.getLogger(__name__) - - -@pytest.fixture(scope="module") -def local_kafka(): - with RedpandaContainer("redpandadata/redpanda:v24.3.3") as kafka_broker: - conn_config = { - "bootstrap.servers": kafka_broker.get_bootstrap_server(), - } - yield conn_config - - -@pytest.fixture() -def topic_name(): - return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - -async def test_happy_path(local_kafka, topic_name): - # Arrange - - sent = ["London: cloudy", "Paris: rainy"] - p = Producer(local_kafka) - for message in sent: # Redpanda creates a topic automatically if it doesn't exist - p.produce(topic_name, message) - p.flush() - - # Act - - received = [] - client_config = local_kafka | { - "group.id": "integration_tests", - "auto.offset.reset": "earliest", - } - - @kafka_consumer(topic_name, **client_config) - @flow.handler - def handle(m: KafkaMessage) -> None: - received.append(m.value.decode()) - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == sent - - -async def test_batching(local_kafka, topic_name): - topic_name = "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - # Arrange - - sent = ["London: cloudy", "Paris: rainy"] - p = Producer(local_kafka) - for message in sent: - p.produce(topic_name, message) - p.flush() - - # Act - - received = [] - client_config = local_kafka | { - "group.id": "integration_tests", - "auto.offset.reset": "earliest", - } - - @kafka_consumer(topic_name, **client_config) - @flow.batch(10, 1, KafkaMessages) - @flow.handler - async def handle(messages: KafkaMessages): - nonlocal received - received += [[m.value.decode() for m in messages]] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == [sent] diff --git a/tests/consumers/pubsub.py b/tests/consumers/pubsub.py deleted file mode 100644 index b375d8d..0000000 --- a/tests/consumers/pubsub.py +++ /dev/null @@ -1,102 +0,0 @@ -import random -import string - -import anyio -import pytest -from testcontainers.google import PubSubContainer - -from localpost import flow, debug -from localpost.consumers.pubsub import pubsub_consumer, ConsumerClient, PubSubMessage, PubSubMessages -from localpost.hosting import Host - -pytestmark = [pytest.mark.anyio, pytest.mark.integration] - - -@pytest.fixture(scope="module") -def local_pubsub(): - with PubSubContainer() as pubsub: - yield pubsub - - -@pytest.fixture() -def topic_name(): - return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - -async def test_happy_path(local_pubsub, topic_name): - # Arrange - - sent = ["hello", "world", "!"] - publisher = local_pubsub.get_publisher_client() - topic_path = publisher.topic_path(local_pubsub.project, topic_name) - # TODO VisibilityTimeout=1 - publisher.create_topic(name=topic_path) - for message in sent: - publisher.publish(topic_path, message.encode("utf-8")) - - # Act - - received = [] - - s_client = local_pubsub.get_subscriber_client() - s_client.subscription_path() - - @pubsub_consumer(lambda: ConsumerClient.create(topic_path, local_pubsub.get_subscriber_client())) - @flow.handler - async def handle(m: PubSubMessage): - nonlocal received - with m as m_body: - received += [m_body.decode()] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == sent - - # TODO Make sure that the messages were deleted from the queue - - -async def test_handler_manager(): - # Test handler lifecycle (via handler manager) - pass - - -async def test_batching(local_pubsub, topic_name): - # Arrange - - sent = ["hello", "world", "!"] - publisher = local_pubsub.get_publisher_client() - topic_path = publisher.topic_path(local_pubsub.project, topic_name) - # TODO Attributes={"VisibilityTimeout": "2"} # Should be greater than the batch window - publisher.create_topic(name=topic_path) - for message in sent: - publisher.publish(topic_path, message.encode("utf-8")) - - # Act - - received = [] - - @pubsub_consumer(lambda: ConsumerClient.create(topic_path, local_pubsub.get_subscriber_client())) - @flow.batch(10, 1, PubSubMessages) - @flow.handler - async def handle(messages: PubSubMessages): - nonlocal received - with messages: - received += [[m.data.decode() for m in messages]] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == [sent] - - # TODO Make sure that the messages were deleted from the queue diff --git a/tests/consumers/pubsub_async.py b/tests/consumers/pubsub_async.py deleted file mode 100644 index dd10ec7..0000000 --- a/tests/consumers/pubsub_async.py +++ /dev/null @@ -1 +0,0 @@ -# FIXME Implement diff --git a/tests/consumers/sqs_aioboto.py b/tests/consumers/sqs_aioboto.py deleted file mode 100644 index cee3cc8..0000000 --- a/tests/consumers/sqs_aioboto.py +++ /dev/null @@ -1,132 +0,0 @@ -import random -import string - -import anyio -import boto3 -import pytest -from aiobotocore.session import get_session as get_aio_session -from testcontainers.localstack import LocalStackContainer -from types_boto3_sqs.client import SQSClient - -from localpost import flow, debug -from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer, AsyncConsumerClient -from localpost.hosting import Host - -pytestmark = [pytest.mark.anyio, pytest.mark.integration] - - -# See https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on -@pytest.fixture -def anyio_backend(): - """ - SQS consumer uses aioboto3 which is asyncio only. - """ - return "asyncio" - - -@pytest.fixture(scope="module") -def local_sqs(): - with LocalStackContainer("localstack/localstack:4.0").with_services("sqs") as aws: - aws_url = aws.get_url() - - conn_params = { - "endpoint_url": aws_url, - "region_name": aws.region_name, - "aws_access_key_id": "test", - "aws_secret_access_key": "test", - } - - yield conn_params - - -@pytest.fixture() -def queue_name(): - return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - -async def test_happy_path(local_sqs, queue_name): - # Arrange - - sent = ["hello", "world", "!"] - sqs_client: SQSClient = boto3.client("sqs", **local_sqs) - queue_url = sqs_client.create_queue( - QueueName=queue_name, Attributes={"VisibilityTimeout": "1"} - )["QueueUrl"] - for message in sent: - sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) - - # Act - - received = [] - - @sqs_queue_consumer(lambda: AsyncConsumerClient.create( - queue_url, get_aio_session().create_client("sqs", **local_sqs))) - @flow.handler - async def handle(m: SqsMessage): - nonlocal received - with m as m_body: - received += [m_body] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == sent - - # Make sure that the messages were deleted from the queue - queue_info = sqs_client.get_queue_attributes( - QueueUrl=queue_url, - AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) - assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 - assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 - - -async def test_handler_manager(): - # Test handler lifecycle (via handler manager) - pass - - -async def test_batching(local_sqs, queue_name): - # Arrange - - sent = ["hello", "world", "!"] - sqs_client: SQSClient = boto3.client("sqs", **local_sqs) - queue_url = sqs_client.create_queue( - QueueName=queue_name, Attributes={"VisibilityTimeout": "2"} # Should be greater than the batch window - )["QueueUrl"] - for message in sent: - sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) - - # Act - - received = [] - - @sqs_queue_consumer(lambda: AsyncConsumerClient.create( - queue_url, get_aio_session().create_client("sqs", **local_sqs))) - @flow.batch(10, 1, SqsMessages) - @flow.handler - async def handle(messages: SqsMessages): - nonlocal received - with messages: - received += [[m.body for m in messages]] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == [sent] - - # Make sure that the messages were deleted from the queue - queue_info = sqs_client.get_queue_attributes( - QueueUrl=queue_url, - AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) - assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 - assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 diff --git a/tests/consumers/sqs_boto.py b/tests/consumers/sqs_boto.py deleted file mode 100644 index 406f5d6..0000000 --- a/tests/consumers/sqs_boto.py +++ /dev/null @@ -1,120 +0,0 @@ -import random -import string - -import anyio -import boto3 -import pytest -from testcontainers.localstack import LocalStackContainer -from types_boto3_sqs.client import SQSClient - -from localpost import flow, debug -from localpost.consumers.sqs import SqsMessage, SqsMessages, sqs_queue_consumer, ConsumerClient -from localpost.hosting import Host - -pytestmark = [pytest.mark.anyio, pytest.mark.integration] - - -@pytest.fixture(scope="module") -def local_sqs(): - with LocalStackContainer("localstack/localstack:4.0").with_services("sqs") as aws: - aws_url = aws.get_url() - - conn_params = { - "endpoint_url": aws_url, - "region_name": aws.region_name, - "aws_access_key_id": "test", - "aws_secret_access_key": "test", - } - - yield conn_params - - -@pytest.fixture() -def queue_name(): - return "test_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) - - -async def test_happy_path(local_sqs, queue_name): - # Arrange - - sent = ["hello", "world", "!"] - sqs_client: SQSClient = boto3.client("sqs", **local_sqs) - queue_url = sqs_client.create_queue( - QueueName=queue_name, Attributes={"VisibilityTimeout": "1"} - )["QueueUrl"] - for message in sent: - sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) - - # Act - - received = [] - - @sqs_queue_consumer(lambda: ConsumerClient.create(queue_url, sqs_client)) - @flow.handler - async def handle(m: SqsMessage): - nonlocal received - with m as m_body: - received += [m_body] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == sent - - # Make sure that the messages were deleted from the queue - queue_info = sqs_client.get_queue_attributes( - QueueUrl=queue_url, - AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) - assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 - assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 - - -async def test_handler_manager(): - # Test handler lifecycle (via handler manager) - pass - - -async def test_batching(local_sqs, queue_name): - # Arrange - - sent = ["hello", "world", "!"] - sqs_client: SQSClient = boto3.client("sqs", **local_sqs) - queue_url = sqs_client.create_queue( - QueueName=queue_name, Attributes={"VisibilityTimeout": "2"} # Should be greater than the batch window - )["QueueUrl"] - for message in sent: - sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) - - # Act - - received = [] - - @sqs_queue_consumer(lambda: ConsumerClient.create(queue_url, sqs_client)) - @flow.batch(10, 1, SqsMessages) - @flow.handler - async def handle(messages: SqsMessages): - nonlocal received - with messages: - received += [[m.body for m in messages]] - - host = Host(handle) - async with debug, host.aserve(): - await anyio.sleep(3) # "App is working" - host.shutdown() - - # Assert - - assert host.status["exception"] is None - assert received == [sent] - - # Make sure that the messages were deleted from the queue - queue_info = sqs_client.get_queue_attributes( - QueueUrl=queue_url, - AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"]) - assert int(queue_info["Attributes"]["ApproximateNumberOfMessages"]) == 0 - assert int(queue_info["Attributes"]["ApproximateNumberOfMessagesNotVisible"]) == 0 diff --git a/tests/consumers/stream.py b/tests/consumers/stream.py index c0e25a8..34086a2 100644 --- a/tests/consumers/stream.py +++ b/tests/consumers/stream.py @@ -1,58 +1,38 @@ -import anyio import pytest -from anyio import WouldBlock, create_memory_object_stream, create_task_group, sleep +from anyio import create_memory_object_stream, sleep -from localpost import flow from localpost.consumers.stream import stream_consumer -from localpost.flow import batch -from localpost.hosting import Host +from localpost.hosting import serve -pytestmark = [pytest.mark.anyio, pytest.mark.integration] +pytestmark = [pytest.mark.anyio] @pytest.fixture() -async def local_queue(): +def local_queue(): writer, reader = create_memory_object_stream(10) - yield writer, reader + return writer, reader async def test_normal_case(local_queue): writer, reader = local_queue # Arrange - sent = ["hello", "world", "!"] for message in sent: writer.send_nowait(message) # Act - received = [] - @stream_consumer(reader) - @flow.handler async def handle(m: str): - nonlocal received - received += [m] + received.append(m) + + consumer = stream_consumer(reader, handle) - host = Host(handle) - async with host.aserve(): - await anyio.sleep(0.5) # "App is working" + async with serve(consumer) as lt: + await sleep(0.5) writer.close() + await lt.stopped # Assert - - assert host.status["exception"] is None assert received == sent - - -async def test_batching(local_queue): - writer, reader = local_queue - - # TODO Implement - - -async def test_buffering(local_queue): - writer, reader = local_queue - - # TODO Implement diff --git a/tests/flow/__init__.py b/tests/flow/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/flow/custom_middlewares.py b/tests/flow/custom_middlewares.py deleted file mode 100644 index 327b812..0000000 --- a/tests/flow/custom_middlewares.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest - -from localpost import flow -from localpost.flow import FlowHandler, FlowHandlerManager - -pytestmark = pytest.mark.anyio - - -async def test_custom_middleware(): - handled_items = [] - - @flow.handler_middleware - async def custom_middleware(next_handler: FlowHandler): - async def modified_async_handler(item): - handled_items.append("modified async " + item) - await next_handler.async_h(item) - - def modified_sync_handler(item): - handled_items.append("modified sync " + item) - next_handler.sync_h(item) - - yield modified_async_handler, modified_sync_handler - - @custom_middleware - @flow.handler - def sample_handler(item): - handled_items.append(item) - - assert isinstance(sample_handler, FlowHandlerManager) - - async with sample_handler as handler: - handler("sample item") - - assert len(handled_items) == 2 - assert "modified sync sample item" in handled_items - assert "sample item" in handled_items diff --git a/tests/flow/flow.py b/tests/flow/flow.py deleted file mode 100644 index 8f74d15..0000000 --- a/tests/flow/flow.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from localpost import flow -from localpost.flow import FlowHandlerManager - -pytestmark = pytest.mark.anyio - - -async def test_handler_decorator(): - handled_items = [] - - @flow.handler - def sample_handler(item): - handled_items.append(item) - - assert isinstance(sample_handler, FlowHandlerManager) - - async with sample_handler as handler: - handler("sample item") - - assert handled_items == ["sample item"] - - -async def test_handler_manager_decorator(): - handled_items = [] - - @flow.handler_manager - async def sample_handler_manager(): - def sample_handler(item): - handled_items.append(item) - - yield sample_handler - - assert isinstance(sample_handler_manager, FlowHandlerManager) - - async with sample_handler_manager as handler: - handler("sample item") - - assert handled_items == ["sample item"] - - - -# TODO Test ensure_async_handler -# TODO Test ensure_async_handler_manager - -# TODO Test ensure_sync_handler -# TODO Test ensure_sync_handler_manager diff --git a/tests/flow/middlewares.py b/tests/flow/middlewares.py deleted file mode 100644 index b8bbeeb..0000000 --- a/tests/flow/middlewares.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -pytestmark = pytest.mark.anyio - - -async def test_skip_first(): - # Create a dummy flow and apply skip_first to it - pass # TODO Implement - - -async def test_buffer(): - # Create a dummy flow and apply buffer to it - pass # TODO Implement - - -async def test_filter(): - # Create a dummy flow and apply filter to it, with a condition - pass # TODO Implement - - -async def test_map(): - # Create a dummy flow and apply map to it, with a transformation function - pass # TODO Implement - - -async def test_flatmap(): - # Create a dummy flow and apply flatmap to it, with a transformation function - pass # TODO Implement diff --git a/tests/flow/ops.py b/tests/flow/ops.py deleted file mode 100644 index 5f174d3..0000000 --- a/tests/flow/ops.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -pytestmark = pytest.mark.anyio - - -# resolved_service = flow_handler_manager | service_factory -def test_resolve_operator(): - # TODO Implement - pass - - -# flow_handler_manager2 = flow_handler_manager1 << skip_first(1) -def test_decorate_operator(): - # TODO Implement - pass From fc7cee2b428f1a4553093f4789961f75e8812467 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 22 Mar 2026 15:02:43 +0000 Subject: [PATCH 065/286] WIP on DI --- localpost/di/_services.py | 30 +++++++++++------------------- localpost/di/flask.py | 2 +- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 691c9a9..cb2c26e 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -48,15 +48,9 @@ class DefaultServiceProvider(ServiceProvider): parent: ServiceProvider registry: ServiceRegistry scope: ResolutionContext - """Scope stack, from outermost to innermost.""" - services: dict[type, object] = field(default_factory=dict) - """Resolved services, keyed by service type.""" - - @classmethod - def from_current(cls, scope: ResolutionContext) -> Self: - if parent := current_provider.get(None): - return cls(parent=parent, registry=parent.registry, scope=scope) - raise RuntimeError("No active DI scope") # Did you forget to enter the app scope? + scope_type: type[ResolutionContext] + services: dict[type, object] = field(default_factory=dict, init=False) + """Resolved services.""" def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: """Create an instance of the given type, resolving constructor deps not provided in kwargs.""" @@ -68,20 +62,17 @@ def resolve[T](self, service_type: type[T], /) -> T: # Well-known DI types if service_type is ServiceProvider: return cast(T, self) - if service_type is ResolutionContext or service_type is AppContext: + if service_type is self.scope_type: return cast(T, self.scope) - # Return cached instance if already resolved in this scope - if service_type in self.services: + if service_type in self.services: # Already resolved in this scope return cast(T, self.services[service_type]) # Look up the descriptor for this scope - descriptor = self.registry.descriptors.get((service_type, type(self.scope))) - if descriptor is not None: + if descriptor := self.registry.descriptors.get((service_type, self.scope_type)): instance = self.services[service_type] = self.scope.enter(descriptor.factory(self)) return instance - # Delegate to parent scope return self.parent.resolve(service_type) @@ -119,11 +110,12 @@ def resolve[T](self, service_type: type[T], /) -> T: raise RuntimeError("No active DI scope") -service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() -"""Proxy for the current DI service provider.""" -current_provider: ContextVar[DefaultServiceProvider] = ContextVar("current_provider") NULL_PROVIDER: Final[ServiceProvider] = NullServiceProvider() +current_provider: ContextVar[DefaultServiceProvider] = ContextVar("current_provider") +# TODO Rename to "services" +service_provider: Final[CurrentServiceProvider] = CurrentServiceProvider() +"""Proxy for the current DI service provider.""" # A factory that returns a context manager — the provider enters it to get the instance and manage its lifecycle. type ServiceFactory[T] = Callable[[ServiceProvider], AbstractContextManager[T]] @@ -227,6 +219,6 @@ def register[T]( @contextmanager def app_scope(self) -> Generator[DefaultServiceProvider]: app_scope = AppContext() - provider = DefaultServiceProvider(NULL_PROVIDER, self, app_scope) + provider = DefaultServiceProvider(NULL_PROVIDER, self, app_scope, AppContext) with app_scope.ctx, scope(provider): yield provider diff --git a/localpost/di/flask.py b/localpost/di/flask.py index 9229501..7d11a0f 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -36,7 +36,7 @@ def init_app(app: Flask, registry: ServiceRegistry, provider: ServiceProvider, / def flask_req_ctx() -> Generator[None]: registry, parent = app.extensions[_EXT_KEY] req_scope = RequestContext() - provider = DefaultServiceProvider(parent, registry, req_scope) + provider = DefaultServiceProvider(parent, registry, req_scope, RequestContext) with req_scope.ctx, scope(provider): yield From ac414e28dd213db4c649b6f7971e5218d812445f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 22 Apr 2026 01:16:12 +0400 Subject: [PATCH 066/286] docs: refresh CLAUDE.md and add per-module READMEs Rewrite CLAUDE.md and README.md to match the current codebase, and add overview + quickstart + key-concepts READMEs for every module. Mark hosting / scheduler / http / di as stable and consumers / openapi as experimental. CLAUDE.md uses @-imports to pull module READMEs into Claude Code's context. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 178 +++++++++++++++++++++------------- README.md | 159 +++++++++++++++++------------- localpost/consumers/README.md | 138 ++++++++++++++++++++++++++ localpost/di/README.md | 154 +++++++++++++++++++++++++++-- localpost/hosting/README.md | 126 ++++++++++++++++++++++++ localpost/http/README.md | 165 ++++++++++++++++++++++++------- localpost/openapi/README.md | 161 ++++++++++++++++++++++++++++++ localpost/scheduler/README.md | 147 ++++++++++++++++++++++++++++ pypi_readme.md | 50 ++++++++-- 9 files changed, 1087 insertions(+), 191 deletions(-) create mode 100644 localpost/consumers/README.md create mode 100644 localpost/hosting/README.md create mode 100644 localpost/openapi/README.md create mode 100644 localpost/scheduler/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 6f1d7c4..6e927f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,103 +1,143 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code when working in this repository. -## Project Overview +## Project overview -LocalPost is a Python async framework providing: -- **Hosting** - App lifecycle management, to orchestrate multiple services -- **In-process task scheduler** - Lightweight scheduler for background tasks -- **Consumers** - Lightweight consumer services for various message brokers (Kafka, SQS, PubSub, in-memory channels, etc.) -- **HTTP Server** - Lightweight HTTP server for sync apps +LocalPost is an async Python framework (Python 3.12+) for building long-running +processes. Five pillars: -Python 3.12+ required. Built on AnyIO for structured concurrency (works with asyncio and Trio). +- **hosting** *(stable)* — service lifecycle + orchestration (start/stop/signals). +- **scheduler** *(stable)* — in-process, composable task scheduler. +- **http** *(stable)* — lightweight sync HTTP/1.1 server. +- **di** *(stable)* — small `.NET`-style IoC container with scopes. +- **consumers** *(experimental)* — message broker consumers (channel, stream, queue, Pub/Sub; more planned). +- **openapi** *(experimental)* — type-driven OpenAPI layer on top of `http`. -## Development Commands +Built on AnyIO (runs on asyncio or Trio). + +**Stability note:** "stable" modules have settled public APIs — avoid +breaking changes unless explicitly asked. "Experimental" modules are usable +but their shape is still evolving; feel free to refactor them, and flag +breaking changes in the CHANGELOG. + +## Development commands + +From `justfile`: ```bash -just deps # Install all dependencies (uses UV) -just format # Format code with ruff -just types # Check types (using ty) -just tests # Run all tests with coverage -just unit-tests # Unit tests only (exclude integration) -just integration-tests # Integration tests (parallel with pytest-xdist) -just check FILE # Check single file (ruff + ty) +just deps # uv sync --all-groups --all-extras +just deps-upgrade # uv lock --upgrade && sync +just format # ruff check --fix + ruff format on localpost/ +just format-all # same, also on examples/ and tests/ +just types # ty check localpost +just types-strict # ty check localpost (strict mode) +just types-all # ty over localpost + examples + tests +just type-coverage # basedpyright --verifytypes on the public API +just tests # pytest with coverage (all tests) +just unit-tests # pytest -m "not integration" +just integration-tests # pytest -m "integration" -n auto (testcontainers) +just check FILE # ruff check --fix + ty check for one file +just why PACKAGE # inverse dep tree for a package ``` -Run a single test: +Single test: + ```bash pytest tests/path/to/test.py::test_function -v ``` ## Architecture -### Module Structure - ``` localpost/ -├── __init__.py # Entry: Result, debug, __version__ -├── _utils.py # Core utilities (Result, Event, MemoryStream) -├── _debug.py # Debug utilities -├── _otel_utils.py # OpenTelemetry utilities -├── scopes.py # Scope/DI container for resource management -├── threadtools.py # Thread sync primitives (CancellableLock, Channel, Semaphore) +├── __init__.py # Re-exports: Result, debug, __version__ +├── _utils.py # Result, Event, MemoryStream, delay helpers +├── _debug.py +├── _otel_utils.py +├── threadtools.py # Sync primitives (CancellableLock, Channel, cancellable_semaphore) +│ +├── hosting/ # Service lifecycle + orchestration +│ ├── _host.py # ServiceLifetime, serve, run, @service, current_service +│ ├── middleware.py # shutdown_on_signal, start_timeout +│ └── services/ # Adapters: _asgi, uvicorn, hypercorn, grpc │ -├── hosting/ # App lifecycle management, service orchestration -│ ├── __init__.py # Exports: service, run, serve, current_service, ServiceState, etc. -│ ├── _host.py # Core: ServiceLifetime, ServiceLifetimeView -│ ├── middleware.py # Middleware (shutdown_on_signal, etc.) -│ └── services/ # Hosting adapters (ASGI, gRPC, Hypercorn, Uvicorn) +├── scheduler/ # Composable task scheduler +│ ├── _scheduler.py # ScheduledTask, ScheduledTaskTemplate, scheduled_task, run +│ ├── _cond.py # Every, After, AfterAll; every/after/after_all; delay, take_first +│ ├── _trigger.py # Trigger decorators (WIP) +│ └── cond/cron.py # cron(...) trigger — needs [cron] extra │ -├── scheduler/ # In-process task scheduler -│ ├── __init__.py # Exports: Scheduler, Task, ScheduledTask, every, after, etc. -│ ├── _scheduler.py # Task and Scheduler implementation -│ ├── _cond.py # Condition/trigger implementations (Every, After) -│ ├── _trigger.py # Trigger decorators (delay, take_first) -│ └── cond/cron.py # Cron expression support +├── consumers/ # Message broker consumers +│ ├── channel.py # in-memory channel +│ ├── stream.py # AnyIO ObjectReceiveStream +│ ├── stdlib_queue.py # queue.Queue / SimpleQueue +│ ├── pubsub.py # Google Cloud Pub/Sub (in-progress; imports are broken) +│ └── _utils.py # SyncHandler / AsyncHandler / AnyHandler types │ -├── consumers/ # Message consumer services -│ ├── channel.py # In-memory channel consumer -│ ├── pubsub.py # Google Cloud Pub/Sub -│ ├── stream.py # AnyIO stream consumer -│ └── stdlib_queue.py # stdlib queue.Queue consumer +├── di/ # IoC container +│ ├── _services.py # ServiceRegistry, ServiceProvider, AppContext +│ ├── flask.py # Flask integration (RequestContext per request) +│ └── quart.py # Quart integration (stub) │ -├── http/ # HTTP server and routing -│ ├── server.py # WSGI server (h11-based) -│ ├── router.py # Routing (URITemplate, Router, RequestCtx) -│ ├── config.py # Server configuration -│ ├── wsgi.py # WSGI app wrapping -│ └── _service.py # HTTP service integration with hosting +├── http/ # Lightweight HTTP/1.1 server (h11-based, sync I/O loop) +│ ├── server.py # start_http_server, HTTPReqCtx, RequestHandler +│ ├── router.py # URITemplate (RFC 6570 L1), Router, RequestCtx +│ ├── wsgi.py # wrap_wsgi() +│ ├── config.py # ServerConfig +│ └── _service.py # @hosting.service wrappers (http_server, wsgi_server) │ -└── openapi/ # OpenAPI/Swagger support - ├── app.py # OpenAPI app builder - ├── spec.py # OpenAPI spec generation - ├── converters.py # Request/response converters - ├── msgspec.py # msgspec serialization - ├── pydantic.py # Pydantic validation - ├── sse.py # Server-Sent Events - └── _docs.py # API documentation UI +└── openapi/ # Type-driven OpenAPI framework + ├── app.py # HttpApp, FromPath/Query/Header/Body, OpResult hierarchy + ├── spec.py # OpenAPI spec dataclasses + ├── converters.py + ├── pydantic.py # Pydantic body/result converters + ├── msgspec.py # msgspec converters (stub) + ├── sse.py # Server-Sent Events + ├── _docs.py # Swagger UI / ReDoc / Scalar HTML templates + └── DESIGN.md # Deeper design notes ``` -Files prefixed with `_` contain internal implementations. Public APIs are exported through `__init__.py`. +Files prefixed with `_` are internal; public API is re-exported from each +module's `__init__.py`. -### Key Patterns +## Conventions -**Hosting/Services**: Services decorated with `@hosting.service` have lifecycle states (Starting → Running → ShuttingDown → Stopped). Middleware decorators for cross-cutting concerns. Context variable provides service lifetime. +- **AnyIO everywhere.** Structured concurrency; no raw `asyncio`. Works on both + asyncio and Trio backends. +- **Public API is fully typed** — verified with `just types` (using `ty`) and + `just type-coverage` (using `basedpyright --verifytypes`). +- **Internal code** uses types where they aid readability; not strictly required. +- **Errors as values** — `Result[T]` (Ok / failure) flows through scheduler tasks + and arg resolvers. +- **Ruff** with `line-length = 120`, `pyupgrade.keep-runtime-typing = true`. +- **Sync + async duality** — consumers and scheduler accept both; sync callables + are offloaded to threads via `anyio.to_thread`. +- Docstrings: Google convention (per ruff config). -**Scheduler**: Tasks triggered by conditions (`every`, `after`, `cron`). Triggers composable with operators (`//`, `>>`). Results wrapped in `Result[T]` (Ok/Failure). +## Testing -**Consumers**: Generic handler interface (sync/async callables). Stream-based API with message broker abstraction (Kafka, SQS, Pub/Sub, in-memory channels). +- Unit tests: default `pytest` invocation, marker `not integration`. +- Integration tests: marked `@pytest.mark.integration`, use `testcontainers` + (LocalStack, NATS, Google Pub/Sub emulator), run in parallel via + `pytest-xdist` (`-n auto`). +- `anyio_mode = "auto"` in `pyproject.toml` — async tests use `anyio[test]`. -**HTTP/OpenAPI**: WSGI server on h11, URI template routing with path parameter extraction. OpenAPI layer for spec generation, validation (Pydantic/msgspec), and docs UI. +## Workflow rules -### Conventions +- After editing any file under `localpost/`, run `just check ` (ruff + ty). + This catches lint and type regressions early. +- Never skip hooks on commit. Prefer a new commit over `--amend`. +- Treat `_`-prefixed modules as internal; change them freely, but don't import + from `_*` outside the module they live in. -- Use AnyIO for async work (structured concurrency) -- **Public API**: Full type annotations, checked with ty (`just types`) -- **Internal API**: Types only where they improve readability +## Module deep-dives -## Testing +For more detail on each module, see: -- Unit tests: `tests/` directory, run with `pytest -m "not integration"` -- Integration tests: Marked with `@pytest.mark.integration`, use testcontainers -- Async tests use `anyio[test]` pytest plugin +@localpost/hosting/README.md +@localpost/scheduler/README.md +@localpost/consumers/README.md +@localpost/di/README.md +@localpost/http/README.md +@localpost/openapi/README.md diff --git a/README.md b/README.md index d18415e..c2cad45 100644 --- a/README.md +++ b/README.md @@ -2,105 +2,128 @@ [![PyPI package version](https://img.shields.io/pypi/v/localpost)](https://pypi.org/project/localpost/) ![Python versions](https://img.shields.io/pypi/pyversions/localpost) -
[![Code coverage](https://img.shields.io/sonar/coverage/alexeyshockov_localpost.py?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/project/overview?id=alexeyshockov_localpost.py) -Simple in-process task scheduler & consumers framework for different message brokers. +A small async Python framework for long-running processes: service hosting, +in-process task scheduling, message broker consumers, and a lightweight HTTP / +OpenAPI stack — all built on [AnyIO](https://anyio.readthedocs.io/) (runs on +asyncio **and** Trio). -## Scheduler +LocalPost is not a monolith. Each module is usable on its own; pick what you +need. -TBD +## Features -### Tasks +- **Service hosting** with a structured lifecycle (`Starting → Running → + ShuttingDown → Stopped`), signal handling, and composable middleware. +- **Scheduler** with declarative triggers (`every`, `after`, `cron`) and + operator-based composition (`every("1m") // delay((0, 10))`). +- **Consumers** for in-memory channels, AnyIO streams, `queue.Queue`, and + Google Cloud Pub/Sub — accepting both sync and async handlers. +- **HTTP server** — sync, h11-based, ~400 LOC; wrap any WSGI app. +- **OpenAPI layer** — type-driven; OpenAPI 3.0 spec is inferred from your + function signatures, with Swagger UI / ReDoc / Scalar docs. +- **IoC container** — `.NET`-style, scoped, with Flask integration. -TBD, including: -- can accept 0 or 1 argument (trigger's value) -- return values will be available in a stream +## Install -#### Concurrency +```bash +pip install localpost +``` -Solely depends on the trigger. +Optional extras: -### Triggers & decorators +| Extra | Adds | +| ----------------- | ----------------------------------------------------------- | +| `[cron]` | `croniter` — cron-expression trigger | +| `[scheduler]` | `humanize`, `pytimeparse2` — string durations | +| `[http-server]` | `h11` — the HTTP server | +| `[http-openapi]` | `msgspec` — OpenAPI serialization | +| `[sqs]` | `botocore` — AWS SQS | +| `[kafka]` | `confluent-kafka` | +| `[nats]` | `nats-py` | +| `[pubsub]` | `google-cloud-pubsub` | +| `[azure-queue]` | `azure-storage-queue` + `azure-identity` | +| `[azure-servicebus]` | `azure-servicebus` + `azure-identity` | -TBD +## Quick start — scheduler -### Built-in triggers & decorators +```python +import random +import sys -TBD, including: -- every() -- delay() -- skip_first() +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first -### Custom triggers & decorators -TBD +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) -## Consumers -TBD, including basic Kafka & SQS examples. +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") -## Flow & flow ops -TBD +if __name__ == "__main__": + sys.exit(run_app(task1, task2)) +``` -### Handlers & handler managers +`run_app` wires signal handling (SIGINT / SIGTERM), starts every service in +parallel, and exits cleanly when they all stop. -TBD +## Modules -### Building pipelines +| Module | Status | Purpose | +| ----------------------------------- | -------------- | ------------------------------------------------------- | +| [`hosting`](localpost/hosting/) | stable | Service lifecycle, signals, middleware, ASGI/gRPC adapters | +| [`scheduler`](localpost/scheduler/) | stable | Composable in-process task scheduler | +| [`di`](localpost/di/) | stable | Scoped IoC container | +| [`http`](localpost/http/) | stable | Small h11-based HTTP/1.1 server | +| [`consumers`](localpost/consumers/) | experimental | Message broker consumer services | +| [`openapi`](localpost/openapi/) | experimental | Type-driven OpenAPI framework | -TBD, including: -- decorator style -- `<<` operator +"Stable" means the public API is not expected to break in a patch or minor +release. "Experimental" means the module is usable and tested, but the API +surface is still being shaped — breaking changes may land before `1.0`. -### Built-in ops +Each subdirectory has its own README with a quickstart, key concepts, and +extension points. -TBD, including: -- map, filter, flatmap -- buffer -- batch +## Why localpost? -### Custom middlewares +- **Type-safe** — public API is checked with `ty` and `basedpyright + --verifytypes`. +- **FastAPI-style ergonomics** — decorators for tasks, services, and HTTP + operations; declarative middleware. +- **Async-first** — built on AnyIO, so structured concurrency is the default, + and you get Trio support for free. +- **Handle-both** — consumers and scheduler accept sync or async callables; + sync ones are offloaded to a thread pool. +- **Small** — each module is focused and independently usable; take just the + scheduler, just the hosting, or the full stack. -TBD +## Status -## Hosting +Beta — actively developed. Python 3.12+ required. See +[CHANGELOG.md](CHANGELOG.md) for history. -TBD +Examples for every module live under [`examples/`](examples/). -### Hosted services +## License -TBD, including: -- combining multiple service together - - run services in parallel - - wrap service(s) with another one +MIT — see [LICENSE](LICENSE). -### Running multiple services +## Contributing -TBD, including: -- combining multiple services, using host's `+` operator -- wrapping a service (or a set of services) with another one, using host's `>>` operator +The `main` branch is the only stable branch and will never be force-pushed. Any +other branch (including release branches) may be rebased or force-pushed at any +time. -### AppHost +Dev setup: -TBD - -## Motivation - -TBD, including: -- type safety -- FastAPI-like - - decorators to create scheduled tasks & hosted services - - middlewares -- async first - - AnyIO backed - - structured concurrency - - compatibility with Trio as a bonus - - easy threading (to support both sync and async consumers) - - -## Git Branch Policy - -The **only** stable branch is `master`. There will *never* be a `git push -f` on master. On the other hand, all other -branches are not considered stable; they may be deleted, rebased, force-pushed, and any other manner of funky business. +```bash +just deps # uv sync --all-groups --all-extras +just tests +``` diff --git a/localpost/consumers/README.md b/localpost/consumers/README.md new file mode 100644 index 0000000..e73a6b6 --- /dev/null +++ b/localpost/consumers/README.md @@ -0,0 +1,138 @@ +# localpost.consumers + +> **Status:** experimental — API surface is still being shaped; expect breaking changes before `1.0`. + +Consumer services for message sources. A consumer is a small `@hosting.service` +wrapper that reads from a source (in-memory channel, AnyIO stream, `queue.Queue`, +Pub/Sub) and dispatches each item to a handler — sync or async — with a +concurrency cap and graceful-shutdown semantics. + +## Install + +Most consumers need no extras. For the managed brokers: + +```bash +pip install localpost[pubsub] # Google Cloud Pub/Sub +pip install localpost[sqs] # AWS SQS (adapter WIP; see Status below) +pip install localpost[kafka] # confluent-kafka (adapter WIP) +pip install localpost[nats] # nats-py (adapter WIP) +pip install localpost[azure-queue] # Azure Storage Queue (adapter WIP) +pip install localpost[azure-servicebus] # Azure Service Bus (adapter WIP) +``` + +## Status + +Shipped adapters: + +- `channel.py` — in-memory `threadtools.Channel` +- `stream.py` — AnyIO `ObjectReceiveStream` +- `stdlib_queue.py` — stdlib `queue.Queue` / `SimpleQueue` +- `pubsub.py` — Google Cloud Pub/Sub (module imports some internals that are + being reworked — treat as in-progress) + +Adapters for SQS, Kafka, NATS and Azure exist as extras in `pyproject.toml` but +the integrations currently live in [`examples/consumers/`](../../examples/consumers/). +They will move into `localpost.consumers` as they stabilise. + +## Quick start + +```python +import anyio +from localpost.consumers.stream import stream_consumer +from localpost.hosting import run_app + + +async def handle(message: str): + print(f"got: {message}") + await anyio.sleep(0.5) + + +async def main(): + send, recv = anyio.create_memory_object_stream[str]() + + async def produce(): + for i in range(20): + await send.send(f"msg-{i}") + await anyio.sleep(0.1) + await send.aclose() + + consumer = stream_consumer(recv, handle, max_concurrency=5) + # `consumer` is a `@hosting.service`; pass it to run_app or await it directly + async with consumer: + await produce() + + +if __name__ == "__main__": + anyio.run(main) +``` + +For a full consumer-as-service example, see +[`examples/host/channel.py`](../../examples/host/channel.py). + +## Key concepts + +- **Handler duality** — every consumer accepts both `SyncHandler[T]` and + `AsyncHandler[T]` (types from `_utils.py`). Sync handlers are offloaded to + a thread pool (sized by `max_concurrency`). Async handlers are scheduled in + the consumer's task group. +- **`max_concurrency`** — a semaphore around in-flight items. The puller blocks + once the cap is reached, so back-pressure flows to the source. +- **`process_leftovers`** — on shutdown, finish items that were already pulled + from the source (`True`, default) or drop them (`False`). +- **Async-context-manager lifecycle** — every consumer is an async CM. Entering + it starts the puller; leaving it drains (or cancels) remaining work. +- **Thread-bridge** — `stdlib_queue` and `channel` run the pull loop in a + worker thread (AnyIO `to_thread.run_sync`) and dispatch into the async task + group via `from_thread.run_sync`. + +## Public API + +| Symbol | Source | +| ----------------------------------------------------- | -------------------- | +| `channel_consumer(stream, handler, *, max_concurrency, process_leftovers=True)` | `channel.py` | +| `stream_consumer(stream, handler, *, max_concurrency=inf, process_leftovers=True)` | `stream.py` | +| `queue_consumer(queue, handler, *, max_concurrency, process_leftovers=True, shutdown_timeout=5.0)` | `stdlib_queue.py` | +| `pubsub_consumer(subscription_path, *, num_consumers=1)` | `pubsub.py` | +| `SyncHandler[T]`, `AsyncHandler[T]`, `AnyHandler[T]` | `_utils.py` | + +## Writing a new consumer + +A consumer is a function decorated with `@hosting.service` that yields once it +has started. Pattern from `stream.py`: + +```python +from anyio import Semaphore, create_task_group +from localpost import hosting +from localpost.consumers._utils import AnyHandler, ensure_async_handler + + +@hosting.service +async def my_consumer(source, h: AnyHandler, /, *, max_concurrency: int): + sem = Semaphore(max_concurrency) + handler = ensure_async_handler(h) + + async def handle(item): + try: + await handler(item) + finally: + sem.release() + + async def pull(): + await sem.acquire() + async for item in source: + tg.start_soon(handle, item) + await sem.acquire() + + async with create_task_group() as tg: + tg.start_soon(pull) + yield # service is ready; wait for shutdown here +``` + +Accept a source, a handler, and at least `max_concurrency`. Honour +`process_leftovers` if your source can be closed mid-flight. + +## See also + +- Examples: [`examples/consumers/`](../../examples/consumers/) (SQS, Kafka stubs) +- Channel + host: [`examples/host/channel.py`](../../examples/host/channel.py) +- Sync primitives: [`../threadtools.py`](../threadtools.py) diff --git a/localpost/di/README.md b/localpost/di/README.md index 6d4ae13..03b3ef3 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -1,14 +1,148 @@ -# Inversion of Control, made simple +# localpost.di -Inspired by .NET DependencyInjection +> **Status:** stable — public API is not expected to break in patch/minor releases. + +Small, `.NET`-style inversion-of-control container: scoped service resolution +with automatic constructor wiring. No decorators, no async, no "one interface, +many implementations" — just a registry + provider + scope. + +## Install + +Comes with core `localpost` — no extra needed. The Flask adapter requires +`flask` in your environment. + +## Quick start + +```python +from dataclasses import dataclass +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + +class Server: + def __init__(self, config: Config): + self.config = config + + +services = ServiceRegistry() +services.register_instance(Config(host="127.0.0.1", port=8080)) +services.register(Server) # auto-wires Config via __init__ + +with services.app_scope() as sp: + server = sp.resolve(Server) + print(server.config) +``` + +With cleanup (generator factory): + +```python +def create_db_pool(conf: Config): + pool = DBPool(conf.db_dsn) + try: + yield pool + finally: + pool.close() + + +services.register(DBPool, create_db_pool) +``` + +See [`examples/di/basic.py`](../../examples/di/basic.py), +[`basic_cleanup.py`](../../examples/di/basic_cleanup.py), +[`flask_app.py`](../../examples/di/flask_app.py). ## Design goals (and non-goals) -- no async for now (maybe in the future) -- Scope is defined by it's type -- Only one registration per type (oposite to .NET DependencyInjection, where it's common to register multiple implentation for an interface, like IHostedService) -- A type can be registered multiple times, once per scope type -- Service Locator (no built-in @inject decorator as in other DI-oriented frameworks) -- Automatic wiring, if a service is resolved via ServiceProvider -- Instance factory can be a generator func, to do custom startup / shutdown -- An instance can be resolved on request (default) or when the scope is entered (create_on_enter=True) +- Inspired by .NET `Microsoft.Extensions.DependencyInjection`. +- No `async` (for now). +- **Scope is defined by its type** (`AppContext`, `RequestContext`, …). +- **Only one registration per `(service_type, scope_type)` pair** (the opposite + of .NET's `IEnumerable` pattern). A type can still be registered + multiple times — once per scope. +- **Service Locator** style — no magic `@inject` decorator. Ask the provider + for what you need. +- Constructor dependencies are **auto-wired** from type hints when resolved + via the provider. +- Factories can be plain callables, or **generator functions** for + setup/teardown (enter on resolve, `finally` on scope exit). +- Instances are **lazily resolved** by default; use `create_on_enter=True` to + build them eagerly when a scope is entered. + +## Key concepts + +- **`ResolutionContext`** (Protocol) — anything with `enter(cm)`. `AppContext` + is the default app-wide scope; `RequestContext` (in the Flask adapter) is + per-request. Define your own for other lifetimes. +- **`ServiceRegistry`** — the mutable catalogue. Holds `ServiceDescriptor`s + keyed by `(service_type, scope_type)`. +- **`ServiceProvider`** — resolves services. The default provider walks the + scope chain (request → app) to find a descriptor. +- **Scope stack** — child scopes hold a reference to their parent provider, so + resolving a parent-scoped service from inside a child scope transparently + reuses it. +- **`service_provider` proxy** — a `CurrentServiceProvider` singleton that + delegates to whichever provider is active in the current `contextvar`. + Lets request-scoped code call `service_provider.resolve(...)` without + plumbing the provider through. + +## Public API + +All public symbols live under `localpost.di._services` (module layout is +being stabilised; expect a top-level `__init__` re-export): + +| Symbol | Notes | +| ----------------------------------------------- | ---------------------------------------- | +| `ServiceRegistry()` | Mutable registry of descriptors | +| `registry.register_instance(value, service_type=None, scope=None)` | Bind an already-built value | +| `registry.register(service_type, factory=None, scope=None, create_on_enter=False)` | Register a type / factory | +| `registry.app_scope()` | Context manager yielding a `ServiceProvider` | +| `ServiceProvider` (Protocol) | `resolve[T](type)`, `sp[Type]`, `create[T](type, **kw)` | +| `AppContext` | Default scope type | +| `service_provider` | `CurrentServiceProvider` proxy (contextvar-backed) | +| `ServiceDescriptor` | Frozen (service_type, scope_type, factory) tuple | +| `ResolutionContext` (Protocol) | Implement for custom scopes | +| `ServiceNotRegisteredError`, `NoResolutionContextError` | Exceptions | + +### Flask adapter — `localpost.di.flask` + +| Symbol | Notes | +| ----------------------------------------- | -------------------------------------------- | +| `RequestContext` | Per-request scope | +| `init_app(app, registry, provider)` | Opens a `RequestContext` per Flask request; registers `flask.Request` for auto-injection | + +A Quart adapter (`localpost/di/quart.py`) exists as a stub only. + +## Defining a custom scope + +1. Create a dataclass implementing `ResolutionContext`: + + ```python + @dataclass(frozen=True, eq=False, slots=True) + class JobContext: + ctx: ExitStack = field(default_factory=ExitStack) + def enter[T](self, cm): return self.ctx.enter_context(cm) + ``` + +2. Register services under it: `services.register(JobRepo, scope=JobContext)`. + +3. Open the scope when you start a job: + + ```python + from localpost.di._services import DefaultServiceProvider, scope + job_ctx = JobContext() + provider = DefaultServiceProvider(parent_provider, registry, job_ctx, JobContext) + with job_ctx.ctx, scope(provider): + run_job() + ``` + +The Flask adapter in [`flask.py`](flask.py) is a compact reference. + +## See also + +- Examples: [`examples/di/`](../../examples/di/) +- Core: [`_services.py`](_services.py) diff --git a/localpost/hosting/README.md b/localpost/hosting/README.md new file mode 100644 index 0000000..c478277 --- /dev/null +++ b/localpost/hosting/README.md @@ -0,0 +1,126 @@ +# localpost.hosting + +> **Status:** stable — public API is not expected to break in patch/minor releases. + +Service lifecycle management and orchestration. A `service` is any async (or +sync) function wrapped with a lifecycle — it goes through `Starting → +Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child +services in the same task group. + +## Quick start + +```python +import sys +import time +from localpost.hosting import ServiceLifetime, run_app, service + + +@service +def a_sync_service(): + def svc(lt: ServiceLifetime): + print("Service started") + lt.set_started() + print("Service running") + time.sleep(5) + print("Service is done") # host stops when all services stop + return svc + + +if __name__ == "__main__": + sys.exit(run_app(a_sync_service())) +``` + +`run_app()` wires `shutdown_on_signal()` for you (SIGINT / SIGTERM), runs the +service with AnyIO (picking asyncio or Trio via `choose_anyio_backend`), and +returns an exit code. + +See `examples/host/finite_service.py`, `examples/host/channel.py`. + +## Key concepts + +- **`ServiceLifetime`** — the handle passed to every service. Exposes + `started`, `shutting_down`, `stopped` events (as `Event` / `EventView`), + an anyio `TaskGroup` (`lt.tg`) for spawning child tasks, and `defer` / + `adefer` to stash context managers / closable resources. +- **`ServiceState`** — the union `Starting | Running | ShuttingDown | Stopped` + (immutable dataclasses). Accessible via `lt.view.state`. +- **`@service` decorator** — turns a factory (returning a service function + or async generator) into a `_ResolvedService`, a callable that doubles as + an async context manager. +- **Middleware** — ordinary function decorators over the service function. + Examples: `shutdown_on_signal(*signals)`, `start_timeout(seconds)`. +- **`current_service()` / `current_app()`** — read-only views of the enclosing + lifetimes via contextvars, without threading them through every call. + +## Public API + +| Symbol | Where | Notes | +| -------------------------------- | -------------------- | ------------------------------------------ | +| `service` | decorator | Wrap a factory into a hosted service | +| `run_app(*services)` | entry point | Signal handling + `anyio.run` | +| `run(svc, parent=None)` | low-level | Run a single service, return exit code | +| `serve(svc, *, parent=None)` | low-level | Async CM yielding `ServiceLifetimeView` | +| `observe_services(*lifetimes)` | async CM | Wait for all to start; shut down on exit | +| `current_service()` | contextvar accessor | Raises if outside a hosting context | +| `ServiceLifetime` | dataclass | Mutable lifetime (internal-ish) | +| `ServiceLifetimeView` | dataclass | Read-only view + `observe()`, `shutdown()` | +| `ServiceState` | type alias | `Starting` \| `Running` \| `ShuttingDown` \| `Stopped` | +| `Starting` / `Running` / `ShuttingDown` / `Stopped` | dataclasses | Individual states | +| `shutdown_on_signal(*signals)` | middleware | SIGINT + SIGTERM by default | + +## Writing a service + +Four signatures are supported; `@service` picks the right adapter: + +1. **Async function** — `async def svc(lt: ServiceLifetime) -> None` +2. **Sync function** — runs in a worker thread via `to_thread.run_sync` +3. **Async generator** — `@service async def factory(): setup; yield; teardown` + (wrapped with `@asynccontextmanager`; `lt.set_started()` is called after + `yield`-in). +4. **Factory returning one of the above** + +Always call `lt.set_started()` once your service is ready. Services that +never call it block `observe_services` forever. + +## Writing middleware + +Middleware is a plain decorator over `ServiceF = Callable[[ServiceLifetime], +Awaitable[None]]`. Reference: `shutdown_on_signal` in `middleware.py`: + +```python +def my_middleware(arg) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: + @wraps(func) + def wrapper(lt: ServiceLifetime) -> Awaitable[None]: + lt.tg.start_soon(my_background_task, lt.view) + return func(lt) + return wrapper + return decorator +``` + +## Adapters for external servers (`services/`) + +| Adapter | What it wraps | +| ------------- | ----------------------------------------- | +| `uvicorn.py` | `uvicorn.Server` (`config` input; reload and multi-worker disabled) | +| `hypercorn.py`| `hypercorn.asyncio.serve(app, config)` with a shutdown trigger | +| `grpc.py` | `grpc.aio.Server` with configurable grace period | +| `_asgi.py` | Shared ASGI lifespan helpers | + +Each adapter is decorated with `@hosting.service`, so it plugs into +`run_app()` the same way as any other service. + +## Implementation notes + +- A service may spawn child services via `lt.start(child_svc)` — they run in + `lt.tg`, so when the parent's service function returns, the child task group + is cancelled. If you want the children to complete, `await` them explicitly + before returning. +- `lt.defer(cm)` / `await lt.adefer(acm)` tie a resource's lifetime to the + service — it's released when the service stops. + +## See also + +- Examples: [`examples/host/`](../../examples/host/) +- Middleware source: [`middleware.py`](middleware.py) +- Core: [`_host.py`](_host.py) diff --git a/localpost/http/README.md b/localpost/http/README.md index c244610..1355c43 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -1,37 +1,128 @@ -## HTTP server - - -## REST (OpenAPI) framework - -Focus: -- LLM apps, APIs - - streaming - -Vision: -- batteries not included - - no DB, just Python to/from HTTP orchestration -- go from specs - - OpenAPI app - - OpenRPC app - - JSON-API app - - application/vnd.api+json - - https://github.com/apiad/jsonapi - - ... -- plain old (sync) Python, to eliminate async complexity and pitfalls -- (type) inference as much as possible, to eliminate boilerplate - - make life easier when possible, when we can infer things from Python code (like OpenAPI schema from types, etc.) -- - - -### How is it different from? -- Flask - - web first (templates, etc), no built-in OpenAPI support - - https://flask-restless.readthedocs.io/en/latest/ -- FastAPI - - Pydantic only - - OpenAPI only - - async only - - more complex (dependencies, etc.) - - -## JSON-RPC (OpenRPC) framework +# localpost.http + +> **Status:** stable — public API is not expected to break in patch/minor releases. + +A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), +plus a router for URI-template-based request dispatch, and a WSGI bridge. About +400 lines of focused, sync code — easy to read, easy to embed. + +Pair it with `localpost.hosting` for lifecycle management, or run it standalone. +For OpenAPI / content negotiation / validation, see +[`localpost.openapi`](../openapi/README.md). + +## Install + +```bash +pip install localpost[http-server] +``` + +## Quick start + +```python +import h11 +from localpost import threadtools +from localpost.http.config import ServerConfig +from localpost.http.server import HTTPReqCtx, start_http_server + +threadtools.check_cancelled = lambda: None # not running under hosting + + +def simple_app(ctx: HTTPReqCtx): + ctx.complete( + h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + b"Hello, World!\n", + ) + + +with start_http_server(ServerConfig()) as server: + while True: + server.run(simple_app) +``` + +See [`examples/http/simple_server.py`](../../examples/http/simple_server.py), +`threaded_server.py`, `wsgi_app_server.py`. + +Running under hosting: + +```python +from localpost.hosting import run_app +from localpost.http._service import http_server +from localpost.http.config import ServerConfig + +sys.exit(run_app(http_server(ServerConfig(), simple_app))) +``` + +## Key concepts + +- **`ServerConfig`** — host, port, backlog, `keep_alive_timeout`, `max_body_size`. +- **`start_http_server(config)`** — context manager; yields a `Server` bound to + a non-blocking listening socket with a `selectors` poller. +- **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, the + raw socket, headers, and `complete(response, body)`. Request bodies are + streamed via `receive(n_bytes)`. +- **`RequestHandler = Callable[[HTTPReqCtx], None]`** — the handler + interface. The server calls `server.run(handler)` once per accepted request. +- **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style variables, + matched with a generated regex). `match(uri) → dict | None`. +- **`Router`** — pick a `RequestHandler` by `(method, template)`. Exposed via + `Router.wsgi` too, for deployment under Gunicorn / Granian / etc. + +## Public API + +### `localpost.http.server` + +| Symbol | Notes | +| ------------------------- | ------------------------------------------ | +| `start_http_server(cfg)` | Context manager yielding a `Server` | +| `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`) | +| `RequestHandler` | `Callable[[HTTPReqCtx], None]` | + +### `localpost.http.router` + +| Symbol | Notes | +| ------------------------- | ------------------------------------------ | +| `URITemplate` | Parse and match RFC 6570 L1 templates | +| `RequestCtx` | Routed request context (path args, query, body access) | +| `Router` | Register `(method, template) → RequestHandler` mappings, expose `.wsgi` | +| `Response` | Simple `(status, headers, body)` tuple | + +### `localpost.http.wsgi` + +| Symbol | Notes | +| ------------------------- | ------------------------------------------ | +| `wrap_wsgi(app)` | Turn a WSGI app into a `RequestHandler` | + +### `localpost.http.config` + +| Symbol | Notes | +| ------------- | -------------------------------------------------- | +| `ServerConfig` | Frozen dataclass of server tuning parameters | +| `LOGGER_NAME` | `"localpost.http"` | + +### Hosting integration — `localpost.http._service` + +Two `@hosting.service` wrappers for running the server inside a hosted app: + +| Service | Notes | +| -------------------------------------------- | ------------------------------------- | +| `http_server(config, handler)` | Runs the server loop with your handler | +| `wsgi_server(config, app)` | Same, for a WSGI app | + +The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown +is driven by `sl.shutting_down` via `threadtools.check_cancelled()`. + +## How is it different from… + +- **Flask** — Flask is web-first (templates, Jinja, sessions) with no built-in + OpenAPI support. `localpost.http` is a low-level server; pair it with + `localpost.openapi` for type-driven OpenAPI. +- **FastAPI** — FastAPI is async, Pydantic-only, OpenAPI-only, and ships a + dependency-injection system. `localpost.http` is sync, has no opinions on + serialization, and is small enough to read in one sitting. + +## See also + +- Examples: [`examples/http/`](../../examples/http/) +- OpenAPI on top: [`../openapi/README.md`](../openapi/README.md) +- Server source: [`server.py`](server.py) +- Router source: [`router.py`](router.py) diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md new file mode 100644 index 0000000..6d5923a --- /dev/null +++ b/localpost/openapi/README.md @@ -0,0 +1,161 @@ +# localpost.openapi + +> **Status:** experimental — API surface is still being shaped; expect breaking changes before `1.0`. + +A type-driven OpenAPI 3.0 framework sitting on top of [`localpost.http`](../http/README.md). + +Define operations as ordinary Python functions. Types, annotations, and return +types are inspected to build the OpenAPI doc and to wire path / query / header / +body resolvers — no schemas to hand-write. The generated spec is served at +`/openapi.json`, with three doc UIs built in. + +For deeper design notes see [`DESIGN.md`](DESIGN.md). + +## Install + +```bash +pip install localpost[http-server,http-openapi] +``` + +The OpenAPI layer uses [msgspec](https://jcristharif.com/msgspec/) by default +for schema / JSON handling; Pydantic converters are also provided. + +## Quick start + +```python +from collections.abc import Generator +from dataclasses import dataclass +from typing import Annotated +from wsgiref.simple_server import make_server + +from localpost.openapi.app import BadRequest, FromPath, HttpApp, NotFound + + +@dataclass +class Book: + id: str + title: str + author: str + + +app = HttpApp() + + +@app.get("/hello/{name}") +def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str, page_number: int = 1) -> Book | NotFound[str]: + if book_id != "00a7a2d4-18e4-11f1-899b-d33838f3bef0": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien") + + +if __name__ == "__main__": + # Try: + # curl http://localhost:8000/hello/world + # curl http://localhost:8000/openapi.json + # Docs: + # http://localhost:8000/docs (Swagger UI) + # http://localhost:8000/docs/redoc (ReDoc) + # http://localhost:8000/docs/scalar (Scalar) + with make_server("", 8000, app.wsgi) as server: + server.serve_forever() +``` + +Full example: [`examples/openapi/app.py`](../../examples/openapi/app.py). + +## Key concepts + +- **`HttpApp`** — the root object. Holds the router, the OpenAPI doc, and + registered operations. Exposes `.wsgi` for any WSGI host (including + `localpost.http.wsgi_server`). +- **Operations** — Python functions registered with `@app.get(path)`, + `.post`, etc. The path follows [`URITemplate`](../http/README.md) syntax + (`/books/{id}`). +- **Arg resolvers** — one per parameter, picked from the annotation. Factories: + - `FromPath()` — path variable (auto-detected for params whose name matches a + `{name}` in the template). + - `FromQuery()` — query string (default for unannotated params that aren't + in the path). + - `FromHeader("Name")` + - `FromBody(converter=...)` — e.g. `PydanticBodyConverter`. + Any resolver can short-circuit by returning an `OpResult`. +- **`OpResult` hierarchy** — `Ok`, `BadRequest[T]`, `Unauthorized[T]`, + `NotFound[T]`, `TooManyRequests[T]`, etc. Return one from your function to + emit a non-200 response; the OpenAPI spec learns about it from the return + type annotation. +- **Result converters** — turn the function's return value into HTTP bytes. + Pluggable via `ResultConverterFactory`. Pydantic support is in + [`pydantic.py`](pydantic.py); msgspec handling is the default. +- **Immutable spec** — every registration produces a new OpenAPI doc; the doc + itself is a tree of frozen dataclasses from [`spec.py`](spec.py). + +## Public API + +From `localpost.openapi.app`: + +| Symbol | Purpose | +| ------------------- | --------------------------------------------------- | +| `HttpApp` | Root app; `.get`, `.post`, `.put`, `.delete`, `.wsgi`, `.openapi_doc` | +| `FromPath()` | Arg resolver factory — path variable | +| `FromQuery()` | Arg resolver factory — query string | +| `FromHeader(name)` | Arg resolver factory — header | +| `FromBody(converter)` | Arg resolver factory — body | +| `Ok[T]` | Success wrapper (usually implicit) | +| `BadRequest[T]`, `Unauthorized[T]`, `NotFound[T]`, `TooManyRequests[T]` | `OpResult` subclasses | +| `OpFilter` (Protocol) | Per-operation pre-filter (e.g. `limit_requests`) | + +From `localpost.openapi.spec`: + +| Symbol | Notes | +| -------------------- | --------------------------------------------------- | +| `OpenAPI`, `Info`, `Operation`, `Response`, `MediaType`, … | Immutable spec dataclasses | + +## Sub-modules + +| Module | What it provides | +| -------------- | ----------------------------------------------------------------------- | +| `app.py` | `HttpApp`, arg resolvers, `OpResult` hierarchy, `FluentPathOp` | +| `spec.py` | OpenAPI 3.0 spec dataclasses | +| `converters.py`| Generic body / result converter helpers | +| `pydantic.py` | `PydanticBodyConverter`, `PydanticResultConverter` — use if Pydantic is your SoT | +| `msgspec.py` | msgspec converters (stub; msgspec is the default for schema handling) | +| `sse.py` | `Event[T]`, `EventStream[T]` for Server-Sent Events (a generator return type is auto-promoted to SSE) | +| `_docs.py` | HTML templates for Swagger UI, ReDoc, Scalar (CDN-loaded) | + +## Writing a custom arg resolver + +```python +from localpost.openapi.app import ArgResolverFactory +from localpost.http.router import RequestCtx + +class FromCookie(ArgResolverFactory): + def __init__(self, name: str): + self.name = name + + def __call__(self, param): + def resolve(req: RequestCtx): + cookies = req.headers.get("cookie", "") + # ...parse... + return value + return resolve +``` + +Use it: + +```python +@app.get("/me") +def me(session: Annotated[str, FromCookie("session")]) -> str: + return session +``` + +## See also + +- Examples: [`examples/openapi/`](../../examples/openapi/) +- Design notes: [`DESIGN.md`](DESIGN.md) +- Underlying server: [`../http/README.md`](../http/README.md) diff --git a/localpost/scheduler/README.md b/localpost/scheduler/README.md new file mode 100644 index 0000000..547bc5e --- /dev/null +++ b/localpost/scheduler/README.md @@ -0,0 +1,147 @@ +# localpost.scheduler + +> **Status:** stable — public API is not expected to break in patch/minor releases. + +Composable in-process task scheduler. Tasks are triggered by **conditions** +(time intervals, cron expressions, completion of another task), and triggers +are built up with operators — `//` to compose middleware, `>>` to extend a +trigger's own middleware pipeline. Tasks publish their outputs as `Result[T]`, +so downstream tasks can subscribe. + +Use it when you need to schedule background work inside the same process — for +example, refresh a cached dataframe every minute. For distributed / +persistent tasks, use Celery, APScheduler with a DB store, etc. + +## Install + +```bash +pip install localpost[scheduler] # human-readable periods (pytimeparse2, humanize) +pip install localpost[scheduler,cron] # also the cron() trigger (croniter) +``` + +## Quick start + +```python +import random +import sys +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first + + +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") + + +if __name__ == "__main__": + sys.exit(run_app(task1, task2)) +``` + +Cron: + +```python +from localpost.scheduler import delay, scheduled_task +from localpost.scheduler.cond.cron import cron + + +@scheduled_task(cron("*/1 * * * *") // delay((0, 10))) +async def job(): + print("running") +``` + +See [`examples/scheduler/`](../../examples/scheduler/) for more +(`cancellation.py`, `failing_tasks.py`, `fastapi_lifespan.py`, `finite_task.py`, +`serve_sync.py`, `fastdepends.py`). + +## Key concepts + +- **`ScheduledTask[T, R]`** — the runtime object: a handler paired with a + trigger factory. Publishes a `Result[R]` stream that others can subscribe to. +- **`ScheduledTaskTemplate[T]`** — a pre-bound trigger factory waiting for a + handler. Composable with `//` (compose trigger middleware) and `>>` + (extend the trigger's own middleware tuple). `every(...)`, `after(...)`, + `after_all(...)`, and `cron(...)` all return templates. +- **`TriggerFactory[T]`** — `Callable[[TaskGroup, EventView], + AbstractAsyncContextManager[AsyncIterator[T]]]`. Under the hood, a trigger is + an async iterator of events fired inside the task's lifetime. +- **Trigger middleware** — an async generator that consumes one `AsyncIterator` + and yields another. `delay` and `take_first` are built-in; write your own the + same way. +- **`Result[T]`** — output envelope (`Ok` or failure). `after()` filters + successes and forwards the `T`; `after_all()` forwards every result. + +## Public API + +| Symbol | Notes | +| ------------------------------- | --------------------------------------------- | +| `scheduled_task(trigger)` | Decorator that wraps a handler into a task | +| `Task` | Base task type | +| `ScheduledTask` | Runtime task object | +| `ScheduledTaskTemplate` | Trigger + middleware, not yet bound to handler | +| `Scheduler` | Groups multiple tasks into one hosted service | +| `run(task_or_scheduler)` | Entry point | +| `every(period)` | Trigger: every `timedelta` or string ("3s") | +| `after(task)` | Trigger: after each success of `task` | +| `after_all(task)` | Trigger: after every run of `task` (success or failure) | +| `delay(factor)` | Middleware: sleep between events | +| `take_first(n)` | Middleware: emit first N, then stop | +| `trigger_factory_middleware` | Helper for writing middleware | +| `scheduler.cond.cron.cron(...)` | Cron-string trigger (needs `[cron]` extra) | + +## Writing a custom trigger + +A trigger is a frozen dataclass / callable that, given a `TaskGroup` and a +`shutting_down` event, returns an async context manager yielding an +`AsyncIterator[T]`. Pattern (adapted from `_cond.py:Every`): + +```python +from dataclasses import dataclass, replace +from contextlib import asynccontextmanager + +@dataclass(frozen=True, slots=True) +class MyTrigger[T]: + middlewares: tuple[TriggerMiddleware, ...] = () + + def __rshift__(self, mw): # >> adds a middleware + return replace(self, middlewares=self.middlewares + (mw,)) + + @asynccontextmanager + async def __call__(self, tg, shutting_down): + async with apply_middlewares(my_source(), self.middlewares) as stream: + yield stream +``` + +Then wrap it: `my_trigger = ScheduledTaskTemplate(MyTrigger())`. + +## Writing a custom middleware + +A middleware is a regular async generator: + +```python +from collections.abc import AsyncIterator +from localpost._utils import maybe_closing + +def skip_every_other(): + async def middleware[T](events: AsyncIterator[T]) -> AsyncIterator[T]: + async with maybe_closing(events): + i = 0 + async for event in events: + if i % 2 == 0: + yield event + i += 1 + return middleware +``` + +Use it via `every("1s") // skip_every_other()`. + +## See also + +- Examples: [`examples/scheduler/`](../../examples/scheduler/) +- Cron source: [`cond/cron.py`](cond/cron.py) +- Built-in triggers: [`_cond.py`](_cond.py) +- Core: [`_scheduler.py`](_scheduler.py) diff --git a/pypi_readme.md b/pypi_readme.md index 44b38e7..6dde547 100644 --- a/pypi_readme.md +++ b/pypi_readme.md @@ -1,15 +1,51 @@ # localpost -Simple in-process task scheduler & consumers framework for different message brokers. +A small async Python framework for long-running processes: service hosting, +in-process task scheduler, message broker consumers, and a lightweight HTTP / +OpenAPI stack. Built on [AnyIO](https://anyio.readthedocs.io/) — runs on +asyncio **and** Trio. -## Scheduler +Python 3.12+ required. -TBD +## Features -## Consumers +- **Hosting** — structured service lifecycle, signal handling, middleware. +- **Scheduler** — declarative triggers (`every`, `after`, `cron`) composed with + operators like `every("1m") // delay((0, 10))`. +- **Consumers** — in-memory channels, AnyIO streams, `queue.Queue`, Google + Cloud Pub/Sub (Kafka / SQS / NATS planned). +- **HTTP** — small h11-based sync server; wrap any WSGI app. +- **OpenAPI** — OpenAPI 3.0 inferred from your function signatures, with + Swagger UI / ReDoc / Scalar docs built in. +- **DI** — `.NET`-style scoped IoC container; optional Flask integration. -TBD +## Quick start -## Hosting +```python +import random +import sys +from localpost.hosting import run_app +from localpost.scheduler import after, every, scheduled_task -TBD + +@scheduled_task(every("3s")) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1)) +async def task2(x: int): + print(f"task1 emitted: {x}") + + +if __name__ == "__main__": + sys.exit(run_app(task1, task2)) +``` + +## Docs + +- [GitHub README](https://github.com/alexeyshockov/localpost.py) — full overview. +- [Module docs](https://github.com/alexeyshockov/localpost.py/tree/main/localpost) — per-module READMEs. +- [Changelog](https://github.com/alexeyshockov/localpost.py/blob/main/CHANGELOG.md). + +MIT licensed. From 62ea4b3e7c821a602c9301ab136190931f37eee3 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 24 Apr 2026 06:46:16 +0000 Subject: [PATCH 067/286] WIP --- examples/host/finite_service.py | 3 +- examples/scheduler/app.py | 13 +- examples/scheduler/cancellation.py | 5 +- examples/scheduler/finite_task.py | 10 +- localpost/_utils.py | 25 ++- localpost/di/_services.py | 78 +++++--- localpost/hosting/__init__.py | 6 +- localpost/hosting/_host.py | 121 ++++++++--- localpost/scheduler/_cond.py | 170 ++++++++++------ localpost/scheduler/_scheduler.py | 309 +++++------------------------ localpost/scheduler/_trigger.py | 66 ------ localpost/threadtools.py | 100 ---------- 12 files changed, 334 insertions(+), 572 deletions(-) diff --git a/examples/host/finite_service.py b/examples/host/finite_service.py index 264583b..26a42ef 100755 --- a/examples/host/finite_service.py +++ b/examples/host/finite_service.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import sys import time @@ -25,4 +26,4 @@ def svc(lt: ServiceLifetime): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run_app(a_sync_service())) + sys.exit(run_app(a_sync_service())) diff --git a/examples/scheduler/app.py b/examples/scheduler/app.py index b4a165b..12e2cfa 100755 --- a/examples/scheduler/app.py +++ b/examples/scheduler/app.py @@ -1,14 +1,13 @@ #!/usr/bin/env python - import logging import random +import sys -from localpost.scheduler import Scheduler, after, delay, every, run, take_first - -scheduler = Scheduler() +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first -@scheduler.task(every("3s") // delay((0, 1))) +@scheduled_task(every("3s") // delay((0, 1))) async def task1(): """ A simple repeating task that returns a random number. @@ -17,7 +16,7 @@ async def task1(): return random.randint(1, 22) -@scheduler.task(after(task1) // take_first(3)) +@scheduled_task(after(task1) // take_first(3)) async def task2(task1_result: int): """ A task that runs after task1 and prints its result. @@ -30,4 +29,4 @@ async def task2(task1_result: int): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run(scheduler)) + sys.exit(run_app(task1, task2)) diff --git a/examples/scheduler/cancellation.py b/examples/scheduler/cancellation.py index 9f847cf..46059d1 100755 --- a/examples/scheduler/cancellation.py +++ b/examples/scheduler/cancellation.py @@ -6,7 +6,8 @@ import anyio -from localpost.scheduler import every, run, scheduled_task +from localpost.hosting import run_app +from localpost.scheduler import every, scheduled_task @scheduled_task(every(timedelta(seconds=3))) @@ -30,4 +31,4 @@ async def long_async_task(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run(long_async_task)) + sys.exit(run_app(long_async_task)) diff --git a/examples/scheduler/finite_task.py b/examples/scheduler/finite_task.py index 4e71ba8..4cfe4af 100755 --- a/examples/scheduler/finite_task.py +++ b/examples/scheduler/finite_task.py @@ -1,14 +1,14 @@ -#!/usr/bin/env python - import logging import random +import sys from datetime import timedelta -from localpost.scheduler import delay, every, run, scheduled_task, take_first +from localpost.hosting import run_app +from localpost.scheduler import delay, every, scheduled_task, take_first @scheduled_task(every(timedelta(seconds=3)) // take_first(3) // delay((0, 3))) -async def task1(): +def task1(_): """ A simple repeating task that returns a random number. """ @@ -21,4 +21,4 @@ async def task1(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run(task1)) + sys.exit(run_app(task1)) diff --git a/localpost/_utils.py b/localpost/_utils.py index 3aebb2c..741a410 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -112,28 +112,25 @@ def set_cvar[T](cvar: ContextVar[T], value: T) -> Generator[T]: cvar.reset(old_value) -# TODO Remove @final -class ClosingContext[T](AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): +class maybe_closing[T](AbstractContextManager[T, None], AbstractAsyncContextManager[T, None]): def __init__(self, enter_result: T): - self.enter_result = enter_result + self._enter_result = enter_result def __enter__(self) -> T: - return self.enter_result + return self._enter_result async def __aenter__(self) -> T: - return self.enter_result + return self._enter_result def __exit__(self, exc_type, exc_value, traceback) -> None: - if hasattr(t := self.enter_result, "close"): - cast(_SupportsClose, t).close() + getattr(self._enter_result, "close", lambda: None)() async def __aexit__(self, exc_type, exc_value, traceback) -> None: - t = self.enter_result - if hasattr(t, "aclose"): - await cast(_SupportsAsyncClose, t).aclose() - elif hasattr(t, "close"): - cast(_SupportsClose, t).close() + target = self._enter_result + close = getattr(target, "aclose", getattr(target, "close", lambda: None)) + if inspect.isawaitable(result := close()): + await result def ensure_int_or_inf(value: int | float, *, min_value: int = 0, name: str = "Value") -> int | float: @@ -350,11 +347,13 @@ async def send_or_drop(self, item: T) -> None: pass +# TODO Remove # For better typing in PyCharm class MemoryStream(Generic[T]): @staticmethod def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjectReceiveStream[T]]: - return create_memory_object_stream(max_buffer_size) # type: ignore[return-value] + send, receive = create_memory_object_stream(max_buffer_size) + return cast(Mem class AsyncBackendConfig(TypedDict): diff --git a/localpost/di/_services.py b/localpost/di/_services.py index cb2c26e..2ca4f8e 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -1,13 +1,14 @@ from __future__ import annotations import inspect -from collections.abc import Callable, Generator +import threading +from collections.abc import Callable, Collection, Generator, Iterator, Mapping from contextlib import AbstractContextManager, ExitStack, contextmanager, nullcontext from contextvars import ContextVar from dataclasses import dataclass, field from dataclasses import dataclass as define from functools import partial -from typing import Any, Final, Protocol, Self, cast, final, get_type_hints +from typing import Any, Final, Protocol, cast, final, get_type_hints from localpost._utils import set_cvar @@ -42,15 +43,20 @@ class ServiceNotRegisteredError(ValueError): """Raised when resolving a service that is not registered.""" +class NoResolutionContextError(RuntimeError): + """Raised when trying to resolve a service outside of a DI scope.""" + + @final -@define(frozen=True, slots=True) +@define(frozen=True, eq=False, slots=True) class DefaultServiceProvider(ServiceProvider): parent: ServiceProvider registry: ServiceRegistry scope: ResolutionContext scope_type: type[ResolutionContext] - services: dict[type, object] = field(default_factory=dict, init=False) + services: Mapping[type, object] = field(default_factory=dict, init=False) """Resolved services.""" + _lock: threading.Lock = field(default_factory=threading.Lock, init=False) def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: """Create an instance of the given type, resolving constructor deps not provided in kwargs.""" @@ -65,49 +71,51 @@ def resolve[T](self, service_type: type[T], /) -> T: if service_type is self.scope_type: return cast(T, self.scope) - if service_type in self.services: # Already resolved in this scope - return cast(T, self.services[service_type]) + if resolved_service := self.services.get(service_type): # Already resolved in this scope + return cast(T, resolved_service) # Look up the descriptor for this scope - if descriptor := self.registry.descriptors.get((service_type, self.scope_type)): - instance = self.services[service_type] = self.scope.enter(descriptor.factory(self)) - return instance + if descriptor := self.registry.get(service_type, self.scope_type): + with self._lock: + if resolved_service := self.services.get(service_type): # Resolved in another thread + return cast(T, resolved_service) + resolved_service = self.scope.enter(descriptor.factory(self)) + object.__setattr__(self, "services", {**self.services, service_type: resolved_service}) + return resolved_service return self.parent.resolve(service_type) class NullServiceProvider(ServiceProvider): def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: - raise RuntimeError("No active DI scope") + raise NoResolutionContextError() def resolve[T](self, service_type: type[T], /) -> T: raise ServiceNotRegisteredError(f"{service_type} is not registered") - def enter[T](self, cm: AbstractContextManager[T]) -> T: - raise RuntimeError("not in DI scope") - @contextmanager def scope(provider: DefaultServiceProvider, /) -> Generator[ServiceProvider]: with set_cvar(current_provider, provider): # Eagerly resolve services marked with create_on_enter for this scope type - scope_type = type(provider.scope) for desc in provider.registry.descriptors.values(): - if desc.create_on_enter and desc.scope is scope_type: - provider.resolve(desc.service_type) + if desc.scope_type is provider.scope_type and desc.create_on_enter: + _ = provider.resolve(desc.service_type) yield provider class CurrentServiceProvider(ServiceProvider): - def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + @property + def _provider(self) -> DefaultServiceProvider: if provider := current_provider.get(None): - return provider.create(target_type, **kwargs) - raise RuntimeError("No active DI scope") + return provider + raise NoResolutionContextError() + + def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: + return self._provider.create(target_type, **kwargs) def resolve[T](self, service_type: type[T], /) -> T: - if provider := current_provider.get(None): - return provider.resolve(service_type) - raise RuntimeError("No active DI scope") + return self._provider.resolve(service_type) NULL_PROVIDER: Final[ServiceProvider] = NullServiceProvider() @@ -124,7 +132,7 @@ def resolve[T](self, service_type: type[T], /) -> T: @dataclass(frozen=True, slots=True) class ServiceDescriptor[T]: service_type: type[T] - scope: type[ResolutionContext] + scope_type: type[ResolutionContext] factory: ServiceFactory[T] = field(repr=False) create_on_enter: bool = False @@ -195,15 +203,29 @@ def factory(provider: ServiceProvider) -> AbstractContextManager[T]: # Kinda like IServiceCollection in .NET, or svcs.Registry @final -class ServiceRegistry: - def __init__(self): - self.descriptors: dict[tuple[type, type[ResolutionContext]], ServiceDescriptor] = {} +@define(eq=False, slots=True) +class ServiceRegistry(Collection[ServiceDescriptor]): + descriptors: dict[tuple[type, type[ResolutionContext]], ServiceDescriptor] = field(default_factory=dict) + + def __iter__(self) -> Iterator[ServiceDescriptor]: + return iter(self.descriptors.values()) + + def __len__(self) -> int: + return len(self.descriptors) + + def __contains__(self, item: ServiceDescriptor) -> bool: + return (item.service_type, item.scope_type) in self.descriptors + + def get[T]( + self, service_type: type[T], scope: type[ResolutionContext] | None = None + ) -> ServiceDescriptor[T] | None: + return self.descriptors.get((service_type, scope or AppContext)) def register_instance[T]( self, value: T, service_type: type[T] | None = None, scope: type[ResolutionContext] | None = None ) -> None: sd = ServiceDescriptor(service_type or type(value), scope or AppContext, lambda _: nullcontext(value)) - self.descriptors[(sd.service_type, sd.scope)] = sd + self.descriptors[(sd.service_type, sd.scope_type)] = sd def register[T]( self, @@ -214,7 +236,7 @@ def register[T]( ) -> None: wrapped = _make_service_factory(factory) if factory else _factory_for_type(service_type) sd = ServiceDescriptor(service_type, scope or AppContext, wrapped, create_on_enter) - self.descriptors[(sd.service_type, sd.scope)] = sd + self.descriptors[(sd.service_type, sd.scope_type)] = sd @contextmanager def app_scope(self) -> Generator[DefaultServiceProvider]: diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index d5bc80b..be03e2f 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -10,6 +10,7 @@ ShuttingDown, Starting, Stopped, + _run_many, current_service, observe_services, run, @@ -35,8 +36,9 @@ ] -def run_app(svc: ServiceF, /) -> int: +def run_app(*svcs: ServiceF) -> int: """Run the target app until it stops or is interrupted by a signal.""" - app = shutdown_on_signal()(svc) + root_svc = svcs[0] if len(svcs) == 1 else _run_many(*svcs) + app = shutdown_on_signal()(root_svc) return anyio.run(run, app, None, **choose_anyio_backend()) diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index a7fc90d..91628e7 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -1,12 +1,19 @@ from __future__ import annotations -import dataclasses as dc import inspect import logging import threading from _contextvars import ContextVar from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + AsyncExitStack, + asynccontextmanager, + nullcontext, +) +from dataclasses import dataclass, field +from dataclasses import dataclass as define from functools import cached_property, wraps from typing import Any, ClassVar, Literal, TypeVar, final, overload @@ -14,7 +21,17 @@ from anyio.abc import TaskGroup from anyio.from_thread import BlockingPortal -from localpost._utils import Event, EventView, cancellable_from, is_async_callable, unwrap_exc, wait_all +from localpost._utils import ( + Event, + EventView, + _SupportsAsyncClose, + _SupportsClose, + cancellable_from, + is_async_callable, + maybe_closing, + unwrap_exc, + wait_all, +) F = TypeVar("F", bound=Callable[..., Any]) @@ -22,20 +39,20 @@ @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Starting: name: ClassVar[Literal["starting"]] = "starting" # timeout: float @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Running: name: ClassVar[Literal["running"]] = "running" @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class ShuttingDown: name: ClassVar[Literal["shutting_down"]] = "shutting_down" reason: BaseException | str | None = None @@ -43,7 +60,7 @@ class ShuttingDown: @final -@dc.dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True) class Stopped: name: ClassVar[Literal["stopped"]] = "stopped" shutdown_reason: BaseException | str | None = None @@ -53,20 +70,24 @@ class Stopped: ServiceState = Starting | Running | ShuttingDown | Stopped +# FIXME Set +_app_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_app") _svc_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_service") -def _current_service() -> ServiceLifetime: - if lt := _svc_lt.get(None): - return lt +def current_app() -> ServiceLifetimeView: + if lt := _app_lt.get(None): + return lt.view raise RuntimeError("Not in hosting context") def current_service() -> ServiceLifetimeView: - return _current_service().view + if lt := _svc_lt.get(None): + return lt.view + raise RuntimeError("Not in hosting context") -@dc.dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class ServiceLifetimeView: _state: ServiceLifetime @@ -138,21 +159,47 @@ def stop(self) -> None: in_host_thread(self._state, self._state.run_scope.cancel) -@dc.dataclass(eq=False, unsafe_hash=True) +@define(eq=False, unsafe_hash=True) class ServiceLifetime: portal: BlockingPortal - tg: TaskGroup = dc.field(default_factory=create_task_group) - thread_id: int = dc.field(default_factory=threading.get_ident) + tg: TaskGroup = field(default_factory=create_task_group) + scope: AsyncExitStack = field(default_factory=AsyncExitStack) + + thread_id: int = field(default_factory=threading.get_ident) - started: Event = dc.field(default_factory=Event) - shutting_down: Event = dc.field(default_factory=Event) - stopped: Event = dc.field(default_factory=Event) + started: Event = field(default_factory=Event) + shutting_down: Event = field(default_factory=Event) + stopped: Event = field(default_factory=Event) shutdown_reason: BaseException | str | None = None exception: BaseException | None = None _exit_code: int | None = None + @overload + def defer[T](self, t: AbstractContextManager[T]) -> T: ... + + @overload + def defer[T: _SupportsClose](self, t: T) -> T: ... + + def defer(self, t): + def as_cm(t) -> AbstractContextManager: + return t if isinstance(t, AbstractContextManager) else maybe_closing(t) + + return self.scope.enter_context(as_cm(t)) + + @overload + async def adefer[T](self, t: AbstractAsyncContextManager[T]) -> T: ... + + @overload + async def adefer[T: _SupportsAsyncClose](self, t: T) -> T: ... + + async def adefer(self, t): + def as_acm(t) -> AbstractAsyncContextManager: + return t if isinstance(t, AbstractAsyncContextManager) else maybe_closing(t) + + return await self.scope.enter_async_context(as_acm(t)) + @cached_property def view(self) -> ServiceLifetimeView: return ServiceLifetimeView(self, self.started, self.shutting_down, self.stopped) @@ -225,10 +272,10 @@ def in_host_thread[R](h: ServiceLifetime, func: Callable[..., R], *args) -> R: # AppF = Callable[[HostLifetime], Awaitable[None]] -@dc.dataclass() +@define() class _ResolvedService(AbstractAsyncContextManager[ServiceLifetimeView]): func: ServiceF - _run: AbstractAsyncContextManager | None = dc.field(default=None, compare=False, repr=False) + _run: AbstractAsyncContextManager | None = field(default=None, compare=False, repr=False) def __call__(self, lt: ServiceLifetime, /) -> Awaitable[None]: return self.func(lt) @@ -306,7 +353,7 @@ async def observe_services(*lifetimes: ServiceLifetimeView): async def _run(svc_f: ServiceF, lt: ServiceLifetime) -> None: - outer_ctx_token = _svc_lt.set(lt) + parent_svc_lt = _svc_lt.set(lt) try: async with lt.tg as tg: await svc_f(lt) @@ -314,11 +361,11 @@ async def _run(svc_f: ServiceF, lt: ServiceLifetime) -> None: except get_cancelled_exc_class() as exc: # BaseException lt.exception = exc raise # Always reraise cancellations - except Exception as exc: + except Exception as exc: # noqa: BLE001 lt.exception = unwrap_exc(exc) finally: lt.stopped.set() - _svc_lt.reset(outer_ctx_token) + _svc_lt.reset(parent_svc_lt) def serve( @@ -329,20 +376,20 @@ def serve( @asynccontextmanager async def _serve_root(svc: ServiceF) -> AsyncIterator[ServiceLifetimeView]: + async def wait_started(): + await child_lt.started + wait_tg.cancel_scope.cancel() + + async def wait_stopped(): + await child_lt.stopped + wait_tg.cancel_scope.cancel() + async with BlockingPortal() as portal: child_lt = ServiceLifetime(portal) tg = portal._task_group tg.start_soon(_run, svc, child_lt) # Wait for either started or stopped (service may fail before starting) async with create_task_group() as wait_tg: - async def wait_started(): - await child_lt.started - wait_tg.cancel_scope.cancel() - - async def wait_stopped(): - await child_lt.stopped - wait_tg.cancel_scope.cancel() - wait_tg.start_soon(wait_started) wait_tg.start_soon(wait_stopped) yield child_lt.view @@ -363,6 +410,7 @@ async def bind_parent_to(csl: ServiceLifetimeView): observe_tg.start_soon(bind_parent_to, child_lt) # Wait for either started or stopped (service may fail before starting) async with create_task_group() as wait_tg: + async def wait_started(): await child_lt.started wait_tg.cancel_scope.cancel() @@ -380,7 +428,16 @@ async def wait_stopped(): async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: - async with (nullcontext(parent.portal) if parent else BlockingPortal()) as portal: + async with nullcontext(parent.portal) if parent else BlockingPortal() as portal: lt = ServiceLifetime(portal) await _run(svc_f, lt) return lt.exit_code + + +def _run_many(*svcs: ServiceF) -> ServiceF: + async def _run(lt: ServiceLifetime) -> None: + children = [lt.start(svc) for svc in svcs] + # TODO Shutdown everything in case of an exception in any child + await wait_all(c.stopped for c in children) + + return _run diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index e0bec76..a107eeb 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -3,18 +3,23 @@ import dataclasses as dc import itertools import logging -from collections.abc import Iterable -from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator, Iterable +from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager +from dataclasses import replace from datetime import timedelta -from typing import Any, Generic, TypeVar, final +from itertools import chain, cycle +from typing import Any, Generic, Self, TypeVar, cast, final from anyio import ( BrokenResourceError, EndOfStream, WouldBlock, + create_memory_object_stream, create_task_group, get_cancelled_exc_class, ) +from anyio.abc import TaskGroup +from anyio.streams.memory import MemoryObjectSendStream from localpost._utils import ( TD_ZERO, @@ -37,91 +42,108 @@ logger = logging.getLogger("localpost.scheduler.cond") +import logging +from collections.abc import AsyncIterator, Callable +from functools import wraps +from typing import ParamSpec, TypeVar + +from localpost._utils import ( + DelayFactory, + ensure_delay_factory, + maybe_closing, +) + +# from ._scheduler import TriggerFactoryDecorator + +# P = ParamSpec("P") +# T = TypeVar("T") +# Trigger = AsyncIterator[T] +# TriggerDecorator = Callable[[Callable[P, AsyncIterator[T]]], Callable[P, AsyncIterator[T]]] +# TriggerMiddleware = Callable[[AsyncIterator[T]], AsyncIterator[T]] + +type TriggerMiddleware[T1, T2] = Callable[[AsyncIterator[T1]], AsyncIterator[T2]] + + +async def wait_trigger(time_spans: Iterable[timedelta], events: MemoryObjectSendStream[None]) -> None: + try: + for iter_n, iter_delay in enumerate(time_spans): + logger.debug("Sleeping for %s (iteration: %s)", td_str(iter_delay), iter_n) + await sleep(iter_delay) + try: + events.send_nowait(None) + except WouldBlock: + logger.warning("All executors are busy, skipping the event") + except BrokenResourceError: + logger.debug("All executors have been closed, stopping") + except get_cancelled_exc_class(): + logger.debug("Task is shutting down, stopping") + raise + finally: + events.close() + + @asynccontextmanager -async def wait_trigger(time_spans: Iterable[timedelta], shutting_down: EventView): - events, events_reader = MemoryStream[None].create(0) - - @cancellable_from(shutting_down) # DO NOT cancel the main task group - async def generate(): - try: - iter_n = 1 - logger.debug("Sleeping for %s (iteration: %s)", td_str(TD_ZERO), iter_n) - await events.send(None) # Execute the first iteration immediately - for iter_delay in time_spans: - iter_n += 1 - logger.debug("Sleeping for %s (iteration: %s)", td_str(iter_delay), iter_n) - await sleep(iter_delay) - try: - events.send_nowait(None) - except WouldBlock: - logger.warning("All executors are busy, skipping the event") - except BrokenResourceError: - logger.debug("All executors have been closed, stopping") - except get_cancelled_exc_class(): - logger.debug("Task is shutting down, stopping") - raise - finally: - events.close() - - # Order matters, the reader should be closed first (so the run loop can stop by itself) - async with create_task_group() as main_tg, events_reader: - start_task_soon(main_tg, generate) - try: - yield events_reader - except GeneratorExit: - # Can happen, if a trigger is wrapped in a middleware, backed by a generator - # (if we don't do that, it will be an unhandled exception in the task group) - pass +async def apply_middlewares[T](source: T, middlewares: tuple[Callable[[T], T], ...]) -> AsyncGenerator[T]: + def as_acm(t) -> AbstractAsyncContextManager[T]: + return t if isinstance(t, AbstractAsyncContextManager) else maybe_closing(t) + + async with AsyncExitStack() as scope: + source = await scope.enter_async_context(as_acm(source)) + for middleware in middlewares: + source = await scope.enter_async_context(as_acm(middleware(source))) + yield source @final @dc.dataclass(frozen=True, slots=True) -class Every: +class Every[T]: period: timedelta + middlewares: tuple[TriggerMiddleware[Any, Any], ...] = () def __repr__(self): return f"every({td_str(self.period)})" - def __call__(self, task: ScheduledTask) -> Trigger[None]: - return wait_trigger(itertools.cycle([self.period]), task.shutting_down) + def __rshift__[T2](self, other: TriggerMiddleware[T, T2]) -> Every[T2]: + return cast(Every[T2], replace(self, middlewares=self.middlewares + (other,))) + + def __call__(self, tg: TaskGroup, shutting_down: EventView) -> AbstractAsyncContextManager[AsyncIterator[T]]: + sender, receiver = create_memory_object_stream[None]() + tg.start_soon(wait_trigger, chain([0], cycle([self.period])), sender) + + return apply_middlewares(receiver, self.middlewares) def every(period: timedelta | str, /) -> ScheduledTaskTemplate[None]: - """ - Trigger an event every `period`. - """ - # return ScheduledTaskTemplate(Every(ensure_td(period))) >> buffer(0, full_mode="drop") - return ScheduledTaskTemplate(Every(ensure_td(period))) + """Trigger an event every `period`.""" + return Every(ensure_td(period)) @final @dc.dataclass(frozen=True, slots=True) -class After(Generic[ResT]): +class After[ResT, T]: target: Task[Any, ResT] + middlewares: tuple[TriggerMiddleware[Any, Any], ...] = () def __repr__(self): return f"after({self.target!r})" - def __call__(self, this_task: ScheduledTask) -> Trigger[ResT]: - task_exec_results = self.target.subscribe() + def __rshift__[T2](self, other: TriggerMiddleware[T, T2]) -> After[ResT, T2]: + return cast(After[ResT, T2], replace(self, middlewares=self.middlewares + (other,))) - async def run(): - try: - while True: - res = await task_exec_results.receive() + def __call__(self, _: TaskGroup, shutting_down: EventView) -> AbstractAsyncContextManager[AsyncIterator[T]]: + def generate(): + async with self.target.subscribe() as + async for task_exec_results.receive() if res.error: logger.warning("Target task failed, skipping") # TODO extra else: yield res.value - except EndOfStream: - logger.info("Target task completed, stopping") - finally: - task_exec_results.close() - - return ClosingContext(run()) + + task_exec_results = self.target.subscribe() + return apply_middlewares(source, self.middlewares) -def after(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[T]: +def after[T](target: ScheduledTask[Any, T], /) -> ScheduledTaskTemplate[T]: """ Trigger an event every time the target task completes successfully. """ @@ -156,3 +178,33 @@ def after_all(target: ScheduledTask[Any, Result[T]] | Task[Any, Result[T]], /) - Trigger an event every time the target task completes (successfully or not). """ return ScheduledTaskTemplate(AfterAll(target if isinstance(target, Task) else target.task)) + + +def take_first(n: int, /) -> TriggerMiddleware: + if n < 0: + raise ValueError("N must be a non-negative integer") + + async def middleware(events: AsyncIterator[T]) -> AsyncIterator[T]: + iter_n = 0 + async with maybe_closing(events): + async for event in events: + if iter_n >= n: + break + iter_n += 1 + yield event + + return middleware + + +def delay(value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: + delay_f = ensure_delay_factory(value) + + async def middleware(events: AsyncIterator[T]) -> AsyncIterator[T]: + async with maybe_closing(events): + async for event in events: + item_jitter = delay_f() + logger.debug("Sleeping for %s (delay)", td_str(item_jitter)) + await sleep(item_jitter) + yield event + + return middleware diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 80679fc..42747d5 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -1,16 +1,19 @@ from __future__ import annotations -import dataclasses as dc import inspect import logging import math import threading -from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager, contextmanager -from typing import Any, Generic, Protocol, TypeAlias, TypeVar, cast, final +from dataclasses import dataclass +from dataclasses import dataclass as define +from functools import partial +from typing import Any, TypeAlias, TypeVar, cast, final import anyio from anyio import BrokenResourceError, WouldBlock, create_task_group, open_signal_receiver, to_thread +from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( @@ -25,45 +28,29 @@ start_task_soon, wait_any, ) +from localpost.hosting import ServiceLifetime -T = TypeVar("T") -T2 = TypeVar("T2") -R = TypeVar("R") +# T = TypeVar("T") +# T2 = TypeVar("T2") +# R = TypeVar("R") DecF = TypeVar("DecF", bound=Callable[..., Any]) -TaskHandler: TypeAlias = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] -HandlerDecorator: TypeAlias = Callable[[Any], Any] +type AnyTaskHandler[T, R] = Callable[[T], Awaitable[R]] | Callable[[T], R] +type TaskHandler[T, R] = Callable[[T], Awaitable[R]] logger = logging.getLogger("localpost.scheduler") -@final -@dc.dataclass() -class Task( - Generic[T, R], - AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], # AsyncHandlerManager[T] -): - name: str - event_aware: bool - - def __init__(self, target: TaskHandler[T, R], /, *, name: str | None = None): - self.name = name or def_full_name(target) - self._target = target - e_aware = self.event_aware = len(inspect.signature(target).parameters) > 0 - - def e_handler(t) -> Callable[[T], Awaitable[R]]: - if is_async_callable(t): - return t if e_aware else lambda _: t() # type: ignore[misc] - return (lambda e: to_thread.run_sync(t, e)) if e_aware else (lambda _: to_thread.run_sync(t)) - - self._handle = e_handler(target) +def task_handler[T, R](func: AnyTaskHandler[T, R]) -> TaskHandler[T, R]: + return func if is_async_callable(func) else partial(to_thread.run_sync, func) - self._cm = ExitStack() - self._subscribers: list[MemoryObjectSendStream[Result[R]]] = [] - self._users = 0 - def __repr__(self): - return f"<{self.__class__.__name__} {self.name!r}>" +@final +@define() +class ScheduledTaskRun[T, R]: + task: ScheduledTask[T, R] + sl: ServiceLifetime + handle: TaskHandler[T, R] def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStream[Result[R]]: # By default, a stream is created with a buffer size of 0, which means that any write will be blocked until @@ -95,91 +82,10 @@ async def __call__(self, event: T) -> None: self._publish_result(result) raise - async def __aenter__(self): - self._users += 1 - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> bool | None: - self._users -= 1 - # A task can be scheduled multiple times, so we need to keep the results streams open until all the scheduled - # tasks are completed - if self._users == 0: - return self._cm.__exit__(exc_type, exc_value, traceback) - return False # Do not suppress exceptions - - -@final -class ScheduledTaskTemplate(Generic[T]): - @classmethod - def ensure(cls, tpl: TriggerFactory[T]) -> ScheduledTaskTemplate[T]: - if isinstance(tpl, cls): - return tpl - return cls(tpl) - - def __init__(self, tf: TriggerFactory[T]): - self._tf = tf - self._tf_queue: tuple[TriggerFactoryDecorator, ...] = () - self._handler_decorators: tuple[HandlerDecorator, ...] = () - - # TriggerFactory[T] - def __call__(self, *args, **kwargs) -> Trigger[T]: - return self.tf(*args, **kwargs) - - def __truediv__(self, middleware: TriggerFactoryMiddleware[T, T2]) -> ScheduledTaskTemplate[T2]: - from ._trigger import trigger_factory_middleware # noqa: PLC0415 - - return self // trigger_factory_middleware(middleware) - - def __floordiv__(self, decorator: TriggerFactoryDecorator[T, T2]) -> ScheduledTaskTemplate[T2]: - n = ScheduledTaskTemplate(self._tf) - n._tf_queue = self._tf_queue + (decorator,) - return cast(ScheduledTaskTemplate[T2], n) - - def __rshift__(self, decorator: HandlerDecorator) -> ScheduledTaskTemplate[T]: - n = ScheduledTaskTemplate[T](self._tf) - n._handler_decorators = self._handler_decorators + (decorator,) - return n - - def resolve_handler(self, task: Task[T, Any]) -> AbstractAsyncContextManager[Callable[[T], Awaitable[None]]]: - # TODO: Support handler decorators (flow module) - return task - - @property - def tf(self) -> TriggerFactory[T]: - tf = self._tf - for decorator in self._tf_queue: - tf = decorator(tf) - return tf - - -class ScheduledTask(Protocol[T, R]): - @property - def shutting_down(self) -> EventView: ... - - @property - def task(self) -> Task[T, R]: ... - - -@final -class _ScheduledTask(Generic[T, R]): - def __init__(self, task: Task[T, R], tf: TriggerFactory[T]): - self.task = task - self._trigger_factory = tf - tpl = ScheduledTaskTemplate.ensure(tf) - self._handler = tpl.resolve_handler(task) - self._shutting_down: EventView = Event() # Placeholder, resolved in run() - - def __repr__(self): - return f"ScheduledTask({self.name!r})" - @property def shutting_down(self) -> EventView: return self._shutting_down - @property - def name(self) -> str: - return self.task.name - async def run(self, shutting_down: EventView) -> None: self._shutting_down = shutting_down trigger = self._trigger_factory(self) @@ -190,157 +96,46 @@ async def run(self, shutting_down: EventView) -> None: logger.debug(f"{self!r} trigger is completed") logger.debug(f"{self!r} is done") - async def __call__(self, shutting_down: EventView) -> None: - return await self.run(shutting_down) - - -Trigger: TypeAlias = AbstractAsyncContextManager[AsyncIterator[T]] -TriggerFactory: TypeAlias = Callable[ - [ScheduledTask[T, Any]], AbstractAsyncContextManager[AsyncIterator[T]] # Trigger[T] -] -TriggerFactoryMiddleware: TypeAlias = Callable[ - [ - AbstractAsyncContextManager[AsyncIterator[T]], # Trigger[T] (source) - ScheduledTask, - ], - AsyncIterable[T2], # TODO AbstractAsyncContextManager[AsyncIterator[T2]] -] -TriggerFactoryDecorator: TypeAlias = Callable[ - [Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T]]]], # TriggerFactory[T] - Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T2]]], # TriggerFactory[T2] -] - - -def scheduled_task( - tf: TriggerFactory[T], /, *, name: str | None = None -) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: - """ - Schedule a task with the given trigger. - """ - - def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> _ScheduledTask[T, R]: - t = func if isinstance(func, Task) else Task(func) - if name: - t.name = name - return _ScheduledTask(t, tf) - - return _decorator - - -class Scheduler: - """ - Manages a collection of periodic tasks. - - Can be used standalone via `aserve()`, or integrated with hosting via `as_service()`. - """ - - def __init__(self, name: str = "scheduler"): - self._name = name - self._scheduled_tasks: list[_ScheduledTask[Any, Any]] = [] - self._shutting_down: Event | None = None - - @property - def name(self) -> str: - return self._name - - def task( - self, tf: TriggerFactory[T], /, *, name: str | None = None - ) -> Callable[[TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]], _ScheduledTask[T, R]]: - """ - Schedule a task with the given trigger. - """ - def _decorator(func: TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]): - if isinstance(func, _ScheduledTask): - func = func.task - st = scheduled_task(tf, name=name)(cast(TaskHandler[T, R] | Task[T, R], func)) - self._scheduled_tasks.append(st) - return st - - return _decorator - - def shutdown(self) -> None: - if self._shutting_down: - self._shutting_down.set() - - @asynccontextmanager - async def aserve(self): - """Run all scheduled tasks. Use `shutdown()` or exit the context to stop.""" - shutting_down = self._shutting_down = Event() - if not self._scheduled_tasks: - yield - return - # The done event is set when all tasks complete naturally (e.g. finite triggers) - done = Event() - - async def _run_task(st: _ScheduledTask[Any, Any], remaining: list[_ScheduledTask[Any, Any]]) -> None: - await st.run(shutting_down) - remaining.remove(st) - if not remaining: - done.set() - - remaining = list(self._scheduled_tasks) - async with create_task_group() as tg: - for st in self._scheduled_tasks: - start_task_soon(tg, lambda s=st: _run_task(s, remaining)) - yield done - shutting_down.set() - - @contextmanager - def serve(self): - """Run the scheduler in a background thread with its own event loop.""" - stop = threading.Event() - - async def _run_loop(): - async with self.aserve(): - await to_thread.run_sync(stop.wait) +@final +@define() +class ScheduledTask[T, R]: + name: str + _until_running: EventView + current_run: ScheduledTaskRun[T, R] | None = None - def _thread_target(): - anyio.run(_run_loop, **choose_anyio_backend()) + def __init__(self, h: TaskHandler[T, R], tf: TriggerFactory[T], /, *, name: str | None = None): + self.name = name or def_full_name(h) + self._handler = h + self._trigger_f = tf - t = threading.Thread(target=_thread_target, daemon=True) - t.start() - try: - yield - finally: - self.shutdown() - stop.set() - t.join() + def __repr__(self): + return f"<{self.__class__.__name__} {self.name!r}>" - async def as_service(self, lt) -> None: - """Run the scheduler as a hosting service (accepts a ServiceLifetime).""" - async with self.aserve(): - lt.set_started() - await lt.shutting_down.wait() + async def get_current_run(self) -> ScheduledTaskRun[T, R]: + await self._until_running.wait() + assert self.current_run is not None + return self.current_run + async def run(self, sl: ServiceLifetime) -> None: + # TODO + # - sl.set_started() + # - create and set current_run + # - + pass -async def _arun(target: Scheduler | _ScheduledTask[Any, Any]) -> None: - """Run a scheduler or single scheduled task with signal handling.""" - if isinstance(target, _ScheduledTask): - scheduler = Scheduler() - scheduler._scheduled_tasks.append(target) - else: - scheduler = target - async with scheduler.aserve() as done, create_task_group() as tg: +type TriggerEvents[T] = AbstractAsyncContextManager[AsyncIterator[T]] +type TriggerFactory[T] = Callable[[TaskGroup, EventView], TriggerEvents[T]] - async def _handle_signals(): - with open_signal_receiver(*HANDLED_SIGNALS) as signals: - async for _ in signals: - logger.info("Shutting down...") - scheduler.shutdown() - break - tg.start_soon(_handle_signals) - # Wait for either shutdown signal or all tasks completing naturally - await wait_any(done, scheduler._shutting_down) # type: ignore[arg-type] - tg.cancel_scope.cancel() +def scheduled_task[T](tf: TriggerFactory[T], /, *, name: str | None = None) -> Callable[..., ScheduledTask[T, Any]]: + """Schedule a task with the given trigger.""" + def _decorator[R](func: AnyTaskHandler[T, R]) -> ScheduledTask[T, R]: + st = ScheduledTask(task_handler(func), tf) + if name: + st.name = name + return st -def run(target: Scheduler | _ScheduledTask[Any, Any]) -> int: - """Run a scheduler or single scheduled task until completion or signal. Returns exit code.""" - try: - anyio.run(_arun, target, **choose_anyio_backend()) - except KeyboardInterrupt: - pass - return 0 + return _decorator diff --git a/localpost/scheduler/_trigger.py b/localpost/scheduler/_trigger.py index c75e8e9..9d48db4 100644 --- a/localpost/scheduler/_trigger.py +++ b/localpost/scheduler/_trigger.py @@ -1,67 +1 @@ from __future__ import annotations - -import logging -from contextlib import AbstractAsyncContextManager -from functools import wraps -from typing import TypeVar, cast - -from localpost._utils import ClosingContext, DelayFactory, cancellable_from, ensure_delay_factory, sleep, td_str - -from ._scheduler import ScheduledTask, Trigger, TriggerFactory, TriggerFactoryDecorator, TriggerFactoryMiddleware - -T = TypeVar("T") -T2 = TypeVar("T2") - -logger = logging.getLogger("localpost.scheduler.cond") - - -def trigger_factory_middleware(middleware: TriggerFactoryMiddleware[T, T2]) -> TriggerFactoryDecorator[T, T2]: - def _decorator(source: TriggerFactory[T]) -> TriggerFactory[T2]: - @wraps(source) - def _run(task): - source_events = source(task) - events = middleware(source_events, task) - return cast( - Trigger[T2], events if isinstance(events, AbstractAsyncContextManager) else ClosingContext(events) - ) - - return _run - - return _decorator - - -def take_first(n: int, /) -> TriggerFactoryDecorator[T, T]: - if n < 0: - raise ValueError("n must be a non-negative integer") - - @trigger_factory_middleware - async def middleware(source: Trigger[T], _): - iter_n = 0 - if iter_n >= n: - return - async with source as events: - async for event in events: - iter_n += 1 - yield event - if iter_n >= n: - break - - return middleware - - -def delay(value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: - delay_f = ensure_delay_factory(value) - - @trigger_factory_middleware - async def middleware(source: Trigger[T], task: ScheduledTask): - shutdown_aware_sleep = cancellable_from(task.shutting_down)(sleep) - async with source as events: - async for event in events: - item_jitter = delay_f() - logger.debug("Sleeping for %s (delay)", td_str(item_jitter)) - await shutdown_aware_sleep(item_jitter) - if task.shutting_down: - break - yield event - - return middleware diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 9a61c74..9ffe474 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -114,106 +114,6 @@ def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: return source -### -# Task Group related -### - - -@dc.dataclass(eq=False, slots=True) -class FutureResult: - value: Any = NOT_SET - - -def gather(*workload: Callable[[], Any] | tuple[Any, ...]) -> Sequence[Any]: - async def run_all(): - results: list[FutureResult] = [] - async with create_task_group() as tg: - for item in workload: - if callable(item): - func, args = item, () - else: - func, *args = item - res = FutureResult() - results.append(res) - tg.start_soon(run_item, res, func, *args) - return [res.value for res in results] - - async def run_item(res: FutureResult, func: Callable[..., Any], *args: Any) -> None: - res.value = await (func(*args) if is_async_callable(func) else to_thread.run_sync(func, *args)) - - return from_thread.run(run_all) - - -### -# Thread Pool -### - - -@dc.dataclass(frozen=True, eq=False, slots=True) -class ExecutorThread[T]: - func: Callable[[T], None] - inbox: ReceiveChannel[T] - stop_at: float | None - - def arun(self, wl: threading.Semaphore, tl: CapacityLimiter) -> Awaitable[None]: - return to_thread.run_sync(self.run, wl, limiter=tl) - - def run(self, limiter: threading.Semaphore) -> None: - with self.inbox as work_items: - for item in work_items: - try: - self.func(item) # It's AnyIO-managed thread in the end, so exceptions will be propagated - if (stop_at := self.stop_at) and (current_time() >= stop_at): - return - finally: - limiter.release() - - -@final -@dc.dataclass(frozen=True, eq=False, slots=True) -class ThreadPoolExecutor[T]: - _sender: SendChannel[T] - _limiter: threading.Semaphore - _start_worker: Callable[[], None] - - def acquire_nowait(self) -> Callable[[T], None] | None: - """ - Acquire a slot in the executor, get a submit callable for the payload. - - The slot is released when the payload is processed. - """ - return self._handle if self._limiter.acquire(blocking=False) else None - - def _handle(self, payload: T) -> None: - try: - self._sender.put_nowait(payload) - except WouldBlock: # No idle workers, start a new one - self._start_worker() - self._sender.put(payload) - - -@asynccontextmanager -async def create_executor[T]( - func: Callable[[T], None], - max_workers: int = min(32, (os.process_cpu_count() or 1) + 4), - worker_max_age: int = 60 * 5, # Seconds after which a thread is stopped -): - max_age_jitter = RandomDelay((0.0, worker_max_age * 0.2)) - threads_limiter = CapacityLimiter(max_workers) - work_limiter = cancellable_semaphore(max_workers) - sender, receiver = Channel[T].create() - - def start_thread() -> None: - stop_at = current_time() + worker_max_age + max_age_jitter().total_seconds() - thread = ExecutorThread(func, receiver.clone(), stop_at) - from_thread.run_sync(exec_tg.start_soon, thread.arun, work_limiter, threads_limiter) - - async with create_task_group() as exec_tg, sender: - permanent_thread = ExecutorThread(func, receiver, None) - exec_tg.start_soon(permanent_thread.arun, work_limiter, threads_limiter) - yield ThreadPoolExecutor[T](sender, work_limiter, start_thread) - - ### # Channels ### From 4ef3953b6a8cf855036e85d44641116e3f2c15e1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 24 Apr 2026 12:26:06 +0400 Subject: [PATCH 068/286] feat(http): end-to-end multi-threaded HTTP flow with per-request cancel scopes Dispatch each accepted request as an AnyIO task in the service's task group, so every request gets its own cancellation scope and shutdown cleanly cancels in-flight handlers. Back-pressure via a cancellable semaphore around the accept loop; the user's sync handler runs on an AnyIO worker under a CapacityLimiter. Router now exposes a native HTTPReqCtx adapter (Router.as_handler) alongside the existing .wsgi entrypoint, plus fluent get/post/put/delete/patch decorators. wrap_wsgi is rewritten against the real HTTPReqCtx with a buffered wsgi.input stream. Tests: router (URITemplate, .wsgi, .as_handler), wsgi (live server, body streaming, generator close), service (per-request tasks, max_concurrency, shutdown cancellation), and an end-to-end integration test booting run_app in a subprocess and verifying concurrent request handling plus clean SIGTERM shutdown. Also drops create_executor / gather from threadtools.__all__ (removed intentionally) and fixes a truncated cast() in _utils.py that was breaking package imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/multithread_server.py | 65 +++++ examples/http/threaded_server.py | 52 ---- examples/http/wsgi_app_server.py | 54 +++-- localpost/_utils.py | 2 +- localpost/http/__init__.py | 34 +++ localpost/http/_service.py | 86 +++++-- localpost/http/router.py | 261 ++++++++++++++++---- localpost/http/wsgi.py | 173 +++++++------ localpost/threadtools.py | 13 +- tests/http/conftest.py | 15 ++ tests/http/integration.py | 158 ++++++++++++ tests/http/router.py | 363 ++++++++++++++++++++++++++++ tests/http/service.py | 261 ++++++++++++++++++++ tests/http/wsgi.py | 157 ++++++++++++ 14 files changed, 1477 insertions(+), 217 deletions(-) create mode 100644 examples/http/multithread_server.py delete mode 100644 examples/http/threaded_server.py create mode 100644 tests/http/conftest.py create mode 100644 tests/http/integration.py create mode 100644 tests/http/router.py create mode 100644 tests/http/service.py create mode 100644 tests/http/wsgi.py diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py new file mode 100644 index 0000000..d818ee6 --- /dev/null +++ b/examples/http/multithread_server.py @@ -0,0 +1,65 @@ +"""Multi-threaded HTTP app. + +Run:: + + uv run examples/http/multithread_server.py + + curl http://localhost:8000/ + curl http://localhost:8000/hello/world + curl http://localhost:8000/slow # sleeps; spam a few of these in parallel + +Handlers run in AnyIO worker threads (``to_thread.run_sync``), bounded by +``max_concurrency``. Each request becomes its own async task in the service's task +group, so SIGINT / SIGTERM cancels in-flight work cleanly. +""" + +from __future__ import annotations + +import logging +import sys +import threading +import time + +from localpost.hosting import run_app +from localpost.http import ( + RequestCtx, + Response, + Router, + ServerConfig, + http_server, +) + + +def _root(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"hello from localpost\n"]) + + +def _hello(ctx: RequestCtx) -> Response: + name = ctx.path_args["name"] + body = f"Hello, {name}! (thread={threading.current_thread().name})\n".encode() + return Response(200, {"content-type": "text/plain"}, [body]) + + +def _slow(_: RequestCtx) -> Response: + time.sleep(1.0) # exercises concurrency: several of these run in parallel + body = f"done on thread={threading.current_thread().name}\n".encode() + return Response(200, {"content-type": "text/plain"}, [body]) + + +def build_router() -> Router: + router = Router() + router.get("/")(_root) + router.get("/hello/{name}")(_hello) + router.get("/slow")(_slow) + return router + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + router = build_router() + config = ServerConfig(host="127.0.0.1", port=8000) + return run_app(http_server(config, router.as_handler(), max_concurrency=16)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/http/threaded_server.py b/examples/http/threaded_server.py deleted file mode 100644 index 16d48ea..0000000 --- a/examples/http/threaded_server.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from contextlib import AbstractContextManager - -import anyio -import h11 -from anyio import from_thread, to_thread - -from localpost.http.config import ServerConfig -from localpost.http.server import HTTPReqCtx, start_http_server -from localpost.threadtools import create_executor - - -async def main(): - logging.basicConfig(level=logging.DEBUG) - - routes = { - (b"GET", b"/"), - (b"GET", b"/hello"), - } - - def handle_route(req_ctx: AbstractContextManager[HTTPReqCtx]): - with req_ctx as req: - req.start_response(h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")])) - req.send(b"Hello, World!\n") - req.finish_response() - - def complex_app(ctx: HTTPReqCtx): - key = (ctx.request.method, ctx.request.target) - if key in routes: - if handle_soon := executor.acquire_nowait(): - handle_soon(ctx.borrow()) - else: - ctx.complete( - h11.Response(status_code=503, headers=[(b"Content-Type", b"text/plain")]), b"Server is busy\n" - ) - else: - response = h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]) - body = b"Not found\n" - ctx.complete(response, body) - - def run_server(): - with start_http_server(ServerConfig()) as server: - while True: - from_thread.check_cancelled() - server.run(complex_app) - - async with create_executor(handle_route) as executor: - await to_thread.run_sync(run_server) - - -if __name__ == "__main__": - anyio.run(main) diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py index 3e89151..629dfe6 100644 --- a/examples/http/wsgi_app_server.py +++ b/examples/http/wsgi_app_server.py @@ -1,40 +1,52 @@ +"""Serve a Flask (WSGI) app via ``localpost.http.wsgi_server``. + +Run:: + + uv run --group dev-http examples/http/wsgi_app_server.py + + curl http://localhost:8000/hello/world + curl http://localhost:8000/hello-stream/world +""" + +from __future__ import annotations + import logging +import sys -from anyio import from_thread, to_thread -from flask import Flask, stream_with_context +from flask import Flask from flask import request as flask_request +from flask.helpers import stream_with_context # type: ignore[import-untyped] -from localpost.http.config import ServerConfig -from localpost.http.server import start_http_server -from localpost.http.wsgi import wrap_wsgi +from localpost.hosting import run_app +from localpost.http import ServerConfig, wsgi_server -async def main(): - logging.basicConfig(level=logging.DEBUG) - +def build_app() -> Flask: app = Flask(__name__) @app.route("/hello/") - def hello(name): + def hello(name: str): user_agent = flask_request.headers.get("User-Agent", "Unknown") return f"Hello, {name}! Your User-Agent is: {user_agent}\n" @app.route("/hello-stream/") - @stream_with_context - def hello_stream(name): + def hello_stream(name: str): user_agent = flask_request.headers.get("User-Agent", "Unknown") - yield f"Hello, {name}! " - yield f"Your User-Agent is: {user_agent}\n" - def run_server(): - with start_http_server(ServerConfig()) as server: - while True: - from_thread.check_cancelled() - server.run(req_handler) + def generate(): + yield f"Hello, {name}! " + yield f"Your User-Agent is: {user_agent}\n" + + return stream_with_context(generate()) + + return app + - async with wrap_wsgi(app) as req_handler: - await to_thread.run_sync(run_server) +def main() -> int: + logging.basicConfig(level=logging.INFO) + config = ServerConfig(host="127.0.0.1", port=8000) + return run_app(wsgi_server(config, build_app(), max_concurrency=8)) if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/localpost/_utils.py b/localpost/_utils.py index 741a410..7bf406a 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -353,7 +353,7 @@ class MemoryStream(Generic[T]): @staticmethod def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjectReceiveStream[T]]: send, receive = create_memory_object_stream(max_buffer_size) - return cast(Mem + return cast(MemorySendStream[T], send), cast(MemoryObjectReceiveStream[T], receive) class AsyncBackendConfig(TypedDict): diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index e69de29..8e33984 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -0,0 +1,34 @@ +from localpost.http._service import http_server, wsgi_server +from localpost.http.config import LOGGER_NAME, ServerConfig +from localpost.http.router import ( + RequestCtx, + Response, + Router, + URITemplate, +) +from localpost.http.router import ( + RequestHandler as RouterRequestHandler, +) +from localpost.http.server import HTTPReqCtx, RequestHandler, start_http_server +from localpost.http.wsgi import wrap_wsgi + +__all__ = [ + # config + "ServerConfig", + "LOGGER_NAME", + # server + "start_http_server", + "HTTPReqCtx", + "RequestHandler", + # router + "Router", + "URITemplate", + "RequestCtx", + "Response", + "RouterRequestHandler", + # wsgi + "wrap_wsgi", + # hosting + "http_server", + "wsgi_server", +] diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 4e14576..90665b3 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,42 +1,82 @@ +from __future__ import annotations + from collections.abc import Awaitable +from contextlib import ExitStack from wsgiref.types import WSGIApplication from anyio import CapacityLimiter, from_thread, to_thread -from localpost import hosting +from localpost import hosting, threadtools from localpost.hosting import ServiceLifetime from localpost.http.config import ServerConfig -from localpost.http.server import RequestHandler, start_http_server -from localpost.threadtools import create_executor +from localpost.http.server import HTTPReqCtx, RequestHandler, start_http_server +from localpost.http.wsgi import wrap_wsgi + +__all__ = ["http_server", "wsgi_server"] @hosting.service -def http_server(config: ServerConfig, handler: RequestHandler, /): - def run(sl: ServiceLifetime) -> Awaitable[None]: - def run_forever(): - with start_http_server(config) as server: - sl.set_started() - while not sl.shutting_down.is_set(): - from_thread.check_cancelled() - server.run(handler) +def http_server( + config: ServerConfig, + handler: RequestHandler, + /, + *, + max_concurrency: int = 1, +): + """Run an :func:`start_http_server` loop inside a hosted service. - return to_thread.run_sync(run_forever, limiter=CapacityLimiter(1)) + Each accepted request is dispatched as an AnyIO task in the service's task group, + so every request has its own cancellation scope and shutdown cancels in-flight + handlers. ``max_concurrency`` bounds concurrent handlers and applies back-pressure + on the accept loop. With the default ``max_concurrency=1`` requests are still + handed off to the task group (and therefore gain a cancel scope), they just never + run concurrently with each other. - return run + ``handler`` runs on an AnyIO worker thread via ``to_thread.run_sync``. + """ + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + def run(lt: ServiceLifetime) -> Awaitable[None]: + req_slots = threadtools.cancellable_semaphore(max_concurrency) + handler_limiter = CapacityLimiter(max_concurrency) -@hosting.service -def wsgi_server(config: ServerConfig, app: WSGIApplication, /): - async def run(sl: ServiceLifetime): - def run_forever(): + async def handle_request(ctx: HTTPReqCtx, borrow_stack: ExitStack) -> None: + try: + await to_thread.run_sync(handler, ctx, limiter=handler_limiter) + finally: + borrow_stack.close() + req_slots.release() + + def dispatch(ctx: HTTPReqCtx) -> None: + req_slots.acquire() # back-pressure + stack = ExitStack() + stack.enter_context(ctx.borrow()) # detach the socket from the accept loop + try: + from_thread.run_sync(lt.tg.start_soon, handle_request, ctx, stack) + except BaseException: + stack.close() + req_slots.release() + raise + + def run_server() -> None: with start_http_server(config) as server: - sl.set_started() - while not sl.shutting_down.is_set(): + lt.set_started() + while not lt.shutting_down.is_set(): from_thread.check_cancelled() - server.run(handler) + server.run(dispatch) - async with create_executor(handle_route) as executor: - # FIXME handler - await to_thread.run_sync(run_forever, limiter=CapacityLimiter(1)) + return to_thread.run_sync(run_server, limiter=CapacityLimiter(1)) + + return run +def wsgi_server( + config: ServerConfig, + app: WSGIApplication, + /, + *, + max_concurrency: int = 1, +): + """Same as :func:`http_server`, but serves a WSGI application.""" + return http_server(config, wrap_wsgi(app), max_concurrency=max_concurrency) diff --git a/localpost/http/router.py b/localpost/http/router.py index 091686b..e49f045 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -8,8 +8,20 @@ from typing import Self, final from urllib.parse import parse_qs +import h11 + from localpost._utils import NOT_SET from localpost.http.config import DEFAULT_BUFFER_SIZE +from localpost.http.server import HTTPReqCtx +from localpost.http.server import RequestHandler as NativeRequestHandler + +__all__ = [ + "URITemplate", + "RequestCtx", + "Response", + "RequestHandler", + "Router", +] _VAR_PATTERN = re.compile(r"\{([^}]+)\}") @@ -29,7 +41,6 @@ def parse(cls, template: str) -> Self: regex_parts: list[str] = [] last_end = 0 for m in _VAR_PATTERN.finditer(template): - # Escape literal text between variables regex_parts.append(re.escape(template[last_end : m.start()])) var_name = m.group(1) variable_names.append(var_name) @@ -60,15 +71,12 @@ class RequestCtx: query_string: str query_args: Mapping[str, list[str]] path_args: Mapping[str, str] - """Path params of the matched route.""" receive: Callable[[int], bytes] - """Receive the body from the connection.""" _req_body: bytearray | None | object = field(default=NOT_SET, init=False, repr=False) def body(self, cache: bool = False) -> bytes: - """Read the request body.""" if self._req_body is None: raise RuntimeError("body has been read and not cached") if isinstance(self._req_body, bytearray): @@ -92,38 +100,68 @@ class Response: RequestHandler = Callable[[RequestCtx], Response] -RequestHandlerMiddleware = Callable[[RequestHandler], RequestHandler] -def _headers_from_environ(environ: dict) -> dict[str, str]: - headers: dict[str, str] = {} - for key, value in environ.items(): - if key.startswith("HTTP_"): - header_name = key[5:].replace("_", "-").lower() - headers[header_name] = value - if "CONTENT_TYPE" in environ: - headers["content-type"] = environ["CONTENT_TYPE"] - if "CONTENT_LENGTH" in environ: - headers["content-length"] = environ["CONTENT_LENGTH"] - return headers +@dataclass(eq=False, slots=True) +class Router: + """Simple URI-template router. + Usage:: -@dataclass(eq=False, frozen=True, slots=True) -class Router: - paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]] + router = Router() - def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: - """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" - request_path = environ.get("PATH_INFO", "/") - request_method_str = environ.get("REQUEST_METHOD", "GET").upper() + @router.get("/hello/{name}") + def hello(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"hi"]) + + # As native handler: + http_server(config, router.as_handler()) + # As WSGI app: + gunicorn.run(router.wsgi) + """ + + paths: dict[URITemplate, dict[HTTPMethod, RequestHandler]] = field(default_factory=dict) + + # --- Registration ------------------------------------------------- + + def add(self, method: HTTPMethod | str, template: str, handler: RequestHandler) -> None: + m = method if isinstance(method, HTTPMethod) else HTTPMethod(method.upper()) + key = _find_template(self.paths, template) or URITemplate.parse(template) + self.paths.setdefault(key, {})[m] = handler + + def _decorator(self, method: HTTPMethod, template: str) -> Callable[[RequestHandler], RequestHandler]: + def deco(handler: RequestHandler) -> RequestHandler: + self.add(method, template, handler) + return handler + + return deco + + def get(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + return self._decorator(HTTPMethod.GET, template) + + def post(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + return self._decorator(HTTPMethod.POST, template) - # Find matching template + def put(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + return self._decorator(HTTPMethod.PUT, template) + + def delete(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + return self._decorator(HTTPMethod.DELETE, template) + + def patch(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + return self._decorator(HTTPMethod.PATCH, template) + + # --- Dispatch ----------------------------------------------------- + + def _match( + self, path: str, method_str: str + ) -> _MatchResult: matched_template: URITemplate | None = None path_args: dict[str, str] = {} matched_methods: Mapping[HTTPMethod, RequestHandler] | None = None for template, methods in self.paths.items(): - result = template.match(request_path) + result = template.match(path) if result is not None: matched_template = template path_args = result @@ -131,25 +169,49 @@ def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: break if matched_template is None or matched_methods is None: - start_response("404 Not Found", [("Content-Type", "text/plain")]) - return [b"Not Found"] + return _MatchResult(kind="not_found") try: - method = HTTPMethod(request_method_str) + method = HTTPMethod(method_str) except ValueError: - start_response("405 Method Not Allowed", [("Content-Type", "text/plain")]) - return [b"Method Not Allowed"] + return _MatchResult(kind="method_not_allowed", allowed=tuple(matched_methods)) handler = matched_methods.get(method) if handler is None: - allowed = ", ".join(m.value for m in matched_methods) - start_response("405 Method Not Allowed", [("Content-Type", "text/plain"), ("Allow", allowed)]) + return _MatchResult(kind="method_not_allowed", allowed=tuple(matched_methods)) + + return _MatchResult( + kind="ok", + handler=handler, + method=method, + matched_template=matched_template, + path_args=path_args, + allowed=tuple(matched_methods), + ) + + def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: + """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" + request_path = environ.get("PATH_INFO", "/") + request_method_str = environ.get("REQUEST_METHOD", "GET").upper() + match = self._match(request_path, request_method_str) + + if match.kind == "not_found": + start_response("404 Not Found", [("Content-Type", "text/plain")]) + return [b"Not Found"] + if match.kind == "method_not_allowed": + allowed = ", ".join(m.value for m in match.allowed) + start_response( + "405 Method Not Allowed", + [("Content-Type", "text/plain"), ("Allow", allowed)], + ) return [b"Method Not Allowed"] + assert match.handler is not None + assert match.matched_template is not None + assert match.method is not None + query_string = environ.get("QUERY_STRING", "") - query_args = parse_qs(query_string) headers = _headers_from_environ(environ) - wsgi_input = environ.get("wsgi.input") def receive(size: int) -> bytes: @@ -161,21 +223,138 @@ def receive(size: int) -> bytes: ctx = RequestCtx( exit_stack=stack, headers=headers, - method=method, - matched_template=matched_template, + method=match.method, + matched_template=match.matched_template, path=request_path, query_string=query_string, - query_args=query_args, - path_args=path_args, + query_args=parse_qs(query_string), + path_args=match.path_args, receive=receive, ) - response = handler(ctx) + response = match.handler(ctx) - response_headers = [(k, v) for k, v in response.headers.items()] status_line = f"{response.status_code} {_status_phrase(response.status_code)}" - start_response(status_line, response_headers) + start_response(status_line, [(k, v) for k, v in response.headers.items()]) return response.body + def as_handler(self) -> NativeRequestHandler: + """Return a :class:`localpost.http.RequestHandler` that dispatches via this router.""" + + def handle(http_ctx: HTTPReqCtx) -> None: + req = http_ctx.request + target = req.target.decode("iso-8859-1") + if "?" in target: + path, query_string = target.split("?", 1) + else: + path, query_string = target, "" + method_str = req.method.decode("ascii").upper() + + match = self._match(path, method_str) + + if match.kind == "not_found": + _send_plain(http_ctx, 404, b"Not Found") + return + if match.kind == "method_not_allowed": + allowed = ", ".join(m.value for m in match.allowed) + _send_plain( + http_ctx, + 405, + b"Method Not Allowed", + extra_headers=[(b"Allow", allowed.encode("ascii"))], + ) + return + + assert match.handler is not None + assert match.matched_template is not None + assert match.method is not None + + headers = {name.decode("iso-8859-1").lower(): value.decode("iso-8859-1") for name, value in req.headers} + + with ExitStack() as stack: + ctx = RequestCtx( + exit_stack=stack, + headers=headers, + method=match.method, + matched_template=match.matched_template, + path=path, + query_string=query_string, + query_args=parse_qs(query_string), + path_args=match.path_args, + receive=http_ctx.receive, + ) + response = match.handler(ctx) + + h11_headers = [(k.encode("iso-8859-1"), v.encode("iso-8859-1")) for k, v in response.headers.items()] + http_ctx.start_response( + h11.Response( + status_code=response.status_code, + headers=h11_headers, + reason=_status_phrase(response.status_code).encode("iso-8859-1"), + ) + ) + for chunk in response.body: + if chunk: + http_ctx.send(chunk) + http_ctx.finish_response() + + return handle + + +@final +@dataclass(frozen=True, slots=True) +class _MatchResult: + kind: str # "ok" | "not_found" | "method_not_allowed" + handler: RequestHandler | None = None + method: HTTPMethod | None = None + matched_template: URITemplate | None = None + path_args: Mapping[str, str] = field(default_factory=dict) + allowed: tuple[HTTPMethod, ...] = () + + +def _find_template( + paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]], template_str: str +) -> URITemplate | None: + for t in paths: + if t.template == template_str: + return t + return None + + +def _headers_from_environ(environ: dict) -> dict[str, str]: + headers: dict[str, str] = {} + for key, value in environ.items(): + if key.startswith("HTTP_"): + header_name = key[5:].replace("_", "-").lower() + headers[header_name] = value + if "CONTENT_TYPE" in environ: + headers["content-type"] = environ["CONTENT_TYPE"] + if "CONTENT_LENGTH" in environ: + headers["content-length"] = environ["CONTENT_LENGTH"] + return headers + + +def _send_plain( + ctx: HTTPReqCtx, + status_code: int, + body: bytes, + *, + extra_headers: list[tuple[bytes, bytes]] | None = None, +) -> None: + headers: list[tuple[bytes, bytes]] = [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ] + if extra_headers: + headers.extend(extra_headers) + ctx.complete( + h11.Response( + status_code=status_code, + headers=headers, + reason=_status_phrase(status_code).encode("iso-8859-1"), + ), + body, + ) + def _status_phrase(code: int) -> str: phrases = { diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 869eeb3..3c95474 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -1,135 +1,172 @@ from __future__ import annotations import sys -from collections.abc import Callable, Iterable -from contextlib import AbstractContextManager, closing, suppress -from io import BufferedReader, IOBase, RawIOBase +from collections.abc import Buffer, Callable +from io import RawIOBase from typing import Any, final, override from wsgiref.types import WSGIApplication import h11 -from localpost.http.server import BorrowedHTTPReq, RequestHandler +from localpost.http.server import HTTPReqCtx, RequestHandler + +__all__ = ["wrap_wsgi"] @final -class RequestBodyStream(RawIOBase): - def __init__(self, ctx: BorrowedHTTPReq) -> None: +class _RequestBodyStream(RawIOBase): + """Expose ``HTTPReqCtx.receive`` as a readable file-like object for ``wsgi.input``.""" + + def __init__(self, ctx: HTTPReqCtx) -> None: self._ctx = ctx - self.completed = False + self._eof = False + self._leftover = b"" @override - def writable(self): + def writable(self) -> bool: return False @override - def seekable(self): + def seekable(self) -> bool: return False @override - def readable(self): + def readable(self) -> bool: return True @override - def readall(self): - chunks = bytearray() - while not self.completed: - chunks.extend(self._ctx.receive()) - return chunks + def readall(self) -> bytes: + buf = bytearray(self._leftover) + self._leftover = b"" + while not self._eof: + chunk = self._ctx.receive() + if not chunk: + self._eof = True + break + buf.extend(chunk) + return bytes(buf) @override - def readinto(self, b: bytearray, /) -> int: - try: - data = self._ctx.receive(len(b)) - size = len(data) - b[:size] = data - return size - except EOFError: + def readinto(self, buffer: Buffer, /) -> int: + view = memoryview(buffer).cast("B") + n = len(view) + if self._leftover: + take = min(n, len(self._leftover)) + view[:take] = self._leftover[:take] + self._leftover = self._leftover[take:] + return take + if self._eof: + return 0 + data = self._ctx.receive(n) + if not data: + self._eof = True return 0 + if len(data) <= n: + view[: len(data)] = data + return len(data) + view[:n] = data[:n] + self._leftover = data[n:] + return n def wrap_wsgi(app: WSGIApplication) -> RequestHandler: - """Wrap a WSGI application as a RequestHandler.""" + """Wrap a WSGI application as a native :class:`RequestHandler`.""" - def handler( - client: HTTPConn, request: h11.Request, body: IOBase - ) -> tuple[h11.Response, AbstractContextManager[Iterable[h11.Data]]]: - environ = _build_environ(client, request, body) - response: h11.Response | None = None + def handler(ctx: HTTPReqCtx) -> None: + environ = _build_environ(ctx) - def wsgi_start_response( + response_state: dict[str, Any] = {} + + def start_response( status: str, headers: list[tuple[str, str]], exc_info: Any = None, ) -> Callable[[bytes], None]: - nonlocal response if exc_info: try: - raise exc_info[1].with_traceback(exc_info[2]) + if response_state.get("started"): + raise exc_info[1].with_traceback(exc_info[2]) finally: - exc_info = None # Avoid circular reference - status_code = int(status.split(" ", 1)[0]) # Parse from "200 OK" format - response = h11.Response( + exc_info = None + status_code = int(status.split(" ", 1)[0]) + reason = status.split(" ", 1)[1] if " " in status else "" + h11_headers = [ + (name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in headers + ] + response_state["response"] = h11.Response( status_code=status_code, - headers=[(name.encode("ISO-8859-1"), value.encode("ISO-8859-1")) for name, value in headers], + headers=h11_headers, + reason=reason.encode("iso-8859-1") if reason else b"", ) - return _wsgi_response_write - - def wsgi_run(): - chunks = app(environ, wsgi_start_response) - yield # Skip the first iteration, to call the app and set the response object - with closing(chunks) if hasattr(chunks, "close") else suppress(): # type: ignore - for chunk in chunks: - yield h11.Data(chunk) + return _wsgi_write_deprecated - response_body = wsgi_run() - next(response_body) - assert response, "WSGI app did not call start_response" - return response, closing(response_body) + body_iter = app(environ, start_response) + try: + first_nonempty: bytes | None = None + # Materialize the first chunk (even if empty) — guarantees start_response has been called. + iterator = iter(body_iter) + for chunk in iterator: + if chunk: + first_nonempty = chunk + break + response = response_state.get("response") + if response is None: + raise RuntimeError("WSGI app returned without calling start_response") + ctx.start_response(response) + response_state["started"] = True + if first_nonempty is not None: + ctx.send(first_nonempty) + for chunk in iterator: + if chunk: + ctx.send(chunk) + ctx.finish_response() + finally: + close = getattr(body_iter, "close", None) + if close is not None: + close() return handler -def _wsgi_response_write(_: bytes) -> None: - raise NotImplementedError("write() is deprecated and not supported") +def _wsgi_write_deprecated(_: bytes) -> None: + raise NotImplementedError("The WSGI write() callable is deprecated and not supported") -def _build_environ(client: HTTPConn, request: h11.Request, body: IOBase) -> dict[str, Any]: - # Decode path and parse query string - path = request.target.decode("ISO-8859-1") - if "?" in path: - path, query_string = path.split("?", 1) +def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: + request = ctx.request + target = request.target.decode("iso-8859-1") + if "?" in target: + path, query_string = target.split("?", 1) else: - query_string = "" + path, query_string = target, "" - headers_dict = {} + headers_dict: dict[str, str] = {} for name, value in request.headers: - headers_dict[name.decode("ISO-8859-1").lower()] = value.decode("ISO-8859-1") + headers_dict[name.decode("iso-8859-1").lower()] = value.decode("iso-8859-1") - # See https://wsgi.readthedocs.io/en/latest/definitions.html - environ = { + environ: dict[str, Any] = { "REQUEST_METHOD": request.method.decode("ascii"), + "SCRIPT_NAME": "", "PATH_INFO": path, "QUERY_STRING": query_string, "CONTENT_TYPE": headers_dict.get("content-type", ""), "CONTENT_LENGTH": headers_dict.get("content-length", ""), - "SERVER_NAME": client.config.host, - "SERVER_PORT": str(client.server.port), + "SERVER_NAME": ctx._server.config.host, + "SERVER_PORT": str(ctx._server.port), "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", "wsgi.version": (1, 0), "wsgi.url_scheme": "http", - "wsgi.input": BufferedReader(body), - "wsgi.errors": sys.stderr, # Handle it later with the logger + "wsgi.input": _RequestBodyStream(ctx), + "wsgi.errors": sys.stderr, "wsgi.multithread": True, "wsgi.multiprocess": False, "wsgi.run_once": False, } - # Add HTTP headers for name, value in request.headers: - name_str = name.decode("ISO-8859-1").upper().replace("-", "_") - if name_str not in ("CONTENT_TYPE", "CONTENT_LENGTH"): - name_str = f"HTTP_{name_str}" - environ[name_str] = value.decode("ISO-8859-1") + name_str = name.decode("iso-8859-1").upper().replace("-", "_") + if name_str in ("CONTENT_TYPE", "CONTENT_LENGTH"): + continue + environ[f"HTTP_{name_str}"] = value.decode("iso-8859-1") return environ diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 9ffe474..170a76a 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -1,34 +1,25 @@ from __future__ import annotations import dataclasses as dc -import os import threading import time from collections import deque -from collections.abc import Awaitable, Callable, Iterator, Sequence -from contextlib import asynccontextmanager -from typing import Any, Protocol, Self, final, override +from collections.abc import Iterator +from typing import Protocol, Self, final, override from anyio import ( - CapacityLimiter, ClosedResourceError, EndOfStream, WouldBlock, - create_task_group, from_thread, - to_thread, ) __all__ = [ "Channel", "SendChannel", "ReceiveChannel", - "create_executor", - "gather", ] -from localpost._utils import NOT_SET, RandomDelay, is_async_callable - CHECK_TIMEOUT: float = 1.0 """Timeout (seconds) for cancellation checks (e.g. in the server loop).""" diff --git a/tests/http/conftest.py b/tests/http/conftest.py new file mode 100644 index 0000000..b8d4c2d --- /dev/null +++ b/tests/http/conftest.py @@ -0,0 +1,15 @@ +import socket + +import pytest + + +@pytest.fixture +def free_port() -> int: + """Bind a temporary socket to port 0 and return the assigned port number. + + The socket is closed before returning; there is a tiny race window where the + OS may reuse the port. Good enough for tests, and avoids ``port=0`` read-back. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] diff --git a/tests/http/integration.py b/tests/http/integration.py new file mode 100644 index 0000000..86ba777 --- /dev/null +++ b/tests/http/integration.py @@ -0,0 +1,158 @@ +"""End-to-end integration test for the HTTP flow. + +Boots the full ``run_app`` + ``http_server`` + ``Router`` stack in a subprocess, +fires concurrent HTTP requests, confirms requests are served from multiple worker +threads, and then verifies a clean shutdown via SIGTERM. +""" + +from __future__ import annotations + +import concurrent.futures +import os +import signal +import socket +import subprocess +import sys +import textwrap +import time + +import httpx +import pytest + +pytestmark = pytest.mark.integration + + +_APP_SCRIPT = textwrap.dedent( + """ + import logging + import os + import sys + import threading + import time + + from localpost.hosting import run_app + from localpost.http import RequestCtx, Response, Router, ServerConfig, http_server + + + logging.basicConfig(level=logging.INFO) + + + def _root(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"pong"]) + + + def _slow(_: RequestCtx) -> Response: + time.sleep(0.2) + body = str(threading.get_ident()).encode() + return Response(200, {"content-type": "text/plain"}, [body]) + + + def _hello(ctx: RequestCtx) -> Response: + return Response( + 200, + {"content-type": "text/plain"}, + [f"hi {ctx.path_args['name']}".encode()], + ) + + + router = Router() + router.get("/ping")(_root) + router.get("/slow")(_slow) + router.get("/hello/{name}")(_hello) + + port = int(os.environ["LP_TEST_PORT"]) + cfg = ServerConfig(host="127.0.0.1", port=port) + sys.exit(run_app(http_server(cfg, router.as_handler(), max_concurrency=8))) + """ +) + + +def _pick_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_ready(port: int, deadline: float = 10.0) -> bool: + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + +@pytest.fixture +def app_process(): + port = _pick_free_port() + env = {**os.environ, "LP_TEST_PORT": str(port)} + proc = subprocess.Popen( # noqa: S603 + [sys.executable, "-u", "-c", _APP_SCRIPT], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + if not _wait_ready(port): + stdout, stderr = b"", b"" + if proc.poll() is not None: + stdout, stderr = proc.communicate(timeout=1) + pytest.fail( + f"Server did not become ready on port {port}.\n" + f"stdout: {stdout!r}\nstderr: {stderr!r}" + ) + yield port, proc + finally: + if proc.poll() is None: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +class TestHttpIntegration: + def test_simple_get(self, app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2) + assert r.status_code == 200 + assert r.text == "pong" + + def test_path_params(self, app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=2) + assert r.status_code == 200 + assert r.text == "hi world" + + def test_404(self, app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/nonexistent", timeout=2) + assert r.status_code == 404 + + def test_concurrent_requests_span_multiple_threads(self, app_process): + port, _ = app_process + n = 8 + with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex: + futures = [ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) for _ in range(n)] + responses = [f.result() for f in futures] + + assert all(r.status_code == 200 for r in responses) + thread_ids = {r.text for r in responses} + assert len(thread_ids) >= 2, f"expected multiple worker threads, saw: {thread_ids}" + + def test_clean_shutdown_via_sigterm(self, app_process): + port, proc = app_process + + # Sanity check + assert httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2).status_code == 200 + + proc.send_signal(signal.SIGTERM) + rc = proc.wait(timeout=5) + assert rc == 0, f"process exited with code {rc}" + + # Port should be free now — expect ConnectionRefusedError once the server is down. + with pytest.raises(ConnectionRefusedError), socket.create_connection(("127.0.0.1", port), timeout=1): + pass diff --git a/tests/http/router.py b/tests/http/router.py new file mode 100644 index 0000000..7947233 --- /dev/null +++ b/tests/http/router.py @@ -0,0 +1,363 @@ +"""Tests for localpost.http.router.""" + +from __future__ import annotations + +import threading +from http import HTTPMethod +from io import BytesIO + +import httpx +import pytest + +from localpost import threadtools +from localpost.http import RequestCtx, Response, Router, ServerConfig, URITemplate, start_http_server + +# --- URITemplate ---------------------------------------------------------- + + +class TestURITemplate: + def test_literal_match(self): + t = URITemplate.parse("/foo/bar") + assert t.match("/foo/bar") == {} + assert t.match("/foo/baz") is None + + def test_single_var_match(self): + t = URITemplate.parse("/books/{id}") + assert t.match("/books/42") == {"id": "42"} + assert t.match("/books") is None + assert t.match("/books/42/chapters") is None # variable doesn't span slashes + + def test_multiple_vars_match(self): + t = URITemplate.parse("/users/{uid}/posts/{pid}") + assert t.match("/users/alice/posts/17") == {"uid": "alice", "pid": "17"} + assert t.match("/users/alice") is None + + def test_variable_names_preserved(self): + t = URITemplate.parse("/a/{x}/b/{y}") + assert t.variable_names == ("x", "y") + + def test_non_matching_returns_none(self): + t = URITemplate.parse("/hello/{name}") + assert t.match("/goodbye/alice") is None + + +# --- Router.wsgi ---------------------------------------------------------- + + +def _fake_wsgi(router: Router, method: str, path: str, query: str = "", body: bytes = b""): + """Drive router.wsgi synchronously; returns (status_line, headers, body).""" + captured: dict = {} + + def start_response(status, headers): + captured["status"] = status + captured["headers"] = headers + + environ = { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "QUERY_STRING": query, + "wsgi.input": BytesIO(body), + } + out = router.wsgi(environ, start_response) + return captured["status"], captured["headers"], b"".join(out) + + +class TestRouterWSGI: + def test_matches_and_dispatches(self): + router = Router() + + @router.get("/hello/{name}") + def hello(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [f"hi {ctx.path_args['name']}".encode()]) + + assert hello # keep reference so pyright is happy + + status, _, body = _fake_wsgi(router, "GET", "/hello/alice") + assert status.startswith("200") + assert body == b"hi alice" + + def test_404_on_miss(self): + router = Router() + status, _, body = _fake_wsgi(router, "GET", "/nope") + assert status.startswith("404") + assert body == b"Not Found" + + def test_405_with_allow_header(self): + router = Router() + + @router.post("/resource") + def create(_: RequestCtx) -> Response: + return Response(201, {}, []) + + assert create + + status, headers, body = _fake_wsgi(router, "GET", "/resource") + assert status.startswith("405") + assert body == b"Method Not Allowed" + header_names = {name.lower(): value for name, value in headers} + assert header_names.get("allow") == "POST" + + def test_query_parsing(self): + router = Router() + + @router.get("/search") + def search(ctx: RequestCtx) -> Response: + q = ctx.query_args.get("q", [""])[0] + return Response(200, {"content-type": "text/plain"}, [q.encode()]) + + assert search + + status, _, body = _fake_wsgi(router, "GET", "/search", query="q=books&extra=1") + assert status.startswith("200") + assert body == b"books" + + def test_multiple_methods_same_template(self): + router = Router() + + @router.get("/item") + def _get(_: RequestCtx) -> Response: + return Response(200, {}, [b"g"]) + + @router.post("/item") + def _post(_: RequestCtx) -> Response: + return Response(200, {}, [b"p"]) + + assert _get is not None + assert _post is not None + + s_g, _, b_g = _fake_wsgi(router, "GET", "/item") + s_p, _, b_p = _fake_wsgi(router, "POST", "/item") + assert s_g.startswith("200") + assert b_g == b"g" + assert s_p.startswith("200") + assert b_p == b"p" + + def test_request_body_read(self): + router = Router() + + @router.post("/echo") + def echo(ctx: RequestCtx) -> Response: + return Response(200, {}, [ctx.body()]) + + assert echo + + status, _, body = _fake_wsgi(router, "POST", "/echo", body=b"payload") + assert status.startswith("200") + assert body == b"payload" + + +# --- Router.as_handler (native) ------------------------------------------- + + +@pytest.fixture(autouse=True) +def _disable_cancellation_check(monkeypatch): + monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) + + +@pytest.fixture +def server_config(): + return ServerConfig(host="127.0.0.1", port=0) + + +def _run_iterations(server, handler, n=15): + for _ in range(n): + try: + server.run(handler) + except (OSError, ValueError, AttributeError): + return # Server context manager exited; selector/socket closed + + +class TestRouterAsHandler: + def test_dispatch_200(self, server_config): + router = Router() + + @router.get("/ping") + def ping(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"pong"]) + + assert ping + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/ping") + t.join(timeout=5) + + assert resp.status_code == 200 + assert resp.text == "pong" + + def test_dispatch_path_params(self, server_config): + router = Router() + captured: dict = {} + + @router.get("/books/{book_id}") + def get_book(ctx: RequestCtx) -> Response: + captured["book_id"] = ctx.path_args["book_id"] + captured["method"] = ctx.method + captured["matched_template"] = ctx.matched_template.template + return Response(200, {"content-type": "text/plain"}, [b"ok"]) + + assert get_book + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + httpx.get(f"http://127.0.0.1:{server.port}/books/xyz-123") + t.join(timeout=5) + + assert captured["book_id"] == "xyz-123" + assert captured["method"] == HTTPMethod.GET + assert captured["matched_template"] == "/books/{book_id}" + + def test_404(self, server_config): + router = Router() + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/does-not-exist") + t.join(timeout=5) + + assert resp.status_code == 404 + assert resp.text == "Not Found" + + def test_405_with_allow_header(self, server_config): + router = Router() + + @router.post("/resource") + def _post(_: RequestCtx) -> Response: + return Response(201, {}, []) + + assert _post + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/resource") + t.join(timeout=5) + + assert resp.status_code == 405 + assert resp.headers.get("allow") == "POST" + + def test_query_parsing(self, server_config): + router = Router() + captured: dict = {} + + @router.get("/search") + def search(ctx: RequestCtx) -> Response: + captured["q"] = ctx.query_args.get("q", [""])[0] + captured["raw"] = ctx.query_string + return Response(200, {"content-type": "text/plain"}, [b"ok"]) + + assert search + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + httpx.get(f"http://127.0.0.1:{server.port}/search?q=books") + t.join(timeout=5) + + assert captured["q"] == "books" + assert captured["raw"] == "q=books" + + def test_streaming_response(self, server_config): + router = Router() + + @router.get("/stream") + def stream(_: RequestCtx) -> Response: + return Response( + 200, + {"transfer-encoding": "chunked", "content-type": "text/plain"}, + [b"first", b"second", b"third"], + ) + + assert stream + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/stream") + t.join(timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"firstsecondthird" + + def test_handler_sees_request_headers(self, server_config): + router = Router() + captured: dict = {} + + @router.get("/hdr") + def hdr(ctx: RequestCtx) -> Response: + captured["x_custom"] = ctx.headers.get("x-custom") + return Response(200, {}, [b"ok"]) + + assert hdr + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) + t.start() + httpx.get(f"http://127.0.0.1:{server.port}/hdr", headers={"X-Custom": "yo"}) + t.join(timeout=5) + + assert captured["x_custom"] == "yo" + + def test_ctx_can_read_body(self, server_config): + router = Router() + captured: dict = {} + + @router.post("/echo") + def echo(ctx: RequestCtx) -> Response: + captured["body"] = ctx.body() + return Response(200, {}, [captured["body"]]) + + assert echo + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, router.as_handler(), 25)) + t.start() + resp = httpx.post(f"http://127.0.0.1:{server.port}/echo", content=b"hello body") + t.join(timeout=5) + + assert resp.status_code == 200 + assert captured["body"] == b"hello body" + + +# --- Router registration API --------------------------------------------- + + +class TestRouterRegistration: + def test_explicit_add(self): + router = Router() + + def handler(_: RequestCtx) -> Response: + return Response(200, {}, [b"x"]) + + router.add("GET", "/thing", handler) + assert len(router.paths) == 1 + + def test_same_template_multiple_methods_stored_once(self): + router = Router() + + @router.get("/r") + def g(_: RequestCtx) -> Response: + return Response(200, {}, []) + + @router.post("/r") + def p(_: RequestCtx) -> Response: + return Response(200, {}, []) + + assert g is not None + assert p is not None + + assert len(router.paths) == 1 + methods = next(iter(router.paths.values())) + assert set(methods) == {HTTPMethod.GET, HTTPMethod.POST} + + def test_decorator_returns_original_handler(self): + router = Router() + + def handler(_: RequestCtx) -> Response: + return Response(200, {}, []) + + registered = router.get("/x")(handler) + assert registered is handler diff --git a/tests/http/service.py b/tests/http/service.py new file mode 100644 index 0000000..9828fac --- /dev/null +++ b/tests/http/service.py @@ -0,0 +1,261 @@ +"""Tests for localpost.http._service (the hosted http_server service).""" + +from __future__ import annotations + +import contextlib +import socket +import threading +import time +from collections import Counter + +import anyio +import h11 +import httpx +import pytest +from anyio import from_thread, to_thread + +from localpost.hosting import serve +from localpost.http import HTTPReqCtx, RequestCtx, Response, Router, ServerConfig, http_server + +pytestmark = pytest.mark.anyio + + +def _handler_200(body: bytes = b"ok"): + def handler(ctx: HTTPReqCtx): + ctx.complete( + h11.Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], + ), + body, + ) + + return handler + + +async def _get(url: str, **kw) -> httpx.Response: + """httpx.get from an async test — offload to a worker thread.""" + return await to_thread.run_sync(lambda: httpx.get(url, **kw)) + + +async def _wait_server_ready(port: int, deadline: float = 5.0): + """Poll the port until it accepts a connection.""" + + def probe(): + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + return await to_thread.run_sync(probe) + + +class TestHttpServerService: + async def test_serves_single_request(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, _handler_200(b"hi")) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_each_request_becomes_a_task(self, free_port): + """max_concurrency>1 → several slow handlers run in parallel (different threads).""" + thread_ids: list[int] = [] + lock = threading.Lock() + entered = threading.Semaphore(0) # used as a barrier signal + release = threading.Event() + + def handler(ctx: HTTPReqCtx): + with lock: + thread_ids.append(threading.get_ident()) + entered.release() + # Block until the test releases us; this forces parallelism. + release.wait(timeout=5.0) + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, handler, max_concurrency=4) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + async def fire(): + return await _get(f"http://127.0.0.1:{free_port}/") + + results: list[httpx.Response | None] = [None, None, None] + + async def do(i): + results[i] = await fire() + + async with anyio.create_task_group() as tg: + tg.start_soon(do, 0) + tg.start_soon(do, 1) + tg.start_soon(do, 2) + + # Wait until all three handlers have entered before releasing. + def wait_for_three(): + for _ in range(3): + assert entered.acquire(timeout=5.0) + + await to_thread.run_sync(wait_for_three) + release.set() + + for r in results: + assert r is not None + assert r.status_code == 200 + + assert len(thread_ids) == 3 + # Three requests served from different worker threads. + assert len(set(thread_ids)) >= 2 # at least two distinct threads + + lt.shutdown() + await lt.stopped + + async def test_max_concurrency_one_serializes(self, free_port): + """With max_concurrency=1, requests are handled one at a time.""" + in_flight = 0 + peak = 0 + lock = threading.Lock() + + def handler(ctx: HTTPReqCtx): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + time.sleep(0.1) + with lock: + in_flight -= 1 + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, handler, max_concurrency=1) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + async with anyio.create_task_group() as tg: + for _ in range(3): + tg.start_soon(_get, f"http://127.0.0.1:{free_port}/") + + assert peak == 1 + + lt.shutdown() + await lt.stopped + + async def test_shutdown_cancels_inflight(self, free_port): + """Triggering shutdown while a handler is running cancels it via the task-group cancel scope.""" + handler_started = threading.Event() + handler_cancelled = threading.Event() + + def handler(ctx: HTTPReqCtx): + handler_started.set() + # Run a loop that cooperates with cancellation via from_thread.check_cancelled + try: + for _ in range(100): + from_thread.check_cancelled() + time.sleep(0.05) + except BaseException: + handler_cancelled.set() + raise + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, handler, max_concurrency=2) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + # Fire and forget — we don't wait for the response since the handler will be cancelled. + async def fire_and_forget(): + with contextlib.suppress(Exception): + await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + + async with anyio.create_task_group() as tg: + tg.start_soon(fire_and_forget) + + # Wait for the handler to start + await to_thread.run_sync(lambda: handler_started.wait(5.0)) + + lt.shutdown() + + await lt.stopped + + # The handler's own loop observes cancellation via from_thread.check_cancelled. + assert handler_cancelled.is_set() + + async def test_router_dispatch_via_service(self, free_port): + router = Router() + + @router.get("/books/{id}") + def get_book(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [f"book={ctx.path_args['id']}".encode()]) + + assert get_book is not None + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, router.as_handler(), max_concurrency=4) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + resp = await _get(f"http://127.0.0.1:{free_port}/books/42") + assert resp.status_code == 200 + assert resp.text == "book=42" + + resp = await _get(f"http://127.0.0.1:{free_port}/missing") + assert resp.status_code == 404 + + lt.shutdown() + await lt.stopped + + async def test_invalid_max_concurrency(self): + with pytest.raises(ValueError, match="max_concurrency"): + http_server(ServerConfig(), _handler_200(), max_concurrency=0) + + +class TestDispatchLoad: + async def test_many_requests_served_from_worker_threads(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + + def handler(ctx: HTTPReqCtx): + tid = str(threading.get_ident()).encode() + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), + tid, + ) + + svc = http_server(cfg, handler, max_concurrency=8) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[str] = [] + + async def fire(): + r = await _get(f"http://127.0.0.1:{free_port}/") + results.append(r.text) + + async with anyio.create_task_group() as tg: + for _ in range(10): + tg.start_soon(fire) + + # Some distribution across worker threads — at least 2 distinct thread names. + counts = Counter(results) + assert sum(counts.values()) == 10 + assert len(counts) >= 2 + + lt.shutdown() + await lt.stopped diff --git a/tests/http/wsgi.py b/tests/http/wsgi.py new file mode 100644 index 0000000..329b171 --- /dev/null +++ b/tests/http/wsgi.py @@ -0,0 +1,157 @@ +"""Tests for localpost.http.wsgi.""" + +from __future__ import annotations + +import threading + +import httpx +import pytest + +from localpost import threadtools +from localpost.http import ServerConfig, start_http_server, wrap_wsgi + + +@pytest.fixture(autouse=True) +def _disable_cancellation_check(monkeypatch): + monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) + + +@pytest.fixture +def server_config(): + return ServerConfig(host="127.0.0.1", port=0) + + +def _run_iterations(server, handler, n=20): + for _ in range(n): + try: + server.run(handler) + except (OSError, ValueError, AttributeError): + return + + +class TestWrapWSGI: + def test_simple_200(self, server_config): + def app(environ, start_response): + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "5")]) + return [b"hello"] + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/") + t.join(timeout=5) + + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/plain" + assert resp.text == "hello" + + def test_multi_chunk_response(self, server_config): + def app(environ, start_response): + start_response("200 OK", [("Content-Type", "text/plain"), ("Transfer-Encoding", "chunked")]) + return [b"foo", b"bar", b"baz"] + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/") + t.join(timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"foobarbaz" + + def test_404(self, server_config): + def app(environ, start_response): + body = b"nope" + start_response("404 Not Found", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))]) + return [body] + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/anything") + t.join(timeout=5) + + assert resp.status_code == 404 + assert resp.text == "nope" + + def test_environ_path_and_query(self, server_config): + seen = {} + + def app(environ, start_response): + seen["method"] = environ["REQUEST_METHOD"] + seen["path"] = environ["PATH_INFO"] + seen["query"] = environ["QUERY_STRING"] + seen["content_type"] = environ.get("CONTENT_TYPE", "") + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "2")]) + return [b"ok"] + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) + t.start() + httpx.post( + f"http://127.0.0.1:{server.port}/api/items?q=1", + content=b"", + headers={"Content-Type": "application/json"}, + ) + t.join(timeout=5) + + assert seen["method"] == "POST" + assert seen["path"] == "/api/items" + assert seen["query"] == "q=1" + assert seen["content_type"] == "application/json" + + def test_request_body_streaming(self, server_config): + received = bytearray() + + def app(environ, start_response): + wsgi_input = environ["wsgi.input"] + while True: + chunk = wsgi_input.read(4) + if not chunk: + break + received.extend(chunk) + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(received)))]) + return [bytes(received)] + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) + t.start() + resp = httpx.post(f"http://127.0.0.1:{server.port}/", content=b"hello wsgi body") + t.join(timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"hello wsgi body" + assert bytes(received) == b"hello wsgi body" + + def test_generator_close_called(self, server_config): + close_called = threading.Event() + + class Body: + def __init__(self): + self._chunks = iter([b"one", b"two"]) + + def __iter__(self): + return self + + def __next__(self): + return next(self._chunks) + + def close(self): + close_called.set() + + def app(environ, start_response): + start_response( + "200 OK", + [("Content-Type", "text/plain"), ("Transfer-Encoding", "chunked")], + ) + return Body() + + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) + t.start() + resp = httpx.get(f"http://127.0.0.1:{server.port}/") + t.join(timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"onetwo" + assert close_called.is_set() From aba0e5fe10c11b68c963b6857e96db4773aa6584 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 24 Apr 2026 12:53:25 +0400 Subject: [PATCH 069/286] refactor(http): split Router into Routes builder + immutable Router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Router is now a frozen, slotted dataclass compiled at build time. Routes is a small mutable builder carrying the fluent decorators (.get, .post, .put, .delete, .patch, .add). Construction is routes.build() (sugar) or Router.from_routes(routes). Build-time ValueError on duplicate templates. Why: dispatch runs from many threads, and the rest of the codebase models request-time state as frozen dataclasses. Freezing Router also lets us precompute once: - A single regex alternation across all templates (uniquely-prefixed named groups per template) instead of N linear probes per request. - Templates ordered by longest literal prefix first, so /books/new wins over /books/{id}. - Per-template Allow header pre-rendered; _MatchResult carries it so dispatchers don't rebuild the string per request. Callers migrated: examples/http/multithread_server.py, tests/http/router.py (TestRouterRegistration → TestRoutesRegistration; the three .paths assertions now live on Routes; added TestRouterBuild + a literal-prefix-ordering test), tests/http/service.py, tests/http/integration.py. Known out-of-scope breakage: localpost/openapi/app.py constructs Router(paths=...) directly — will need a follow-up when openapi gets its refresh. Test count: 64 passed (was 60; +4 new build/ordering tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/multithread_server.py | 11 +- localpost/http/README.md | 15 ++- localpost/http/__init__.py | 2 + localpost/http/router.py | 182 +++++++++++++++++++++------- tests/http/integration.py | 11 +- tests/http/router.py | 154 +++++++++++++++++------ tests/http/service.py | 7 +- 7 files changed, 278 insertions(+), 104 deletions(-) diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index d818ee6..f3cb4cd 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -25,6 +25,7 @@ RequestCtx, Response, Router, + Routes, ServerConfig, http_server, ) @@ -47,11 +48,11 @@ def _slow(_: RequestCtx) -> Response: def build_router() -> Router: - router = Router() - router.get("/")(_root) - router.get("/hello/{name}")(_hello) - router.get("/slow")(_slow) - return router + routes = Routes() + routes.get("/")(_root) + routes.get("/hello/{name}")(_hello) + routes.get("/slow")(_slow) + return routes.build() def main() -> int: diff --git a/localpost/http/README.md b/localpost/http/README.md index 1355c43..c4f0eed 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -40,7 +40,7 @@ with start_http_server(ServerConfig()) as server: ``` See [`examples/http/simple_server.py`](../../examples/http/simple_server.py), -`threaded_server.py`, `wsgi_app_server.py`. +`multithread_server.py`, `wsgi_app_server.py`. Running under hosting: @@ -64,8 +64,14 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) interface. The server calls `server.run(handler)` once per accepted request. - **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style variables, matched with a generated regex). `match(uri) → dict | None`. -- **`Router`** — pick a `RequestHandler` by `(method, template)`. Exposed via - `Router.wsgi` too, for deployment under Gunicorn / Granian / etc. +- **`Routes`** — mutable builder. Accumulate routes via decorators + (`@routes.get("/path")`, `.post`, `.put`, `.delete`, `.patch`, `.add`), then + call `.build()` to compile into a `Router`. +- **`Router`** — immutable, compiled URI-template dispatcher. One regex + alternation over all templates, templates ordered by longest literal prefix, + `Allow` headers pre-rendered. Exposes `.as_handler()` (native + `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian / + etc.). Build via `routes.build()` or `Router.from_routes(routes)`. ## Public API @@ -83,7 +89,8 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) | ------------------------- | ------------------------------------------ | | `URITemplate` | Parse and match RFC 6570 L1 templates | | `RequestCtx` | Routed request context (path args, query, body access) | -| `Router` | Register `(method, template) → RequestHandler` mappings, expose `.wsgi` | +| `Routes` | Mutable builder — decorators (`.get`, `.post`, …) / `.add`. Call `.build()` to freeze | +| `Router` | Immutable, compiled dispatcher. `.as_handler()` for native, `.wsgi` for WSGI | | `Response` | Simple `(status, headers, body)` tuple | ### `localpost.http.wsgi` diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 8e33984..99e823a 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -4,6 +4,7 @@ RequestCtx, Response, Router, + Routes, URITemplate, ) from localpost.http.router import ( @@ -22,6 +23,7 @@ "RequestHandler", # router "Router", + "Routes", "URITemplate", "RequestCtx", "Response", diff --git a/localpost/http/router.py b/localpost/http/router.py index e49f045..99f04a5 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -20,6 +20,7 @@ "RequestCtx", "Response", "RequestHandler", + "Routes", "Router", ] @@ -102,28 +103,25 @@ class Response: RequestHandler = Callable[[RequestCtx], Response] +@final @dataclass(eq=False, slots=True) -class Router: - """Simple URI-template router. +class Routes: + """Mutable route builder. Accumulate routes via decorators, then ``build()`` a Router. Usage:: - router = Router() + routes = Routes() - @router.get("/hello/{name}") + @routes.get("/hello/{name}") def hello(ctx: RequestCtx) -> Response: return Response(200, {"content-type": "text/plain"}, [b"hi"]) - # As native handler: - http_server(config, router.as_handler()) - # As WSGI app: - gunicorn.run(router.wsgi) + router = routes.build() + # or equivalently: router = Router.from_routes(routes) """ paths: dict[URITemplate, dict[HTTPMethod, RequestHandler]] = field(default_factory=dict) - # --- Registration ------------------------------------------------- - def add(self, method: HTTPMethod | str, template: str, handler: RequestHandler) -> None: m = method if isinstance(method, HTTPMethod) else HTTPMethod(method.upper()) key = _find_template(self.paths, template) or URITemplate.parse(template) @@ -151,44 +149,116 @@ def delete(self, template: str) -> Callable[[RequestHandler], RequestHandler]: def patch(self, template: str) -> Callable[[RequestHandler], RequestHandler]: return self._decorator(HTTPMethod.PATCH, template) - # --- Dispatch ----------------------------------------------------- + def build(self) -> Router: + return Router.from_routes(self) - def _match( - self, path: str, method_str: str - ) -> _MatchResult: - matched_template: URITemplate | None = None - path_args: dict[str, str] = {} - matched_methods: Mapping[HTTPMethod, RequestHandler] | None = None - - for template, methods in self.paths.items(): - result = template.match(path) - if result is not None: - matched_template = template - path_args = result - matched_methods = methods - break - if matched_template is None or matched_methods is None: - return _MatchResult(kind="not_found") +@final +@dataclass(frozen=True, eq=False, slots=True) +class Router: + """Immutable, compiled URI-template dispatcher. + + Build via :meth:`from_routes` or :meth:`Routes.build`. The constructor fields are + an implementation detail — treat the class as read-only outside of ``from_routes``. + """ + + templates: tuple[URITemplate, ...] + """Templates in dispatch order — longest literal prefix first, stable on ties.""" + + methods: tuple[Mapping[HTTPMethod, RequestHandler], ...] + """Per-template method → handler table; parallel to :attr:`templates`.""" + + allow_headers: tuple[str, ...] + """Pre-rendered ``Allow`` header value per template (e.g. ``"GET, POST"``).""" + + _regex: re.Pattern[str] + _group_prefixes: tuple[str, ...] + + @classmethod + def from_routes(cls, routes: Routes) -> Self: + # Deduplicate: Routes.add already merges by template string, but guard + # against anyone constructing the builder's paths dict by hand. + seen: set[str] = set() + for t in routes.paths: + if t.template in seen: + raise ValueError(f"duplicate template: {t.template!r}") + seen.add(t.template) + + ordered = sorted( + routes.paths.items(), + key=lambda item: _literal_prefix_len(item[0]), + reverse=True, + ) + + templates: list[URITemplate] = [] + methods_list: list[Mapping[HTTPMethod, RequestHandler]] = [] + allow_headers: list[str] = [] + group_prefixes: list[str] = [] + alternatives: list[str] = [] + + for i, (tmpl, method_map) in enumerate(ordered): + prefix = f"r{i}" + templates.append(tmpl) + # Freeze the handler map at the type level (still a plain dict underneath, + # but exposed as Mapping — external mutation is a type error). + methods_list.append(dict(method_map)) + allow_headers.append(", ".join(m.value for m in sorted(method_map, key=lambda hm: hm.value))) + group_prefixes.append(prefix) + alternatives.append(f"(?P<{prefix}>{_reprefixed_regex_source(tmpl, prefix)})") + + combined = re.compile("^(?:" + "|".join(alternatives) + ")$") if alternatives else re.compile(r"^(?!)") - try: - method = HTTPMethod(method_str) - except ValueError: - return _MatchResult(kind="method_not_allowed", allowed=tuple(matched_methods)) - - handler = matched_methods.get(method) - if handler is None: - return _MatchResult(kind="method_not_allowed", allowed=tuple(matched_methods)) - - return _MatchResult( - kind="ok", - handler=handler, - method=method, - matched_template=matched_template, - path_args=path_args, - allowed=tuple(matched_methods), + return cls( + templates=tuple(templates), + methods=tuple(methods_list), + allow_headers=tuple(allow_headers), + _regex=combined, + _group_prefixes=tuple(group_prefixes), ) + # --- Dispatch ----------------------------------------------------- + + def _match(self, path: str, method_str: str) -> _MatchResult: + m = self._regex.match(path) + if m is None: + return _MatchResult(kind="not_found") + + for i, outer in enumerate(self._group_prefixes): + if m.group(outer) is None: + continue + tmpl = self.templates[i] + method_map = self.methods[i] + path_args = {v: m.group(f"{outer}_{v}") or "" for v in tmpl.variable_names} + + try: + method = HTTPMethod(method_str) + except ValueError: + return _MatchResult( + kind="method_not_allowed", + allowed=tuple(method_map), + allow_header=self.allow_headers[i], + ) + + handler = method_map.get(method) + if handler is None: + return _MatchResult( + kind="method_not_allowed", + allowed=tuple(method_map), + allow_header=self.allow_headers[i], + ) + + return _MatchResult( + kind="ok", + handler=handler, + method=method, + matched_template=tmpl, + path_args=path_args, + allowed=tuple(method_map), + allow_header=self.allow_headers[i], + ) + + raise AssertionError("unreachable: regex matched but no outer group set") + def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" request_path = environ.get("PATH_INFO", "/") @@ -199,10 +269,9 @@ def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: start_response("404 Not Found", [("Content-Type", "text/plain")]) return [b"Not Found"] if match.kind == "method_not_allowed": - allowed = ", ".join(m.value for m in match.allowed) start_response( "405 Method Not Allowed", - [("Content-Type", "text/plain"), ("Allow", allowed)], + [("Content-Type", "text/plain"), ("Allow", match.allow_header)], ) return [b"Method Not Allowed"] @@ -255,12 +324,11 @@ def handle(http_ctx: HTTPReqCtx) -> None: _send_plain(http_ctx, 404, b"Not Found") return if match.kind == "method_not_allowed": - allowed = ", ".join(m.value for m in match.allowed) _send_plain( http_ctx, 405, b"Method Not Allowed", - extra_headers=[(b"Allow", allowed.encode("ascii"))], + extra_headers=[(b"Allow", match.allow_header.encode("ascii"))], ) return @@ -309,6 +377,26 @@ class _MatchResult: matched_template: URITemplate | None = None path_args: Mapping[str, str] = field(default_factory=dict) allowed: tuple[HTTPMethod, ...] = () + allow_header: str = "" + + +def _literal_prefix_len(t: URITemplate) -> int: + """Length of the literal prefix up to the first ``{var}`` placeholder.""" + m = _VAR_PATTERN.search(t.template) + return len(t.template) if m is None else m.start() + + +def _reprefixed_regex_source(t: URITemplate, prefix: str) -> str: + """Rebuild ``t``'s regex source with ``{prefix}_`` prepended to every named group.""" + parts: list[str] = [] + last_end = 0 + for m in _VAR_PATTERN.finditer(t.template): + parts.append(re.escape(t.template[last_end : m.start()])) + var_name = m.group(1) + parts.append(f"(?P<{prefix}_{var_name}>[^/]+)") + last_end = m.end() + parts.append(re.escape(t.template[last_end:])) + return "".join(parts) def _find_template( diff --git a/tests/http/integration.py b/tests/http/integration.py index 86ba777..cdbd831 100644 --- a/tests/http/integration.py +++ b/tests/http/integration.py @@ -31,7 +31,7 @@ import time from localpost.hosting import run_app - from localpost.http import RequestCtx, Response, Router, ServerConfig, http_server + from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server logging.basicConfig(level=logging.INFO) @@ -55,10 +55,11 @@ def _hello(ctx: RequestCtx) -> Response: ) - router = Router() - router.get("/ping")(_root) - router.get("/slow")(_slow) - router.get("/hello/{name}")(_hello) + routes = Routes() + routes.get("/ping")(_root) + routes.get("/slow")(_slow) + routes.get("/hello/{name}")(_hello) + router = routes.build() port = int(os.environ["LP_TEST_PORT"]) cfg = ServerConfig(host="127.0.0.1", port=port) diff --git a/tests/http/router.py b/tests/http/router.py index 7947233..d37b2e3 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -10,7 +10,15 @@ import pytest from localpost import threadtools -from localpost.http import RequestCtx, Response, Router, ServerConfig, URITemplate, start_http_server +from localpost.http import ( + RequestCtx, + Response, + Router, + Routes, + ServerConfig, + URITemplate, + start_http_server, +) # --- URITemplate ---------------------------------------------------------- @@ -64,32 +72,34 @@ def start_response(status, headers): class TestRouterWSGI: def test_matches_and_dispatches(self): - router = Router() + routes = Routes() - @router.get("/hello/{name}") + @routes.get("/hello/{name}") def hello(ctx: RequestCtx) -> Response: return Response(200, {"content-type": "text/plain"}, [f"hi {ctx.path_args['name']}".encode()]) assert hello # keep reference so pyright is happy + router = routes.build() status, _, body = _fake_wsgi(router, "GET", "/hello/alice") assert status.startswith("200") assert body == b"hi alice" def test_404_on_miss(self): - router = Router() + router = Routes().build() status, _, body = _fake_wsgi(router, "GET", "/nope") assert status.startswith("404") assert body == b"Not Found" def test_405_with_allow_header(self): - router = Router() + routes = Routes() - @router.post("/resource") + @routes.post("/resource") def create(_: RequestCtx) -> Response: return Response(201, {}, []) assert create + router = routes.build() status, headers, body = _fake_wsgi(router, "GET", "/resource") assert status.startswith("405") @@ -98,32 +108,34 @@ def create(_: RequestCtx) -> Response: assert header_names.get("allow") == "POST" def test_query_parsing(self): - router = Router() + routes = Routes() - @router.get("/search") + @routes.get("/search") def search(ctx: RequestCtx) -> Response: q = ctx.query_args.get("q", [""])[0] return Response(200, {"content-type": "text/plain"}, [q.encode()]) assert search + router = routes.build() status, _, body = _fake_wsgi(router, "GET", "/search", query="q=books&extra=1") assert status.startswith("200") assert body == b"books" def test_multiple_methods_same_template(self): - router = Router() + routes = Routes() - @router.get("/item") + @routes.get("/item") def _get(_: RequestCtx) -> Response: return Response(200, {}, [b"g"]) - @router.post("/item") + @routes.post("/item") def _post(_: RequestCtx) -> Response: return Response(200, {}, [b"p"]) assert _get is not None assert _post is not None + router = routes.build() s_g, _, b_g = _fake_wsgi(router, "GET", "/item") s_p, _, b_p = _fake_wsgi(router, "POST", "/item") @@ -133,18 +145,43 @@ def _post(_: RequestCtx) -> Response: assert b_p == b"p" def test_request_body_read(self): - router = Router() + routes = Routes() - @router.post("/echo") + @routes.post("/echo") def echo(ctx: RequestCtx) -> Response: return Response(200, {}, [ctx.body()]) assert echo + router = routes.build() status, _, body = _fake_wsgi(router, "POST", "/echo", body=b"payload") assert status.startswith("200") assert body == b"payload" + def test_longer_literal_prefix_wins(self): + """Ambiguous templates: /books/new should beat /books/{id}.""" + routes = Routes() + hits: list[str] = [] + + @routes.get("/books/{id}") + def get_book(_: RequestCtx) -> Response: + hits.append("by_id") + return Response(200, {}, [b"id"]) + + @routes.get("/books/new") + def new_book(_: RequestCtx) -> Response: + hits.append("new") + return Response(200, {}, [b"new"]) + + assert get_book is not None + assert new_book is not None + router = routes.build() + + _fake_wsgi(router, "GET", "/books/new") + _fake_wsgi(router, "GET", "/books/42") + + assert hits == ["new", "by_id"] + # --- Router.as_handler (native) ------------------------------------------- @@ -169,13 +206,14 @@ def _run_iterations(server, handler, n=15): class TestRouterAsHandler: def test_dispatch_200(self, server_config): - router = Router() + routes = Routes() - @router.get("/ping") + @routes.get("/ping") def ping(_: RequestCtx) -> Response: return Response(200, {"content-type": "text/plain"}, [b"pong"]) assert ping + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -187,10 +225,10 @@ def ping(_: RequestCtx) -> Response: assert resp.text == "pong" def test_dispatch_path_params(self, server_config): - router = Router() + routes = Routes() captured: dict = {} - @router.get("/books/{book_id}") + @routes.get("/books/{book_id}") def get_book(ctx: RequestCtx) -> Response: captured["book_id"] = ctx.path_args["book_id"] captured["method"] = ctx.method @@ -198,6 +236,7 @@ def get_book(ctx: RequestCtx) -> Response: return Response(200, {"content-type": "text/plain"}, [b"ok"]) assert get_book + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -210,7 +249,7 @@ def get_book(ctx: RequestCtx) -> Response: assert captured["matched_template"] == "/books/{book_id}" def test_404(self, server_config): - router = Router() + router = Routes().build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -222,13 +261,14 @@ def test_404(self, server_config): assert resp.text == "Not Found" def test_405_with_allow_header(self, server_config): - router = Router() + routes = Routes() - @router.post("/resource") + @routes.post("/resource") def _post(_: RequestCtx) -> Response: return Response(201, {}, []) assert _post + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -240,16 +280,17 @@ def _post(_: RequestCtx) -> Response: assert resp.headers.get("allow") == "POST" def test_query_parsing(self, server_config): - router = Router() + routes = Routes() captured: dict = {} - @router.get("/search") + @routes.get("/search") def search(ctx: RequestCtx) -> Response: captured["q"] = ctx.query_args.get("q", [""])[0] captured["raw"] = ctx.query_string return Response(200, {"content-type": "text/plain"}, [b"ok"]) assert search + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -261,9 +302,9 @@ def search(ctx: RequestCtx) -> Response: assert captured["raw"] == "q=books" def test_streaming_response(self, server_config): - router = Router() + routes = Routes() - @router.get("/stream") + @routes.get("/stream") def stream(_: RequestCtx) -> Response: return Response( 200, @@ -272,6 +313,7 @@ def stream(_: RequestCtx) -> Response: ) assert stream + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -283,15 +325,16 @@ def stream(_: RequestCtx) -> Response: assert resp.content == b"firstsecondthird" def test_handler_sees_request_headers(self, server_config): - router = Router() + routes = Routes() captured: dict = {} - @router.get("/hdr") + @routes.get("/hdr") def hdr(ctx: RequestCtx) -> Response: captured["x_custom"] = ctx.headers.get("x-custom") return Response(200, {}, [b"ok"]) assert hdr + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) @@ -302,15 +345,16 @@ def hdr(ctx: RequestCtx) -> Response: assert captured["x_custom"] == "yo" def test_ctx_can_read_body(self, server_config): - router = Router() + routes = Routes() captured: dict = {} - @router.post("/echo") + @routes.post("/echo") def echo(ctx: RequestCtx) -> Response: captured["body"] = ctx.body() return Response(200, {}, [captured["body"]]) assert echo + router = routes.build() with start_http_server(server_config) as server: t = threading.Thread(target=_run_iterations, args=(server, router.as_handler(), 25)) @@ -322,42 +366,72 @@ def echo(ctx: RequestCtx) -> Response: assert captured["body"] == b"hello body" -# --- Router registration API --------------------------------------------- +# --- Routes registration API --------------------------------------------- -class TestRouterRegistration: +class TestRoutesRegistration: def test_explicit_add(self): - router = Router() + routes = Routes() def handler(_: RequestCtx) -> Response: return Response(200, {}, [b"x"]) - router.add("GET", "/thing", handler) - assert len(router.paths) == 1 + routes.add("GET", "/thing", handler) + assert len(routes.paths) == 1 def test_same_template_multiple_methods_stored_once(self): - router = Router() + routes = Routes() - @router.get("/r") + @routes.get("/r") def g(_: RequestCtx) -> Response: return Response(200, {}, []) - @router.post("/r") + @routes.post("/r") def p(_: RequestCtx) -> Response: return Response(200, {}, []) assert g is not None assert p is not None - assert len(router.paths) == 1 - methods = next(iter(router.paths.values())) + assert len(routes.paths) == 1 + methods = next(iter(routes.paths.values())) assert set(methods) == {HTTPMethod.GET, HTTPMethod.POST} def test_decorator_returns_original_handler(self): - router = Router() + routes = Routes() def handler(_: RequestCtx) -> Response: return Response(200, {}, []) - registered = router.get("/x")(handler) + registered = routes.get("/x")(handler) assert registered is handler + + +# --- Router build-time checks -------------------------------------------- + + +class TestRouterBuild: + def test_empty_routes_produce_empty_router(self): + """An empty Routes still builds a valid (never-matching) Router.""" + router = Routes().build() + status, _, body = _fake_wsgi(router, "GET", "/") + assert status.startswith("404") + assert body == b"Not Found" + + def test_build_is_idempotent_snapshot(self): + """Building twice produces equivalent routers with the same templates tuple.""" + routes = Routes() + routes.add("GET", "/a", lambda ctx: Response(200, {}, [])) + routes.add("GET", "/b", lambda ctx: Response(200, {}, [])) + r1 = routes.build() + r2 = routes.build() + assert [t.template for t in r1.templates] == [t.template for t in r2.templates] + + def test_allow_header_precomputed(self): + routes = Routes() + routes.add("GET", "/x", lambda ctx: Response(200, {}, [])) + routes.add("POST", "/x", lambda ctx: Response(200, {}, [])) + router = routes.build() + # Methods are sorted for a stable header; both methods end up in the header. + assert router.allow_headers[0] in ("GET, POST", "POST, GET") + assert set(router.allow_headers[0].split(", ")) == {"GET", "POST"} diff --git a/tests/http/service.py b/tests/http/service.py index 9828fac..007ecaf 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -15,7 +15,7 @@ from anyio import from_thread, to_thread from localpost.hosting import serve -from localpost.http import HTTPReqCtx, RequestCtx, Response, Router, ServerConfig, http_server +from localpost.http import HTTPReqCtx, RequestCtx, Response, Routes, ServerConfig, http_server pytestmark = pytest.mark.anyio @@ -197,13 +197,14 @@ async def fire_and_forget(): assert handler_cancelled.is_set() async def test_router_dispatch_via_service(self, free_port): - router = Router() + routes = Routes() - @router.get("/books/{id}") + @routes.get("/books/{id}") def get_book(ctx: RequestCtx) -> Response: return Response(200, {"content-type": "text/plain"}, [f"book={ctx.path_args['id']}".encode()]) assert get_book is not None + router = routes.build() cfg = ServerConfig(host="127.0.0.1", port=free_port) svc = http_server(cfg, router.as_handler(), max_concurrency=4) From e04edd32e0f3f9fa752a01adc91e333d1e7412c8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 24 Apr 2026 15:31:48 +0400 Subject: [PATCH 070/286] feat(http): native Flask adapter in localpost.http.flask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New flask_handler(app) / flask_server(config, app) drive Flask without going through WSGI. Flask's request context stays active for the entire request lifetime, including response-body streaming — so generators in responses can use flask.request / session / g without @stream_with_context. teardown_request runs after the body is fully sent (the opposite of standard WSGI Flask), so resources like DB sessions live through streaming. Uses Werkzeug/Flask internals (app.request_context, app.full_dispatch_request, Response.iter_encoded). Documented as stable across Flask 3.x but not a forever contract. Generic WSGI path (wsgi_server / wrap_wsgi) stays as the framework-agnostic option. - New module: localpost/http/flask.py - New extra: [http-flask] = ["flask ~=3.1"] - Example: examples/http/flask_app_server.py (streaming without stream_with_context) - Tests: 9 in tests/http/flask_server.py, including the streaming behavior contract and teardown ordering - README updated with the Flask adapter section and behavior diff Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/flask_app_server.py | 62 +++++++++ localpost/http/README.md | 38 +++++- localpost/http/flask.py | 81 +++++++++++ pyproject.toml | 3 + tests/http/flask_server.py | 216 ++++++++++++++++++++++++++++++ uv.lock | 6 +- 6 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 examples/http/flask_app_server.py create mode 100644 localpost/http/flask.py create mode 100644 tests/http/flask_server.py diff --git a/examples/http/flask_app_server.py b/examples/http/flask_app_server.py new file mode 100644 index 0000000..f0422c7 --- /dev/null +++ b/examples/http/flask_app_server.py @@ -0,0 +1,62 @@ +"""Flask app served via ``flask_server`` — streaming without ``@stream_with_context``. + +Run:: + + uv run --group dev-http examples/http/flask_app_server.py + + curl http://localhost:8000/hello/world + curl http://localhost:8000/stream/world # uses flask.request during streaming + +Key difference from the WSGI example: the ``/stream/...`` view returns a +generator that reads ``flask.request.headers`` while yielding — and we did +**not** wrap it in ``@stream_with_context``. It works because +:func:`localpost.http.flask.flask_server` keeps Flask's request context +active through response-body iteration. +""" + +from __future__ import annotations + +import logging +import sys + +from flask import Flask, Response +from flask import request as flask_request + +from localpost.hosting import run_app +from localpost.http import ServerConfig +from localpost.http.flask import flask_server + + +def build_app() -> Flask: + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + ua = flask_request.headers.get("User-Agent", "unknown") + return f"Hello, {name}! (UA={ua})\n" + + @app.route("/stream/") + def stream(name: str): + def generate(): + yield f"Hello, {name}! " + # flask.request is still live here — no stream_with_context needed. + yield f"Your User-Agent: {flask_request.headers.get('User-Agent', '?')}\n" + + return Response(generate(), mimetype="text/plain") + + @app.teardown_request + def _log_teardown(exc): + # Under flask_server this runs AFTER the body is fully sent. + app.logger.info("teardown_request (exc=%r)", exc) + + return app + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + config = ServerConfig(host="127.0.0.1", port=8000) + return run_app(flask_server(config, build_app(), max_concurrency=8)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/localpost/http/README.md b/localpost/http/README.md index c4f0eed..8e20875 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -99,6 +99,33 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) | ------------------------- | ------------------------------------------ | | `wrap_wsgi(app)` | Turn a WSGI app into a `RequestHandler` | +### `localpost.http.flask` + +Native Flask adapter — optional extra `[http-flask]`. Bypasses WSGI on both +sides: drives Flask's pipeline directly and streams the Werkzeug `Response` +straight to h11. See [`flask.py`](flask.py). + +| Symbol | Notes | +| ----------------------------------------- | ------------------------------------------------------------------- | +| `flask_handler(app)` | Flask → `RequestHandler` | +| `flask_server(config, app, *, max_concurrency=1)` | Hosted service serving a Flask app | + +**Behavior differences from `wsgi_server`** (with a Flask app): + +- Flask's **request context is active during response-body iteration**. A + generator returned from a view can use `flask.request`, `session`, `g` + without `@stream_with_context`. (`stream_with_context` still works, just + becomes a no-op.) +- `teardown_request` / `teardown_appcontext` run **after** the body is fully + sent (the opposite of standard WSGI Flask). Resources like DB sessions + stay alive for the duration of streaming. + +Trade-off: the adapter touches Werkzeug/Flask internals (`app.request_context`, +`app.full_dispatch_request`, `Response.iter_encoded`). Stable across Flask 3.x +but not a long-term contract. Use `wsgi_server` for framework-agnostic WSGI +(Django, Flask without the extras, anything else) or when you want to stay +on the documented public Flask API. + ### `localpost.http.config` | Symbol | Notes | @@ -108,12 +135,13 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) ### Hosting integration — `localpost.http._service` -Two `@hosting.service` wrappers for running the server inside a hosted app: +`@hosting.service` wrappers for running the server inside a hosted app: -| Service | Notes | -| -------------------------------------------- | ------------------------------------- | -| `http_server(config, handler)` | Runs the server loop with your handler | -| `wsgi_server(config, app)` | Same, for a WSGI app | +| Service | Notes | +| -------------------------------------------- | ------------------------------------------- | +| `http_server(config, handler)` | Runs the server loop with your handler | +| `wsgi_server(config, app)` | Same, for a generic WSGI app | +| `flask_server(config, app)` | Native Flask — see `localpost.http.flask` | The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown is driven by `sl.shutting_down` via `threadtools.check_cancelled()`. diff --git a/localpost/http/flask.py b/localpost/http/flask.py new file mode 100644 index 0000000..b87ab4d --- /dev/null +++ b/localpost/http/flask.py @@ -0,0 +1,81 @@ +"""Native Flask adapter — drives a Flask app without going through WSGI. + +Compared to :func:`localpost.http.wrap_wsgi` / :func:`localpost.http.wsgi_server`: + +- Flask's request context stays active for the **entire** request lifetime, + including response-body iteration. Generators returned from a view can use + ``flask.request`` / ``session`` / ``g`` without ``@stream_with_context``. + ``stream_with_context`` still works (becomes a no-op). +- ``teardown_request`` / ``teardown_appcontext`` callbacks run **after** the + response body has been fully sent, not before — the opposite of standard + WSGI. This means DB sessions and similar resources live through streaming, + and teardown sees the true end of the request. + +Trade-off: this couples to Flask/Werkzeug internals +(``app.request_context``, ``app.full_dispatch_request``, ``app.handle_exception``, +``werkzeug.Response.iter_encoded``). All documented, stable across Flask 3.x. +""" + +from __future__ import annotations + +import h11 +from flask import Flask + +from localpost.http._service import http_server +from localpost.http.config import ServerConfig +from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.http.wsgi import _build_environ # intra-package helper + +__all__ = ["flask_handler", "flask_server"] + + +def flask_handler(app: Flask) -> RequestHandler: + """Wrap a Flask app as a native :class:`RequestHandler`. + + See the module docstring for the behavior differences vs. WSGI — notably, + the request context stays active during response-body streaming. + """ + + def handle(http_ctx: HTTPReqCtx) -> None: + environ = _build_environ(http_ctx) + with app.request_context(environ): + try: + response = app.full_dispatch_request() + except Exception as exc: # noqa: BLE001 + # app.handle_exception returns a Response; it only re-raises in + # debug / propagate_exceptions mode. If it does re-raise, let + # the exception bubble up to the service task group. + response = app.handle_exception(exc) + try: + _write_response(http_ctx, response) + finally: + response.close() # fire werkzeug's call_on_close callbacks + + return handle + + +def _write_response(http_ctx: HTTPReqCtx, response) -> None: + # response is a werkzeug.Response (Flask's Response subclasses it). + reason = response.status.split(" ", 1)[1] if " " in response.status else "" + h11_headers = [ + (name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items() + ] + http_ctx.start_response( + h11.Response( + status_code=response.status_code, + headers=h11_headers, + reason=reason.encode("iso-8859-1") if reason else b"", + ) + ) + for chunk in response.iter_encoded(): + if chunk: + http_ctx.send(chunk) + http_ctx.finish_response() + + +def flask_server(config: ServerConfig, app: Flask, /, *, max_concurrency: int = 1): + """Hosted service serving a Flask app via :func:`flask_handler`. + + See :func:`localpost.http.http_server` for the concurrency model. + """ + return http_server(config, flask_handler(app), max_concurrency=max_concurrency) diff --git a/pyproject.toml b/pyproject.toml index 75bf50c..510c2fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ http-server = [ http-openapi = [ "msgspec ~=0.19", ] +http-flask = [ + "flask ~=3.1", +] sqs = [ "botocore ~=1.38", # Sync SQS client (default) # "boto3 ~=1.38", # In case boto3 is available, the default session will be used diff --git a/tests/http/flask_server.py b/tests/http/flask_server.py new file mode 100644 index 0000000..9515491 --- /dev/null +++ b/tests/http/flask_server.py @@ -0,0 +1,216 @@ +"""Tests for localpost.http.flask — the native Flask adapter.""" + +from __future__ import annotations + +import threading + +import httpx +import pytest +from flask import Flask, Response, stream_with_context +from flask import request as flask_request + +from localpost import threadtools +from localpost.http import ServerConfig, start_http_server +from localpost.http.flask import flask_handler + + +@pytest.fixture(autouse=True) +def _disable_cancellation_check(monkeypatch): + monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) + + +@pytest.fixture +def server_config(): + return ServerConfig(host="127.0.0.1", port=0) + + +def _run_iterations(server, handler, n=20): + for _ in range(n): + try: + server.run(handler) + except (OSError, ValueError, AttributeError): + return + + +def _serve(server_config, app: Flask, request_fn): + """Run the server in a thread, call request_fn(port), return whatever it returns.""" + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, flask_handler(app))) + t.start() + try: + return request_fn(server.port) + finally: + t.join(timeout=5) + + +class TestFlaskHandler: + def test_simple_200(self, server_config): + app = Flask(__name__) + + @app.route("/") + def index(): + return "hello flask" + + assert index + + resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/")) + assert resp.status_code == 200 + assert resp.text == "hello flask" + + def test_path_parameters(self, server_config): + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + return f"hi {name}" + + assert hello + + resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/hello/alice")) + assert resp.status_code == 200 + assert resp.text == "hi alice" + + def test_post_body(self, server_config): + app = Flask(__name__) + captured: dict = {} + + @app.route("/echo", methods=["POST"]) + def echo(): + captured["body"] = flask_request.get_data() + return Response(captured["body"], mimetype="application/octet-stream") + + assert echo + + resp = _serve( + server_config, + app, + lambda port: httpx.post(f"http://127.0.0.1:{port}/echo", content=b"payload"), + ) + assert captured.get("body") == b"payload", f"status={resp.status_code}, body={resp.content!r}" + assert resp.status_code == 200 + assert resp.content == b"payload" + + def test_flask_404(self, server_config): + app = Flask(__name__) + + resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/missing")) + assert resp.status_code == 404 + + def test_view_exception_returns_500(self, server_config): + app = Flask(__name__) + + @app.route("/boom") + def boom(): + raise RuntimeError("bang") + + assert boom + + resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/boom")) + assert resp.status_code == 500 + + def test_streaming_without_stream_with_context(self, server_config): + """Key behavior test: generator uses flask.request without @stream_with_context.""" + app = Flask(__name__) + + @app.route("/stream") + def stream(): + def generate(): + # Would normally raise "Working outside of request context" + # under standard WSGI without @stream_with_context. + yield "ua=" + yield flask_request.headers.get("User-Agent", "?") + "\n" + + return Response(generate(), mimetype="text/plain") + + assert stream + + resp = _serve( + server_config, + app, + lambda port: httpx.get( + f"http://127.0.0.1:{port}/stream", + headers={"User-Agent": "test-client"}, + ), + ) + assert resp.status_code == 200 + assert resp.text == "ua=test-client\n" + + def test_streaming_with_stream_with_context_still_works(self, server_config): + """Backwards compat: @stream_with_context is a no-op but must not break.""" + app = Flask(__name__) + + @app.route("/stream-ctx") + def stream_ctx(): + def generate(): + yield "ua=" + yield flask_request.headers.get("User-Agent", "?") + "\n" + + return Response(stream_with_context(generate()), mimetype="text/plain") + + assert stream_ctx + + resp = _serve( + server_config, + app, + lambda port: httpx.get( + f"http://127.0.0.1:{port}/stream-ctx", + headers={"User-Agent": "test-client"}, + ), + ) + assert resp.status_code == 200 + assert resp.text == "ua=test-client\n" + + def test_teardown_runs_after_body_sent(self, server_config): + """teardown_request fires AFTER response iteration completes, not before. + + This is the opposite of standard WSGI Flask behavior. + """ + app = Flask(__name__) + events: list[str] = [] + events_lock = threading.Lock() + + def log(ev: str) -> None: + with events_lock: + events.append(ev) + + @app.teardown_request + def _teardown(exc): + log("teardown") + + @app.route("/ordering") + def ordering(): + log("view-start") + + def generate(): + log("chunk-1") + yield b"one" + log("chunk-2") + yield b"two" + log("chunk-end") + + return Response(generate(), mimetype="text/plain") + + assert ordering + + resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/ordering")) + assert resp.status_code == 200 + assert resp.content == b"onetwo" + + # Expected order: view-start → (chunks iterate) → teardown + with events_lock: + captured = list(events) + assert captured == ["view-start", "chunk-1", "chunk-2", "chunk-end", "teardown"] + + def test_response_headers_are_forwarded(self, server_config): + app = Flask(__name__) + + @app.route("/with-header") + def with_header(): + return Response("body", mimetype="text/plain", headers={"X-Custom": "yes"}) + + assert with_header + + resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/with-header")) + assert resp.status_code == 200 + assert resp.headers.get("x-custom") == "yes" + assert resp.headers.get("content-type", "").startswith("text/plain") diff --git a/uv.lock b/uv.lock index 315ddaf..32be4e5 100644 --- a/uv.lock +++ b/uv.lock @@ -1067,6 +1067,9 @@ azure-servicebus = [ cron = [ { name = "croniter" }, ] +http-flask = [ + { name = "flask" }, +] http-openapi = [ { name = "msgspec" }, ] @@ -1158,6 +1161,7 @@ requires-dist = [ { name = "botocore", marker = "extra == 'sqs'", specifier = "~=1.38" }, { name = "confluent-kafka", marker = "extra == 'kafka'", specifier = "~=2.4" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, + { name = "flask", marker = "extra == 'http-flask'", specifier = "~=3.1" }, { name = "google-cloud-pubsub", marker = "extra == 'pubsub'", specifier = "~=2.28" }, { name = "h11", marker = "extra == 'http-server'", specifier = "~=0.16" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, @@ -1165,7 +1169,7 @@ requires-dist = [ { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] +provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "http-flask", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] [package.metadata.requires-dev] dev = [ From 613fa0cba0027687f5b6f6baf2f682345c4f7672 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 24 Apr 2026 16:00:47 +0400 Subject: [PATCH 071/286] feat(http): tailored Sentry integration for Router and Flask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two narrow Sentry adapters, deliberately split so Router-only users don't drag in Flask: - localpost/http/router_sentry.py — sentry_router_handler(router) wraps router.as_handler() in a transaction. Pre-matches the URI template so the transaction name is "GET /books/{id}" (route source) instead of "GET /books/42" — keeps Sentry's transaction cardinality low. - localpost/http/flask_sentry.py — sentry_flask_handler(app) mirrors the flask_handler shape but holds the transaction open through response.iter_encoded(). Spans/errors emitted inside a streaming generator land on the same request transaction. Stock FlaskIntegration ends the transaction when wsgi_app returns, before the WSGI server iterates the body — that bug is what motivated this. Verified by test_streaming_span_lands_on_same_transaction. Small core change: HTTPReqCtx.response_status (int | None), set by start_response / complete. Lets observers read the response code without intercepting dispatch. - New extra: [http-sentry] = ["sentry-sdk ~=2.51"]; Flask users install [http-flask, http-sentry] explicitly. - Example: examples/http/sentry_router_server.py (DSN from env, optional). - Tests: 4 router-sentry + 4 flask-sentry, using a CapturingTransport that records envelopes and asserts on transaction name, source, op, http.status_code, and the streaming-span attachment. - README: new router_sentry and flask_sentry sections explaining both modules and why the Flask one exists. All 81 tests/http/ pass (was 73). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/sentry_router_server.py | 61 ++++++++ localpost/http/README.md | 33 +++++ localpost/http/flask_sentry.py | 76 ++++++++++ localpost/http/router_sentry.py | 71 ++++++++++ localpost/http/server.py | 5 + pyproject.toml | 3 + tests/http/flask_sentry_handler.py | 193 ++++++++++++++++++++++++++ tests/http/router_sentry_handler.py | 174 +++++++++++++++++++++++ uv.lock | 6 +- 9 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 examples/http/sentry_router_server.py create mode 100644 localpost/http/flask_sentry.py create mode 100644 localpost/http/router_sentry.py create mode 100644 tests/http/flask_sentry_handler.py create mode 100644 tests/http/router_sentry_handler.py diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py new file mode 100644 index 0000000..a5044db --- /dev/null +++ b/examples/http/sentry_router_server.py @@ -0,0 +1,61 @@ +"""Router app with Sentry tracing. + +Run:: + + SENTRY_DSN='https://...@.../...' uv run --group dev-sentry examples/http/sentry_router_server.py + + curl http://localhost:8000/books/42 # transaction "GET /books/{id}" (route source) + curl http://localhost:8000/nope # transaction "GET /nope" (url source) + +If ``SENTRY_DSN`` is unset, Sentry is initialised in disabled mode — the app +still works, transactions are just no-ops. Useful to demo the wiring without +sending data. +""" + +from __future__ import annotations + +import logging +import os +import sys +import time + +import sentry_sdk + +from localpost.hosting import run_app +from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server +from localpost.http.router_sentry import sentry_router_handler + + +def _root(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"hello\n"]) + + +def _get_book(ctx: RequestCtx) -> Response: + book_id = ctx.path_args["id"] + # Spans inside the handler land on the request transaction. + with sentry_sdk.start_span(op="db.query", name="select book"): + time.sleep(0.01) + return Response(200, {"content-type": "text/plain"}, [f"book={book_id}\n".encode()]) + + +def build_router(): + routes = Routes() + routes.get("/")(_root) + routes.get("/books/{id}")(_get_book) + return routes.build() + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), # None → Sentry runs in disabled mode + traces_sample_rate=1.0, + ) + + handler = sentry_router_handler(build_router()) + config = ServerConfig(host="127.0.0.1", port=8000) + return run_app(http_server(config, handler, max_concurrency=8)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/localpost/http/README.md b/localpost/http/README.md index 8e20875..0fb2a19 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -110,6 +110,39 @@ straight to h11. See [`flask.py`](flask.py). | `flask_handler(app)` | Flask → `RequestHandler` | | `flask_server(config, app, *, max_concurrency=1)` | Hosted service serving a Flask app | +### `localpost.http.router_sentry` + +Sentry tracing wrapper for `Router`. Optional extra `[http-sentry]`. No Flask +dependency — use this with the native `Router` + `http_server` flow. + +| Symbol | Notes | +| ----------------------------------------------- | ---------------------------------------------------------------------- | +| `sentry_router_handler(router, *, op="http.server")` | Wraps `router.as_handler()` in a Sentry transaction per request. | + +Transaction is named `"METHOD /books/{id}"` (the URI template, low cardinality) +on a match, or `"METHOD /raw/path"` on a miss. `http.method`, `http.url`, +`http.response.status_code` are recorded. Spans started inside the handler +attach to the request transaction. + +### `localpost.http.flask_sentry` + +Sentry tracing wrapper for the native Flask adapter. Requires both +`[http-flask]` and `[http-sentry]`. + +| Symbol | Notes | +| ----------------------------------------------- | ---------------------------------------------------------------------- | +| `sentry_flask_handler(app, *, op="http.server")` | Wraps Flask in a Sentry transaction that covers the entire request, **including response-body streaming**. | + +Why this exists: Sentry's stock `FlaskIntegration` ends the transaction when +the WSGI `wsgi_app` returns — *before* the body is iterated. Spans / errors +inside a streaming generator land outside the request transaction (or are +dropped). Because our Flask adapter holds the request context (and the +transaction) open through `response.iter_encoded()`, this fix-pack version +keeps everything on the same transaction. + +Transaction is named after Flask's `url_rule.rule` (e.g. `"GET /hello/"`) +once routing has matched. + **Behavior differences from `wsgi_server`** (with a Flask app): - Flask's **request context is active during response-body iteration**. A diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py new file mode 100644 index 0000000..c93b600 --- /dev/null +++ b/localpost/http/flask_sentry.py @@ -0,0 +1,76 @@ +"""Sentry tracing wrapper for :func:`localpost.http.flask.flask_handler`. + +Each request becomes a Sentry transaction that **covers the entire request +lifetime including response-body streaming**. Sentry's stock +``FlaskIntegration`` ends the transaction when the WSGI ``wsgi_app`` returns, +which is *before* the body is iterated — so any spans / errors inside a +streaming generator land outside the transaction. This adapter doesn't have +that bug because we hold the transaction open through ``response.iter_encoded()``. + +The transaction is named after Flask's matched URL rule (e.g. +``"GET /books/"``) once routing has run; before then it's ``METHOD path``. + +Install:: + + pip install localpost[http-flask,http-sentry] + +Usage:: + + sentry_sdk.init(dsn=..., traces_sample_rate=1.0) + handler = sentry_flask_handler(my_flask_app) + run_app(http_server(config, handler, max_concurrency=8)) +""" + +from __future__ import annotations + +import sentry_sdk +from flask import Flask +from flask import request as flask_request + +from localpost.http.flask import _write_response +from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.http.wsgi import _build_environ + +__all__ = ["sentry_flask_handler"] + + +def sentry_flask_handler(app: Flask, *, op: str = "http.server") -> RequestHandler: + """Wrap a Flask app with a Sentry transaction covering the full request, streaming included.""" + + def handle(ctx: HTTPReqCtx) -> None: + environ = _build_environ(ctx) + method = environ["REQUEST_METHOD"] + path = environ["PATH_INFO"] + + with ( + sentry_sdk.isolation_scope(), + sentry_sdk.start_transaction( + op=op, + name=f"{method} {path}", + source="url", + ) as tx, + ): + tx.set_tag("http.method", method) + tx.set_data("http.url", path) + + with app.request_context(environ): + # Once routing has matched, rename the transaction to the route rule + # for low cardinality. + rule = flask_request.url_rule + if rule is not None: + sentry_sdk.get_current_scope().set_transaction_name( + f"{method} {rule.rule}", + source="route", + ) + + try: + response = app.full_dispatch_request() + except Exception as exc: # noqa: BLE001 + response = app.handle_exception(exc) + tx.set_http_status(response.status_code) + try: + _write_response(ctx, response) + finally: + response.close() + + return handle diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py new file mode 100644 index 0000000..879deea --- /dev/null +++ b/localpost/http/router_sentry.py @@ -0,0 +1,71 @@ +"""Sentry tracing wrapper for :class:`localpost.http.Router`. + +Each request becomes a Sentry transaction named after the matched URI template +(e.g. ``"GET /books/{id}"``) — a low-cardinality identifier suitable for +Sentry's transaction metrics. Unmatched URLs fall back to the raw path with +``source="url"``. + +Install:: + + pip install localpost[http-sentry] + +Usage:: + + routes = Routes() + @routes.get("/books/{id}") + def get_book(ctx): ... + router = routes.build() + + sentry_sdk.init(dsn=..., traces_sample_rate=1.0) + handler = sentry_router_handler(router) + + run_app(http_server(config, handler, max_concurrency=16)) + +Pure Router instrumentation — no Flask import. Use +:mod:`localpost.http.flask_sentry` for Flask apps. +""" + +from __future__ import annotations + +import sentry_sdk + +from localpost.http.router import Router +from localpost.http.server import HTTPReqCtx, RequestHandler + +__all__ = ["sentry_router_handler"] + + +def sentry_router_handler(router: Router, *, op: str = "http.server") -> RequestHandler: + """Wrap a :class:`Router` with a Sentry transaction per request.""" + inner = router.as_handler() + + def handle(ctx: HTTPReqCtx) -> None: + req = ctx.request + method = req.method.decode("ascii") + target = req.target.decode("iso-8859-1") + path = target.split("?", 1)[0] if "?" in target else target + + # Pre-match so the transaction name uses the URI template (low cardinality). + # Router's dispatch will match again, but template matching is a single + # combined regex run — cheap. + match = router._match(path, method) + if match.kind == "ok" and match.matched_template is not None: + tx_name = f"{method} {match.matched_template.template}" + source = "route" + else: + tx_name = f"{method} {path}" + source = "url" + + with ( + sentry_sdk.isolation_scope(), + sentry_sdk.start_transaction(op=op, name=tx_name, source=source) as tx, + ): + tx.set_tag("http.method", method) + tx.set_data("http.url", target) + try: + inner(ctx) + finally: + if ctx.response_status is not None: + tx.set_http_status(ctx.response_status) + + return handle diff --git a/localpost/http/server.py b/localpost/http/server.py index 1b1eedf..dcd3818 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -205,6 +205,9 @@ class HTTPReqCtx: _conn: HTTPConn request: h11.Request + response_status: int | None = field(default=None, init=False) + """Status code of the response sent for this request (set by start_response / complete).""" + @property def borrowed(self) -> bool: return not self._conn.tracked @@ -250,6 +253,8 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: raise RuntimeError(f"Unexpected h11 event: {event!r}") def start_response(self, response: h11.Response | h11.InformationalResponse, /) -> None: + if isinstance(response, h11.Response): + object.__setattr__(self, "response_status", response.status_code) self._conn.send(response) # TODO Accept any Buffer, later diff --git a/pyproject.toml b/pyproject.toml index 510c2fe..e8a9237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,9 @@ http-openapi = [ http-flask = [ "flask ~=3.1", ] +http-sentry = [ + "sentry-sdk ~=2.51", +] sqs = [ "botocore ~=1.38", # Sync SQS client (default) # "boto3 ~=1.38", # In case boto3 is available, the default session will be used diff --git a/tests/http/flask_sentry_handler.py b/tests/http/flask_sentry_handler.py new file mode 100644 index 0000000..61b2359 --- /dev/null +++ b/tests/http/flask_sentry_handler.py @@ -0,0 +1,193 @@ +"""Tests for localpost.http.flask_sentry — focus on the streaming-in-same-transaction fix.""" + +from __future__ import annotations + +import threading + +import httpx +import pytest +import sentry_sdk +from flask import Flask +from flask import Response as FlaskResponse +from flask import request as flask_request +from sentry_sdk.envelope import Envelope +from sentry_sdk.transport import Transport + +from localpost import threadtools +from localpost.http import ServerConfig, start_http_server +from localpost.http.flask_sentry import sentry_flask_handler + + +class CapturingTransport(Transport): + def __init__(self) -> None: + super().__init__({}) + self.envelopes: list[Envelope] = [] + + def capture_envelope(self, envelope: Envelope) -> None: + self.envelopes.append(envelope) + + def flush(self, timeout: float, callback=None) -> None: # type: ignore[override] + pass + + +def _transactions(transport: CapturingTransport) -> list[dict]: + out: list[dict] = [] + for env in transport.envelopes: + for item in env.items: + if item.headers.get("type") == "transaction": + payload = item.payload.json + if payload is not None: + out.append(payload) + return out + + +@pytest.fixture(autouse=True) +def _disable_cancellation_check(monkeypatch): + monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) + + +@pytest.fixture +def server_config(): + return ServerConfig(host="127.0.0.1", port=0) + + +@pytest.fixture +def sentry_transport(): + transport = CapturingTransport() + sentry_sdk.init( + dsn="https://public@example.com/1", + transport=transport, + traces_sample_rate=1.0, + default_integrations=False, + auto_enabling_integrations=False, + ) + yield transport + sentry_sdk.flush(timeout=2.0) + + +def _run_iterations(server, handler, n=20): + for _ in range(n): + try: + server.run(handler) + except (OSError, ValueError, AttributeError): + return + + +def _serve(server_config, handler, request_fn): + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, handler)) + t.start() + try: + return request_fn(server.port) + finally: + t.join(timeout=5) + + +class TestSentryFlaskHandler: + def test_transaction_named_after_url_rule(self, server_config, sentry_transport): + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + return f"hi {name}" + + assert hello + + resp = _serve( + server_config, + sentry_flask_handler(app), + lambda port: httpx.get(f"http://127.0.0.1:{port}/hello/alice"), + ) + assert resp.status_code == 200 + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /hello/" + assert tx["transaction_info"]["source"] == "route" + assert tx["contexts"]["trace"]["op"] == "http.server" + + def test_unmatched_url_keeps_url_source(self, server_config, sentry_transport): + app = Flask(__name__) + + resp = _serve( + server_config, + sentry_flask_handler(app), + lambda port: httpx.get(f"http://127.0.0.1:{port}/no-such-route"), + ) + assert resp.status_code == 404 + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + assert txs[0]["transaction"] == "GET /no-such-route" + assert txs[0]["transaction_info"]["source"] == "url" + + def test_streaming_span_lands_on_same_transaction(self, server_config, sentry_transport): + """The reason this adapter exists: spans emitted inside a streaming + generator must land on the same transaction as the request, not on a + new (or no) transaction. Stock Sentry FlaskIntegration ends the + transaction before the WSGI server iterates the body. + """ + app = Flask(__name__) + + @app.route("/stream/") + def stream(name: str): + def generate(): + # Span emitted DURING body iteration. Must land on the request transaction. + with sentry_sdk.start_span(op="custom.streaming-chunk", name="emit-body"): + yield f"hi {name}\n".encode() + # Touch flask.request to prove context is alive. + yield f"ua={flask_request.headers.get('User-Agent', '?')}\n".encode() + + return FlaskResponse(generate(), mimetype="text/plain") + + assert stream + + resp = _serve( + server_config, + sentry_flask_handler(app), + lambda port: httpx.get( + f"http://127.0.0.1:{port}/stream/alice", + headers={"User-Agent": "test"}, + ), + ) + assert resp.status_code == 200 + assert b"hi alice" in resp.content + assert b"ua=test" in resp.content + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /stream/" + + # The streaming span must be in this transaction's spans. + span_ops = [s.get("op") for s in tx.get("spans", [])] + assert "custom.streaming-chunk" in span_ops, ( + f"streaming span did not land on the transaction; spans={tx.get('spans')}" + ) + + def test_view_exception_records_500(self, server_config, sentry_transport): + app = Flask(__name__) + + @app.route("/boom") + def boom(): + raise RuntimeError("bang") + + assert boom + + resp = _serve( + server_config, + sentry_flask_handler(app), + lambda port: httpx.get(f"http://127.0.0.1:{port}/boom"), + ) + assert resp.status_code == 500 + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + trace = txs[0]["contexts"]["trace"] + status = trace.get("data", {}).get("http.response.status_code") or txs[0]["tags"].get("http.status_code") + assert status in (500, "500") diff --git a/tests/http/router_sentry_handler.py b/tests/http/router_sentry_handler.py new file mode 100644 index 0000000..2addb0d --- /dev/null +++ b/tests/http/router_sentry_handler.py @@ -0,0 +1,174 @@ +"""Tests for localpost.http.router_sentry.""" + +from __future__ import annotations + +import threading + +import httpx +import pytest +import sentry_sdk +from sentry_sdk.envelope import Envelope +from sentry_sdk.transport import Transport + +from localpost import threadtools +from localpost.http import RequestCtx, Response, Routes, ServerConfig, start_http_server +from localpost.http.router_sentry import sentry_router_handler + + +class CapturingTransport(Transport): + """Sentry transport that just records envelopes (no network).""" + + def __init__(self) -> None: + super().__init__({}) + self.envelopes: list[Envelope] = [] + + def capture_envelope(self, envelope: Envelope) -> None: + self.envelopes.append(envelope) + + def flush(self, timeout: float, callback=None) -> None: # type: ignore[override] + pass + + +def _transactions(transport: CapturingTransport) -> list[dict]: + out: list[dict] = [] + for env in transport.envelopes: + for item in env.items: + if item.headers.get("type") == "transaction": + payload = item.payload.json + if payload is not None: + out.append(payload) + return out + + +@pytest.fixture(autouse=True) +def _disable_cancellation_check(monkeypatch): + monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) + + +@pytest.fixture +def server_config(): + return ServerConfig(host="127.0.0.1", port=0) + + +@pytest.fixture +def sentry_transport(): + transport = CapturingTransport() + sentry_sdk.init( + dsn="https://public@example.com/1", + transport=transport, + traces_sample_rate=1.0, + # Disable default integrations that might pollute envelopes. + default_integrations=False, + auto_enabling_integrations=False, + ) + yield transport + sentry_sdk.flush(timeout=2.0) + + +def _run_iterations(server, handler, n=20): + for _ in range(n): + try: + server.run(handler) + except (OSError, ValueError, AttributeError): + return + + +def _serve(server_config, handler, request_fn): + with start_http_server(server_config) as server: + t = threading.Thread(target=_run_iterations, args=(server, handler)) + t.start() + try: + return request_fn(server.port) + finally: + t.join(timeout=5) + + +def _build_router(): + routes = Routes() + + @routes.get("/books/{id}") + def get_book(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [f"book={ctx.path_args['id']}".encode()]) + + @routes.post("/books") + def create_book(_: RequestCtx) -> Response: + return Response(201, {}, []) + + assert get_book is not None + assert create_book is not None + return routes.build() + + +class TestSentryRouterHandler: + def test_matched_route_uses_template_name(self, server_config, sentry_transport): + router = _build_router() + handler = sentry_router_handler(router) + + resp = _serve( + server_config, + handler, + lambda port: httpx.get(f"http://127.0.0.1:{port}/books/42"), + ) + assert resp.status_code == 200 + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /books/{id}" + assert tx["transaction_info"]["source"] == "route" + # Op lives under contexts.trace.op + assert tx["contexts"]["trace"]["op"] == "http.server" + + def test_unmatched_uses_url_source(self, server_config, sentry_transport): + router = _build_router() + handler = sentry_router_handler(router) + + resp = _serve( + server_config, + handler, + lambda port: httpx.get(f"http://127.0.0.1:{port}/does-not-exist"), + ) + assert resp.status_code == 404 + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + assert tx["transaction"] == "GET /does-not-exist" + assert tx["transaction_info"]["source"] == "url" + + def test_status_code_is_recorded(self, server_config, sentry_transport): + router = _build_router() + handler = sentry_router_handler(router) + + _serve( + server_config, + handler, + lambda port: httpx.post(f"http://127.0.0.1:{port}/books"), + ) + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + tx = txs[0] + # Sentry stores http status under contexts.trace.data["http.response.status_code"] + # (older SDKs put it on tags). Check both. + trace_data = tx["contexts"]["trace"].get("data", {}) + status = trace_data.get("http.response.status_code") or tx.get("tags", {}).get("http.status_code") + assert status in (201, "201") + + def test_method_tag_recorded(self, server_config, sentry_transport): + router = _build_router() + handler = sentry_router_handler(router) + + _serve( + server_config, + handler, + lambda port: httpx.get(f"http://127.0.0.1:{port}/books/1"), + ) + sentry_sdk.flush(timeout=2.0) + + txs = _transactions(sentry_transport) + assert len(txs) == 1 + assert txs[0]["tags"].get("http.method") == "GET" diff --git a/uv.lock b/uv.lock index 32be4e5..d6aeed2 100644 --- a/uv.lock +++ b/uv.lock @@ -1073,6 +1073,9 @@ http-flask = [ http-openapi = [ { name = "msgspec" }, ] +http-sentry = [ + { name = "sentry-sdk" }, +] http-server = [ { name = "h11" }, ] @@ -1168,8 +1171,9 @@ requires-dist = [ { name = "msgspec", marker = "extra == 'http-openapi'", specifier = "~=0.19" }, { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, + { name = "sentry-sdk", marker = "extra == 'http-sentry'", specifier = "~=2.51" }, ] -provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "http-flask", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] +provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "http-flask", "http-sentry", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] [package.metadata.requires-dev] dev = [ From 929b156def735e9e9298f5bf8c84e3f62d70581c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 11:48:27 +0400 Subject: [PATCH 072/286] refactor(http): Route dataclass, drop hand-maintained status phrases Two cleanups in localpost/http/router.py. - Introduce Route, a frozen dataclass holding template + methods + pre-rendered allow_header (and an internal _group_prefix for the combined regex). Router carries a single routes: tuple[Route, ...] instead of four parallel tuples (templates / methods / allow_headers / _group_prefixes). Eliminates the parallel-index hazard and makes _match read as a per-route loop. - Drop _status_phrase(): the hand-maintained dict of ~10 status codes is replaced by http.client.responses.get(code, "Unknown") at the three call sites (WSGI status line + two h11.Response reason phrases). Stdlib dict covers every standard HTTP code; one less thing to keep in sync. Public-API delta: Route is exported from localpost.http and added to the router README table. Router.templates / .methods / .allow_headers are no longer present (folded into Router.routes[i].X). Tests updated to read router.routes[i].template / .allow_header. All 81 tests/http/ pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/README.md | 3 +- localpost/http/__init__.py | 2 + localpost/http/router.py | 94 ++++++++++++++++++-------------------- tests/http/router.py | 8 ++-- 4 files changed, 52 insertions(+), 55 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index 0fb2a19..3d9bb03 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -90,7 +90,8 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) | `URITemplate` | Parse and match RFC 6570 L1 templates | | `RequestCtx` | Routed request context (path args, query, body access) | | `Routes` | Mutable builder — decorators (`.get`, `.post`, …) / `.add`. Call `.build()` to freeze | -| `Router` | Immutable, compiled dispatcher. `.as_handler()` for native, `.wsgi` for WSGI | +| `Router` | Immutable, compiled dispatcher. `.as_handler()` for native, `.wsgi` for WSGI. `.routes` is a tuple of `Route`. | +| `Route` | One compiled route: `template`, `methods`, pre-rendered `allow_header` | | `Response` | Simple `(status, headers, body)` tuple | ### `localpost.http.wsgi` diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 99e823a..19f9445 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -3,6 +3,7 @@ from localpost.http.router import ( RequestCtx, Response, + Route, Router, Routes, URITemplate, @@ -24,6 +25,7 @@ # router "Router", "Routes", + "Route", "URITemplate", "RequestCtx", "Response", diff --git a/localpost/http/router.py b/localpost/http/router.py index 99f04a5..1f0907d 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -5,6 +5,7 @@ from contextlib import ExitStack from dataclasses import dataclass, field from http import HTTPMethod +from http.client import responses as _http_phrases from typing import Self, final from urllib.parse import parse_qs @@ -20,6 +21,7 @@ "RequestCtx", "Response", "RequestHandler", + "Route", "Routes", "Router", ] @@ -103,6 +105,22 @@ class Response: RequestHandler = Callable[[RequestCtx], Response] +@final +@dataclass(frozen=True, eq=False, slots=True) +class Route: + """One compiled route inside a :class:`Router`.""" + + template: URITemplate + methods: Mapping[HTTPMethod, RequestHandler] + """Method → handler table for this template.""" + + allow_header: str + """Pre-rendered ``Allow`` header value (e.g. ``"GET, POST"``).""" + + _group_prefix: str + """Internal: the named-group prefix this route uses inside the combined regex.""" + + @final @dataclass(eq=False, slots=True) class Routes: @@ -162,17 +180,10 @@ class Router: an implementation detail — treat the class as read-only outside of ``from_routes``. """ - templates: tuple[URITemplate, ...] - """Templates in dispatch order — longest literal prefix first, stable on ties.""" - - methods: tuple[Mapping[HTTPMethod, RequestHandler], ...] - """Per-template method → handler table; parallel to :attr:`templates`.""" - - allow_headers: tuple[str, ...] - """Pre-rendered ``Allow`` header value per template (e.g. ``"GET, POST"``).""" + routes: tuple[Route, ...] + """Routes in dispatch order — longest literal prefix first, stable on ties.""" _regex: re.Pattern[str] - _group_prefixes: tuple[str, ...] @classmethod def from_routes(cls, routes: Routes) -> Self: @@ -190,30 +201,29 @@ def from_routes(cls, routes: Routes) -> Self: reverse=True, ) - templates: list[URITemplate] = [] - methods_list: list[Mapping[HTTPMethod, RequestHandler]] = [] - allow_headers: list[str] = [] - group_prefixes: list[str] = [] + compiled_routes: list[Route] = [] alternatives: list[str] = [] for i, (tmpl, method_map) in enumerate(ordered): prefix = f"r{i}" - templates.append(tmpl) - # Freeze the handler map at the type level (still a plain dict underneath, - # but exposed as Mapping — external mutation is a type error). - methods_list.append(dict(method_map)) - allow_headers.append(", ".join(m.value for m in sorted(method_map, key=lambda hm: hm.value))) - group_prefixes.append(prefix) + allow_header = ", ".join(m.value for m in sorted(method_map, key=lambda hm: hm.value)) + compiled_routes.append( + Route( + template=tmpl, + # Freeze the handler map at the type level (still a plain dict + # underneath, but exposed as Mapping — external mutation is a type error). + methods=dict(method_map), + allow_header=allow_header, + _group_prefix=prefix, + ) + ) alternatives.append(f"(?P<{prefix}>{_reprefixed_regex_source(tmpl, prefix)})") combined = re.compile("^(?:" + "|".join(alternatives) + ")$") if alternatives else re.compile(r"^(?!)") return cls( - templates=tuple(templates), - methods=tuple(methods_list), - allow_headers=tuple(allow_headers), + routes=tuple(compiled_routes), _regex=combined, - _group_prefixes=tuple(group_prefixes), ) # --- Dispatch ----------------------------------------------------- @@ -223,12 +233,12 @@ def _match(self, path: str, method_str: str) -> _MatchResult: if m is None: return _MatchResult(kind="not_found") - for i, outer in enumerate(self._group_prefixes): - if m.group(outer) is None: + for route in self.routes: + if m.group(route._group_prefix) is None: continue - tmpl = self.templates[i] - method_map = self.methods[i] - path_args = {v: m.group(f"{outer}_{v}") or "" for v in tmpl.variable_names} + tmpl = route.template + method_map = route.methods + path_args = {v: m.group(f"{route._group_prefix}_{v}") or "" for v in tmpl.variable_names} try: method = HTTPMethod(method_str) @@ -236,7 +246,7 @@ def _match(self, path: str, method_str: str) -> _MatchResult: return _MatchResult( kind="method_not_allowed", allowed=tuple(method_map), - allow_header=self.allow_headers[i], + allow_header=route.allow_header, ) handler = method_map.get(method) @@ -244,7 +254,7 @@ def _match(self, path: str, method_str: str) -> _MatchResult: return _MatchResult( kind="method_not_allowed", allowed=tuple(method_map), - allow_header=self.allow_headers[i], + allow_header=route.allow_header, ) return _MatchResult( @@ -254,7 +264,7 @@ def _match(self, path: str, method_str: str) -> _MatchResult: matched_template=tmpl, path_args=path_args, allowed=tuple(method_map), - allow_header=self.allow_headers[i], + allow_header=route.allow_header, ) raise AssertionError("unreachable: regex matched but no outer group set") @@ -302,7 +312,7 @@ def receive(size: int) -> bytes: ) response = match.handler(ctx) - status_line = f"{response.status_code} {_status_phrase(response.status_code)}" + status_line = f"{response.status_code} {_http_phrases.get(response.status_code, 'Unknown')}" start_response(status_line, [(k, v) for k, v in response.headers.items()]) return response.body @@ -357,7 +367,7 @@ def handle(http_ctx: HTTPReqCtx) -> None: h11.Response( status_code=response.status_code, headers=h11_headers, - reason=_status_phrase(response.status_code).encode("iso-8859-1"), + reason=_http_phrases.get(response.status_code, "Unknown").encode("iso-8859-1"), ) ) for chunk in response.body: @@ -438,23 +448,7 @@ def _send_plain( h11.Response( status_code=status_code, headers=headers, - reason=_status_phrase(status_code).encode("iso-8859-1"), + reason=_http_phrases.get(status_code, "Unknown").encode("iso-8859-1"), ), body, ) - - -def _status_phrase(code: int) -> str: - phrases = { - 200: "OK", - 201: "Created", - 204: "No Content", - 301: "Moved Permanently", - 400: "Bad Request", - 401: "Unauthorized", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 500: "Internal Server Error", - } - return phrases.get(code, "Unknown") diff --git a/tests/http/router.py b/tests/http/router.py index d37b2e3..5d585a3 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -419,13 +419,13 @@ def test_empty_routes_produce_empty_router(self): assert body == b"Not Found" def test_build_is_idempotent_snapshot(self): - """Building twice produces equivalent routers with the same templates tuple.""" + """Building twice produces equivalent routers with the same routes order.""" routes = Routes() routes.add("GET", "/a", lambda ctx: Response(200, {}, [])) routes.add("GET", "/b", lambda ctx: Response(200, {}, [])) r1 = routes.build() r2 = routes.build() - assert [t.template for t in r1.templates] == [t.template for t in r2.templates] + assert [r.template.template for r in r1.routes] == [r.template.template for r in r2.routes] def test_allow_header_precomputed(self): routes = Routes() @@ -433,5 +433,5 @@ def test_allow_header_precomputed(self): routes.add("POST", "/x", lambda ctx: Response(200, {}, [])) router = routes.build() # Methods are sorted for a stable header; both methods end up in the header. - assert router.allow_headers[0] in ("GET, POST", "POST, GET") - assert set(router.allow_headers[0].split(", ")) == {"GET", "POST"} + assert router.routes[0].allow_header in ("GET, POST", "POST, GET") + assert set(router.routes[0].allow_header.split(", ")) == {"GET", "POST"} From c65b59c6d27341d7d2fbd504b22bfc47fc2be2b9 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 20:49:42 +0400 Subject: [PATCH 073/286] refactor(http): decouple Server.run from threadtools The cancellation check inside Server.run is the caller's job (the hosted service already does from_thread.check_cancelled before each iteration), so drop it from the loop and expose `timeout` as a parameter. The select / RW deadlines now read from a new ServerConfig.rw_timeout field instead of threadtools.CHECK_TIMEOUT, so the server module no longer imports threadtools at all. Standalone quick-start examples lose the workaround line. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/simple_server.py | 5 +---- localpost/http/README.md | 3 --- localpost/http/config.py | 5 +++-- localpost/http/server.py | 22 ++++++++++++---------- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/examples/http/simple_server.py b/examples/http/simple_server.py index 65365ea..50eab38 100644 --- a/examples/http/simple_server.py +++ b/examples/http/simple_server.py @@ -2,15 +2,11 @@ import h11 -from localpost import threadtools from localpost.http.config import ServerConfig from localpost.http.server import HTTPReqCtx, start_http_server def _main(): - logging.basicConfig(level=logging.DEBUG) - threadtools.check_cancelled = lambda: None - def simple_app(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"Hello, World!\n") @@ -20,4 +16,5 @@ def simple_app(ctx: HTTPReqCtx): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) _main() diff --git a/localpost/http/README.md b/localpost/http/README.md index 3d9bb03..11bedfa 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -20,12 +20,9 @@ pip install localpost[http-server] ```python import h11 -from localpost import threadtools from localpost.http.config import ServerConfig from localpost.http.server import HTTPReqCtx, start_http_server -threadtools.check_cancelled = lambda: None # not running under hosting - def simple_app(ctx: HTTPReqCtx): ctx.complete( diff --git a/localpost/http/config.py b/localpost/http/config.py index 7794fc4..f730f52 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -21,8 +21,9 @@ class ServerConfig: port: int = 8000 backlog: int = 1024 """Maximum number of queued (in the kernel) connections.""" - # rw_timeout: float = threadtools.CHECK_TIMEOUT - # """Timeout (seconds) for receive/send operations on a client connection.""" + rw_timeout: float = 1.0 + """Timeout (seconds) for receive/send operations on a borrowed client connection, + and for the keep-alive read deadline extended after each chunk arrives.""" keep_alive_timeout: float = 15.0 # TODO add it to the response """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB diff --git a/localpost/http/server.py b/localpost/http/server.py index dcd3818..8094de5 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -20,15 +20,11 @@ import h11 -from localpost import threadtools from localpost.http.config import DEFAULT_BUFFER_SIZE, LOGGER_NAME, ServerConfig __all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] -RW_TIMEOUT = threadtools.CHECK_TIMEOUT - - @contextmanager def start_http_server(config: ServerConfig) -> Iterator[Server]: logger = logging.getLogger(LOGGER_NAME) @@ -101,17 +97,23 @@ def stop_tracking(self, conn: HTTPConn) -> None: sock = conn.sock with self._lock: self.selector.unregister(sock) - sock.settimeout(RW_TIMEOUT) + sock.settimeout(self.config.rw_timeout) conn.tracked = False - def run(self, h: RequestHandler) -> None: - """One iteration of the server loop. Should be called repeatedly until the server is stopped.""" + def run(self, h: RequestHandler, /, *, timeout: float | None = None) -> None: + """One iteration of the server loop. Should be called repeatedly until the server is stopped. + + ``timeout`` bounds the underlying ``selector.select`` call — it caps how long this + method blocks before returning to the caller, giving the caller a chance to check + for shutdown / cancellation. Defaults to ``config.rw_timeout``. + """ + if timeout is None: + timeout = self.config.rw_timeout server_sock = self.sock - threadtools.check_cancelled() self._cleanup_stale() # TODO Take iteration payload (pending connections) and set it empty, under the lock # TODO Add selector.select() to the current payload (chain) - for key, _ in self.selector.select(timeout=threadtools.CHECK_TIMEOUT): + for key, _ in self.selector.select(timeout=timeout): if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() conn = HTTPConn(self, client_sock, client_addr) @@ -183,7 +185,7 @@ def __call__(self, h: RequestHandler) -> None: self.receive() except BlockingIOError: return # Wait for it in the selector - self.close_at = time.monotonic() + RW_TIMEOUT + self.close_at = time.monotonic() + self.server.config.rw_timeout elif isinstance(event, h11.Data | h11.EndOfMessage): continue # Drain the request body elif isinstance(event, h11.Request): From 5edd8f32df4449867222f31c268c9e50c07b9ae2 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 20:55:02 +0400 Subject: [PATCH 074/286] test(http): centralize fixtures, drop magic iteration counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `serve_in_thread` fixture in tests/http/conftest.py that owns the server thread end-to-end: an explicit stop event, a 50 ms select timeout, and a join-with-leak-assert on teardown. Migrate every socket-based HTTP test to use it, dropping the duplicated `_run_iterations(n=10/15/20/…)` helpers, the no-longer-needed `_disable_cancellation_check` autouse fixture, and the per-file `server_config` fixture. Extract the Sentry test boilerplate (CapturingTransport, transactions decoder, init_sentry helper) into tests/http/_sentry_helpers.py. Side benefit: the HTTP suite drops from ~133 s to ~12 s and stops flaking on the body-receive test that was racing the iteration count. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/_sentry_helpers.py | 44 ++++++++ tests/http/conftest.py | 88 ++++++++++++++++ tests/http/flask_sentry_handler.py | 112 ++++---------------- tests/http/flask_server.py | 104 ++++++++----------- tests/http/router.py | 95 +++++------------ tests/http/router_sentry_handler.py | 117 ++++----------------- tests/http/server.py | 153 ++++++++-------------------- tests/http/wsgi.py | 77 ++++---------- 8 files changed, 299 insertions(+), 491 deletions(-) create mode 100644 tests/http/_sentry_helpers.py diff --git a/tests/http/_sentry_helpers.py b/tests/http/_sentry_helpers.py new file mode 100644 index 0000000..a989f0e --- /dev/null +++ b/tests/http/_sentry_helpers.py @@ -0,0 +1,44 @@ +"""Shared helpers for the Sentry-handler tests.""" + +from __future__ import annotations + +import sentry_sdk +from sentry_sdk.envelope import Envelope +from sentry_sdk.transport import Transport + + +class CapturingTransport(Transport): + """Sentry transport that just records envelopes (no network).""" + + def __init__(self) -> None: + super().__init__({}) + self.envelopes: list[Envelope] = [] + + def capture_envelope(self, envelope: Envelope) -> None: + self.envelopes.append(envelope) + + def flush(self, timeout: float, callback=None) -> None: # type: ignore[override] + pass + + +def transactions(transport: CapturingTransport) -> list[dict]: + out: list[dict] = [] + for env in transport.envelopes: + for item in env.items: + if item.headers.get("type") == "transaction": + payload = item.payload.json + if payload is not None: + out.append(payload) + return out + + +def init_sentry(transport: CapturingTransport) -> None: + """Boot a Sentry SDK with traces enabled and the in-memory transport.""" + sentry_sdk.init( + dsn="https://public@example.com/1", + transport=transport, + traces_sample_rate=1.0, + # Disable default integrations that might pollute envelopes. + default_integrations=False, + auto_enabling_integrations=False, + ) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index b8d4c2d..a4ffcaf 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,7 +1,13 @@ +from __future__ import annotations + import socket +import threading +from collections.abc import Callable, Iterator import pytest +from localpost.http import RequestHandler, ServerConfig, start_http_server + @pytest.fixture def free_port() -> int: @@ -13,3 +19,85 @@ def free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] + + +@pytest.fixture +def server_config() -> ServerConfig: + """Default config for in-thread server tests: localhost + auto-assigned port.""" + return ServerConfig(host="127.0.0.1", port=0) + + +ServeInThread = Callable[[RequestHandler], "_ServerCM"] + + +class _ServerCM: + """Returned by ``serve_in_thread(handler)``; use as a context manager. + + Yields the live port; on exit, signals the worker to stop, joins it, + and asserts no thread leak. + """ + + def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: + self._config = config + self._handler = handler + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._cm = start_http_server(config) + + def __enter__(self) -> int: + server = self._cm.__enter__() + handler = self._handler + stop = self._stop + + def loop() -> None: + while not stop.is_set(): + try: + server.run(handler, timeout=0.05) + except OSError: + return # listening socket closed + + self._thread = threading.Thread(target=loop, daemon=True) + self._thread.start() + return server.port + + def __exit__(self, exc_type, exc, tb) -> None: + self._stop.set() + try: + t = self._thread + assert t is not None + t.join(timeout=5) + assert not t.is_alive(), "server thread did not stop within 5s" + finally: + self._cm.__exit__(exc_type, exc, tb) + + +@pytest.fixture +def serve_in_thread(server_config: ServerConfig) -> Iterator[ServeInThread]: + """Run an HTTP server in a background thread for the duration of a ``with`` block. + + Usage:: + + def test_thing(serve_in_thread): + def handler(ctx): ... + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/") + assert resp.status_code == 200 + + The worker thread reacts to shutdown within ~50 ms (selector poll interval), + so tests don't need magic iteration counts. + """ + active: list[_ServerCM] = [] + + def make(handler: RequestHandler) -> _ServerCM: + cm = _ServerCM(server_config, handler) + active.append(cm) + return cm + + yield make + + # Safety net for tests that forgot the ``with`` block. + for cm in active: + t = cm._thread + if t is not None and t.is_alive(): + cm._stop.set() + t.join(timeout=5) diff --git a/tests/http/flask_sentry_handler.py b/tests/http/flask_sentry_handler.py index 61b2359..500ea29 100644 --- a/tests/http/flask_sentry_handler.py +++ b/tests/http/flask_sentry_handler.py @@ -2,89 +2,28 @@ from __future__ import annotations -import threading - import httpx import pytest import sentry_sdk from flask import Flask from flask import Response as FlaskResponse from flask import request as flask_request -from sentry_sdk.envelope import Envelope -from sentry_sdk.transport import Transport -from localpost import threadtools -from localpost.http import ServerConfig, start_http_server from localpost.http.flask_sentry import sentry_flask_handler - -class CapturingTransport(Transport): - def __init__(self) -> None: - super().__init__({}) - self.envelopes: list[Envelope] = [] - - def capture_envelope(self, envelope: Envelope) -> None: - self.envelopes.append(envelope) - - def flush(self, timeout: float, callback=None) -> None: # type: ignore[override] - pass - - -def _transactions(transport: CapturingTransport) -> list[dict]: - out: list[dict] = [] - for env in transport.envelopes: - for item in env.items: - if item.headers.get("type") == "transaction": - payload = item.payload.json - if payload is not None: - out.append(payload) - return out - - -@pytest.fixture(autouse=True) -def _disable_cancellation_check(monkeypatch): - monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) - - -@pytest.fixture -def server_config(): - return ServerConfig(host="127.0.0.1", port=0) +from ._sentry_helpers import CapturingTransport, init_sentry, transactions @pytest.fixture def sentry_transport(): transport = CapturingTransport() - sentry_sdk.init( - dsn="https://public@example.com/1", - transport=transport, - traces_sample_rate=1.0, - default_integrations=False, - auto_enabling_integrations=False, - ) + init_sentry(transport) yield transport sentry_sdk.flush(timeout=2.0) -def _run_iterations(server, handler, n=20): - for _ in range(n): - try: - server.run(handler) - except (OSError, ValueError, AttributeError): - return - - -def _serve(server_config, handler, request_fn): - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, handler)) - t.start() - try: - return request_fn(server.port) - finally: - t.join(timeout=5) - - class TestSentryFlaskHandler: - def test_transaction_named_after_url_rule(self, server_config, sentry_transport): + def test_transaction_named_after_url_rule(self, serve_in_thread, sentry_transport): app = Flask(__name__) @app.route("/hello/") @@ -93,38 +32,32 @@ def hello(name: str): assert hello - resp = _serve( - server_config, - sentry_flask_handler(app), - lambda port: httpx.get(f"http://127.0.0.1:{port}/hello/alice"), - ) + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/alice", timeout=5) assert resp.status_code == 200 sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 tx = txs[0] assert tx["transaction"] == "GET /hello/" assert tx["transaction_info"]["source"] == "route" assert tx["contexts"]["trace"]["op"] == "http.server" - def test_unmatched_url_keeps_url_source(self, server_config, sentry_transport): + def test_unmatched_url_keeps_url_source(self, serve_in_thread, sentry_transport): app = Flask(__name__) - resp = _serve( - server_config, - sentry_flask_handler(app), - lambda port: httpx.get(f"http://127.0.0.1:{port}/no-such-route"), - ) + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/no-such-route", timeout=5) assert resp.status_code == 404 sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 assert txs[0]["transaction"] == "GET /no-such-route" assert txs[0]["transaction_info"]["source"] == "url" - def test_streaming_span_lands_on_same_transaction(self, server_config, sentry_transport): + def test_streaming_span_lands_on_same_transaction(self, serve_in_thread, sentry_transport): """The reason this adapter exists: spans emitted inside a streaming generator must land on the same transaction as the request, not on a new (or no) transaction. Stock Sentry FlaskIntegration ends the @@ -145,20 +78,18 @@ def generate(): assert stream - resp = _serve( - server_config, - sentry_flask_handler(app), - lambda port: httpx.get( + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get( f"http://127.0.0.1:{port}/stream/alice", headers={"User-Agent": "test"}, - ), - ) + timeout=5, + ) assert resp.status_code == 200 assert b"hi alice" in resp.content assert b"ua=test" in resp.content sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 tx = txs[0] assert tx["transaction"] == "GET /stream/" @@ -169,7 +100,7 @@ def generate(): f"streaming span did not land on the transaction; spans={tx.get('spans')}" ) - def test_view_exception_records_500(self, server_config, sentry_transport): + def test_view_exception_records_500(self, serve_in_thread, sentry_transport): app = Flask(__name__) @app.route("/boom") @@ -178,15 +109,12 @@ def boom(): assert boom - resp = _serve( - server_config, - sentry_flask_handler(app), - lambda port: httpx.get(f"http://127.0.0.1:{port}/boom"), - ) + with serve_in_thread(sentry_flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=5) assert resp.status_code == 500 sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 trace = txs[0]["contexts"]["trace"] status = trace.get("data", {}).get("http.response.status_code") or txs[0]["tags"].get("http.status_code") diff --git a/tests/http/flask_server.py b/tests/http/flask_server.py index 9515491..f71ec88 100644 --- a/tests/http/flask_server.py +++ b/tests/http/flask_server.py @@ -5,46 +5,14 @@ import threading import httpx -import pytest from flask import Flask, Response, stream_with_context from flask import request as flask_request -from localpost import threadtools -from localpost.http import ServerConfig, start_http_server from localpost.http.flask import flask_handler -@pytest.fixture(autouse=True) -def _disable_cancellation_check(monkeypatch): - monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) - - -@pytest.fixture -def server_config(): - return ServerConfig(host="127.0.0.1", port=0) - - -def _run_iterations(server, handler, n=20): - for _ in range(n): - try: - server.run(handler) - except (OSError, ValueError, AttributeError): - return - - -def _serve(server_config, app: Flask, request_fn): - """Run the server in a thread, call request_fn(port), return whatever it returns.""" - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, flask_handler(app))) - t.start() - try: - return request_fn(server.port) - finally: - t.join(timeout=5) - - class TestFlaskHandler: - def test_simple_200(self, server_config): + def test_simple_200(self, serve_in_thread): app = Flask(__name__) @app.route("/") @@ -53,11 +21,13 @@ def index(): assert index - resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/")) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + assert resp.status_code == 200 assert resp.text == "hello flask" - def test_path_parameters(self, server_config): + def test_path_parameters(self, serve_in_thread): app = Flask(__name__) @app.route("/hello/") @@ -66,11 +36,13 @@ def hello(name: str): assert hello - resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/hello/alice")) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/alice", timeout=5) + assert resp.status_code == 200 assert resp.text == "hi alice" - def test_post_body(self, server_config): + def test_post_body(self, serve_in_thread): app = Flask(__name__) captured: dict = {} @@ -81,22 +53,22 @@ def echo(): assert echo - resp = _serve( - server_config, - app, - lambda port: httpx.post(f"http://127.0.0.1:{port}/echo", content=b"payload"), - ) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/echo", content=b"payload", timeout=5) + assert captured.get("body") == b"payload", f"status={resp.status_code}, body={resp.content!r}" assert resp.status_code == 200 assert resp.content == b"payload" - def test_flask_404(self, server_config): + def test_flask_404(self, serve_in_thread): app = Flask(__name__) - resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/missing")) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/missing", timeout=5) + assert resp.status_code == 404 - def test_view_exception_returns_500(self, server_config): + def test_view_exception_returns_500(self, serve_in_thread): app = Flask(__name__) @app.route("/boom") @@ -105,10 +77,12 @@ def boom(): assert boom - resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/boom")) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=5) + assert resp.status_code == 500 - def test_streaming_without_stream_with_context(self, server_config): + def test_streaming_without_stream_with_context(self, serve_in_thread): """Key behavior test: generator uses flask.request without @stream_with_context.""" app = Flask(__name__) @@ -124,18 +98,17 @@ def generate(): assert stream - resp = _serve( - server_config, - app, - lambda port: httpx.get( + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get( f"http://127.0.0.1:{port}/stream", headers={"User-Agent": "test-client"}, - ), - ) + timeout=5, + ) + assert resp.status_code == 200 assert resp.text == "ua=test-client\n" - def test_streaming_with_stream_with_context_still_works(self, server_config): + def test_streaming_with_stream_with_context_still_works(self, serve_in_thread): """Backwards compat: @stream_with_context is a no-op but must not break.""" app = Flask(__name__) @@ -149,18 +122,17 @@ def generate(): assert stream_ctx - resp = _serve( - server_config, - app, - lambda port: httpx.get( + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get( f"http://127.0.0.1:{port}/stream-ctx", headers={"User-Agent": "test-client"}, - ), - ) + timeout=5, + ) + assert resp.status_code == 200 assert resp.text == "ua=test-client\n" - def test_teardown_runs_after_body_sent(self, server_config): + def test_teardown_runs_after_body_sent(self, serve_in_thread): """teardown_request fires AFTER response iteration completes, not before. This is the opposite of standard WSGI Flask behavior. @@ -192,7 +164,9 @@ def generate(): assert ordering - resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/ordering")) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/ordering", timeout=5) + assert resp.status_code == 200 assert resp.content == b"onetwo" @@ -201,7 +175,7 @@ def generate(): captured = list(events) assert captured == ["view-start", "chunk-1", "chunk-2", "chunk-end", "teardown"] - def test_response_headers_are_forwarded(self, server_config): + def test_response_headers_are_forwarded(self, serve_in_thread): app = Flask(__name__) @app.route("/with-header") @@ -210,7 +184,9 @@ def with_header(): assert with_header - resp = _serve(server_config, app, lambda port: httpx.get(f"http://127.0.0.1:{port}/with-header")) + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/with-header", timeout=5) + assert resp.status_code == 200 assert resp.headers.get("x-custom") == "yes" assert resp.headers.get("content-type", "").startswith("text/plain") diff --git a/tests/http/router.py b/tests/http/router.py index 5d585a3..6842966 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -2,22 +2,17 @@ from __future__ import annotations -import threading from http import HTTPMethod from io import BytesIO import httpx -import pytest -from localpost import threadtools from localpost.http import ( RequestCtx, Response, Router, Routes, - ServerConfig, URITemplate, - start_http_server, ) # --- URITemplate ---------------------------------------------------------- @@ -186,26 +181,8 @@ def new_book(_: RequestCtx) -> Response: # --- Router.as_handler (native) ------------------------------------------- -@pytest.fixture(autouse=True) -def _disable_cancellation_check(monkeypatch): - monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) - - -@pytest.fixture -def server_config(): - return ServerConfig(host="127.0.0.1", port=0) - - -def _run_iterations(server, handler, n=15): - for _ in range(n): - try: - server.run(handler) - except (OSError, ValueError, AttributeError): - return # Server context manager exited; selector/socket closed - - class TestRouterAsHandler: - def test_dispatch_200(self, server_config): + def test_dispatch_200(self, serve_in_thread): routes = Routes() @routes.get("/ping") @@ -215,16 +192,13 @@ def ping(_: RequestCtx) -> Response: assert ping router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/ping") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=5) assert resp.status_code == 200 assert resp.text == "pong" - def test_dispatch_path_params(self, server_config): + def test_dispatch_path_params(self, serve_in_thread): routes = Routes() captured: dict = {} @@ -238,29 +212,23 @@ def get_book(ctx: RequestCtx) -> Response: assert get_book router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - httpx.get(f"http://127.0.0.1:{server.port}/books/xyz-123") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + httpx.get(f"http://127.0.0.1:{port}/books/xyz-123", timeout=5) assert captured["book_id"] == "xyz-123" assert captured["method"] == HTTPMethod.GET assert captured["matched_template"] == "/books/{book_id}" - def test_404(self, server_config): + def test_404(self, serve_in_thread): router = Routes().build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/does-not-exist") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/does-not-exist", timeout=5) assert resp.status_code == 404 assert resp.text == "Not Found" - def test_405_with_allow_header(self, server_config): + def test_405_with_allow_header(self, serve_in_thread): routes = Routes() @routes.post("/resource") @@ -270,16 +238,13 @@ def _post(_: RequestCtx) -> Response: assert _post router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/resource") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/resource", timeout=5) assert resp.status_code == 405 assert resp.headers.get("allow") == "POST" - def test_query_parsing(self, server_config): + def test_query_parsing(self, serve_in_thread): routes = Routes() captured: dict = {} @@ -292,16 +257,13 @@ def search(ctx: RequestCtx) -> Response: assert search router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - httpx.get(f"http://127.0.0.1:{server.port}/search?q=books") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + httpx.get(f"http://127.0.0.1:{port}/search?q=books", timeout=5) assert captured["q"] == "books" assert captured["raw"] == "q=books" - def test_streaming_response(self, server_config): + def test_streaming_response(self, serve_in_thread): routes = Routes() @routes.get("/stream") @@ -315,16 +277,13 @@ def stream(_: RequestCtx) -> Response: assert stream router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/stream") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/stream", timeout=5) assert resp.status_code == 200 assert resp.content == b"firstsecondthird" - def test_handler_sees_request_headers(self, server_config): + def test_handler_sees_request_headers(self, serve_in_thread): routes = Routes() captured: dict = {} @@ -336,15 +295,12 @@ def hdr(ctx: RequestCtx) -> Response: assert hdr router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler())) - t.start() - httpx.get(f"http://127.0.0.1:{server.port}/hdr", headers={"X-Custom": "yo"}) - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + httpx.get(f"http://127.0.0.1:{port}/hdr", headers={"X-Custom": "yo"}, timeout=5) assert captured["x_custom"] == "yo" - def test_ctx_can_read_body(self, server_config): + def test_ctx_can_read_body(self, serve_in_thread): routes = Routes() captured: dict = {} @@ -356,11 +312,8 @@ def echo(ctx: RequestCtx) -> Response: assert echo router = routes.build() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, router.as_handler(), 25)) - t.start() - resp = httpx.post(f"http://127.0.0.1:{server.port}/echo", content=b"hello body") - t.join(timeout=5) + with serve_in_thread(router.as_handler()) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/echo", content=b"hello body", timeout=5) assert resp.status_code == 200 assert captured["body"] == b"hello body" diff --git a/tests/http/router_sentry_handler.py b/tests/http/router_sentry_handler.py index 2addb0d..c253e46 100644 --- a/tests/http/router_sentry_handler.py +++ b/tests/http/router_sentry_handler.py @@ -2,87 +2,24 @@ from __future__ import annotations -import threading - import httpx import pytest import sentry_sdk -from sentry_sdk.envelope import Envelope -from sentry_sdk.transport import Transport -from localpost import threadtools -from localpost.http import RequestCtx, Response, Routes, ServerConfig, start_http_server +from localpost.http import RequestCtx, Response, Routes from localpost.http.router_sentry import sentry_router_handler - -class CapturingTransport(Transport): - """Sentry transport that just records envelopes (no network).""" - - def __init__(self) -> None: - super().__init__({}) - self.envelopes: list[Envelope] = [] - - def capture_envelope(self, envelope: Envelope) -> None: - self.envelopes.append(envelope) - - def flush(self, timeout: float, callback=None) -> None: # type: ignore[override] - pass - - -def _transactions(transport: CapturingTransport) -> list[dict]: - out: list[dict] = [] - for env in transport.envelopes: - for item in env.items: - if item.headers.get("type") == "transaction": - payload = item.payload.json - if payload is not None: - out.append(payload) - return out - - -@pytest.fixture(autouse=True) -def _disable_cancellation_check(monkeypatch): - monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) - - -@pytest.fixture -def server_config(): - return ServerConfig(host="127.0.0.1", port=0) +from ._sentry_helpers import CapturingTransport, init_sentry, transactions @pytest.fixture def sentry_transport(): transport = CapturingTransport() - sentry_sdk.init( - dsn="https://public@example.com/1", - transport=transport, - traces_sample_rate=1.0, - # Disable default integrations that might pollute envelopes. - default_integrations=False, - auto_enabling_integrations=False, - ) + init_sentry(transport) yield transport sentry_sdk.flush(timeout=2.0) -def _run_iterations(server, handler, n=20): - for _ in range(n): - try: - server.run(handler) - except (OSError, ValueError, AttributeError): - return - - -def _serve(server_config, handler, request_fn): - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, handler)) - t.start() - try: - return request_fn(server.port) - finally: - t.join(timeout=5) - - def _build_router(): routes = Routes() @@ -100,19 +37,15 @@ def create_book(_: RequestCtx) -> Response: class TestSentryRouterHandler: - def test_matched_route_uses_template_name(self, server_config, sentry_transport): + def test_matched_route_uses_template_name(self, serve_in_thread, sentry_transport): router = _build_router() - handler = sentry_router_handler(router) - resp = _serve( - server_config, - handler, - lambda port: httpx.get(f"http://127.0.0.1:{port}/books/42"), - ) + with serve_in_thread(sentry_router_handler(router)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/42", timeout=5) assert resp.status_code == 200 sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 tx = txs[0] assert tx["transaction"] == "GET /books/{id}" @@ -120,36 +53,28 @@ def test_matched_route_uses_template_name(self, server_config, sentry_transport) # Op lives under contexts.trace.op assert tx["contexts"]["trace"]["op"] == "http.server" - def test_unmatched_uses_url_source(self, server_config, sentry_transport): + def test_unmatched_uses_url_source(self, serve_in_thread, sentry_transport): router = _build_router() - handler = sentry_router_handler(router) - resp = _serve( - server_config, - handler, - lambda port: httpx.get(f"http://127.0.0.1:{port}/does-not-exist"), - ) + with serve_in_thread(sentry_router_handler(router)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/does-not-exist", timeout=5) assert resp.status_code == 404 sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 tx = txs[0] assert tx["transaction"] == "GET /does-not-exist" assert tx["transaction_info"]["source"] == "url" - def test_status_code_is_recorded(self, server_config, sentry_transport): + def test_status_code_is_recorded(self, serve_in_thread, sentry_transport): router = _build_router() - handler = sentry_router_handler(router) - _serve( - server_config, - handler, - lambda port: httpx.post(f"http://127.0.0.1:{port}/books"), - ) + with serve_in_thread(sentry_router_handler(router)) as port: + httpx.post(f"http://127.0.0.1:{port}/books", timeout=5) sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 tx = txs[0] # Sentry stores http status under contexts.trace.data["http.response.status_code"] @@ -158,17 +83,13 @@ def test_status_code_is_recorded(self, server_config, sentry_transport): status = trace_data.get("http.response.status_code") or tx.get("tags", {}).get("http.status_code") assert status in (201, "201") - def test_method_tag_recorded(self, server_config, sentry_transport): + def test_method_tag_recorded(self, serve_in_thread, sentry_transport): router = _build_router() - handler = sentry_router_handler(router) - _serve( - server_config, - handler, - lambda port: httpx.get(f"http://127.0.0.1:{port}/books/1"), - ) + with serve_in_thread(sentry_router_handler(router)) as port: + httpx.get(f"http://127.0.0.1:{port}/books/1", timeout=5) sentry_sdk.flush(timeout=2.0) - txs = _transactions(sentry_transport) + txs = transactions(sentry_transport) assert len(txs) == 1 assert txs[0]["tags"].get("http.method") == "GET" diff --git a/tests/http/server.py b/tests/http/server.py index 09e8bcb..c725444 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -1,115 +1,80 @@ """Tests for the HTTP server (localpost.http.server).""" +from __future__ import annotations + import socket -import threading import h11 import httpx import pytest -from localpost import threadtools -from localpost.http.config import ServerConfig -from localpost.http.server import HTTPReqCtx, start_http_server - - -@pytest.fixture(autouse=True) -def _disable_cancellation_check(monkeypatch): - """Server loop calls check_cancelled(), which requires AnyIO context. Disable it for sync tests.""" - monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) - - -@pytest.fixture -def server_config(): - return ServerConfig(host="127.0.0.1", port=0) # port=0 → auto-assign - +from localpost.http import HTTPReqCtx, ServerConfig, start_http_server -def _run_server_iterations(server, handler, n=10): - """Run the server loop for a fixed number of iterations.""" - for _ in range(n): - try: - server.run(handler) - except (OSError, ValueError): - return # Server socket/selector closed (context manager exited) - -# --- Tests --- +# --- Listening-socket lifecycle (no requests) --------------------------------- class TestStartHttpServer: - def test_creates_server_with_auto_port(self, server_config): - with start_http_server(server_config) as server: + def test_creates_server_with_auto_port(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0)) as server: assert server.port > 0 assert server.port != 8000 # auto-assigned, should differ from the default - def test_server_socket_is_listening(self, server_config): - with start_http_server(server_config) as server: - # Should be able to connect + def test_server_socket_is_listening(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0)) as server: with socket.create_connection(("127.0.0.1", server.port), timeout=2): pass - def test_server_socket_closed_after_context(self, server_config): - with start_http_server(server_config) as server: + def test_server_socket_closed_after_context(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0)) as server: port = server.port - # Socket should be closed, connection should fail with pytest.raises(ConnectionRefusedError): socket.create_connection(("127.0.0.1", port), timeout=1) +# --- Request / response basics ----------------------------------------------- + + class TestBasicRequestResponse: - def test_simple_200(self, server_config): + def test_simple_200(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete( h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"OK", ) - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - - t.join(timeout=5) + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 200 assert resp.text == "OK" - def test_404_response(self, server_config): + def test_404_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete( h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), b"Not Found", ) - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - - t.join(timeout=5) + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 404 assert resp.text == "Not Found" - def test_empty_body(self, server_config): + def test_empty_body(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=204, headers=[])) - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - - t.join(timeout=5) + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 204 assert resp.content == b"" class TestRequestRouting: - def test_handler_sees_method_and_target(self, server_config): + def test_handler_sees_method_and_target(self, serve_in_thread): captured = {} def handler(ctx: HTTPReqCtx): @@ -117,34 +82,28 @@ def handler(ctx: HTTPReqCtx): captured["target"] = ctx.request.target ctx.complete(h11.Response(status_code=200, headers=[]), b"") - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - httpx.post(f"http://127.0.0.1:{server.port}/api/items?q=1") - t.join(timeout=5) + with serve_in_thread(handler) as port: + httpx.post(f"http://127.0.0.1:{port}/api/items?q=1", timeout=5) assert captured["method"] == b"POST" assert captured["target"] == b"/api/items?q=1" - def test_handler_sees_headers(self, server_config): - captured_headers = {} + def test_handler_sees_headers(self, serve_in_thread): + captured_headers: dict[bytes, bytes] = {} def handler(ctx: HTTPReqCtx): for name, value in ctx.request.headers: captured_headers[name] = value ctx.complete(h11.Response(status_code=200, headers=[]), b"") - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - httpx.get(f"http://127.0.0.1:{server.port}/", headers={"X-Custom": "hello"}) - t.join(timeout=5) + with serve_in_thread(handler) as port: + httpx.get(f"http://127.0.0.1:{port}/", headers={"X-Custom": "hello"}, timeout=5) assert captured_headers[b"x-custom"] == b"hello" class TestRequestBody: - def test_receive_post_body(self, server_config): + def test_receive_post_body(self, serve_in_thread): received_body = bytearray() def handler(ctx: HTTPReqCtx): @@ -155,19 +114,14 @@ def handler(ctx: HTTPReqCtx): received_body.extend(chunk) ctx.complete(h11.Response(status_code=200, headers=[]), b"ok") - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler, 20)) - t.start() - - httpx.post(f"http://127.0.0.1:{server.port}/", content=b"hello world body") - - t.join(timeout=5) + with serve_in_thread(handler) as port: + httpx.post(f"http://127.0.0.1:{port}/", content=b"hello world body", timeout=5) assert bytes(received_body) == b"hello world body" class TestChunkedResponse: - def test_streaming_response(self, server_config): + def test_streaming_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.start_response( h11.Response( @@ -179,20 +133,15 @@ def handler(ctx: HTTPReqCtx): ctx.send(b"chunk2") ctx.finish_response() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - - t.join(timeout=5) + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 200 assert resp.content == b"chunk1chunk2" class TestBorrow: - def test_borrow_and_return(self, server_config): + def test_borrow_and_return(self, serve_in_thread): borrow_states = [] def handler(ctx: HTTPReqCtx): @@ -202,17 +151,14 @@ def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[]), b"borrowed") borrow_states.append(ctx.borrowed) # False — re-tracked after finish_response - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler, 15)) - t.start() - httpx.get(f"http://127.0.0.1:{server.port}/") - t.join(timeout=5) + with serve_in_thread(handler) as port: + httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert borrow_states == [False, True, False] class TestKeepAlive: - def test_multiple_requests_on_same_connection(self, server_config): + def test_multiple_requests_on_same_connection(self, serve_in_thread): call_count = 0 def handler(ctx: HTTPReqCtx): @@ -223,20 +169,14 @@ def handler(ctx: HTTPReqCtx): b"ok", ) - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler, 30)) - t.start() - - # httpx.Client reuses the TCP connection (keep-alive by default) - with httpx.Client(base_url=f"http://127.0.0.1:{server.port}") as client: + with serve_in_thread(handler) as port: + with httpx.Client(base_url=f"http://127.0.0.1:{port}", timeout=5) as client: client.get("/") client.get("/") - t.join(timeout=5) - assert call_count == 2 - def test_connection_close_header(self, server_config): + def test_connection_close_header(self, serve_in_thread): """When client sends Connection: close, server should close after one request.""" call_count = 0 @@ -248,12 +188,7 @@ def handler(ctx: HTTPReqCtx): b"ok", ) - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_server_iterations, args=(server, handler)) - t.start() - - httpx.get(f"http://127.0.0.1:{server.port}/", headers={"Connection": "close"}) - - t.join(timeout=5) + with serve_in_thread(handler) as port: + httpx.get(f"http://127.0.0.1:{port}/", headers={"Connection": "close"}, timeout=5) assert call_count == 1 diff --git a/tests/http/wsgi.py b/tests/http/wsgi.py index 329b171..a987d72 100644 --- a/tests/http/wsgi.py +++ b/tests/http/wsgi.py @@ -5,76 +5,47 @@ import threading import httpx -import pytest -from localpost import threadtools -from localpost.http import ServerConfig, start_http_server, wrap_wsgi - - -@pytest.fixture(autouse=True) -def _disable_cancellation_check(monkeypatch): - monkeypatch.setattr(threadtools, "check_cancelled", lambda: None) - - -@pytest.fixture -def server_config(): - return ServerConfig(host="127.0.0.1", port=0) - - -def _run_iterations(server, handler, n=20): - for _ in range(n): - try: - server.run(handler) - except (OSError, ValueError, AttributeError): - return +from localpost.http import wrap_wsgi class TestWrapWSGI: - def test_simple_200(self, server_config): + def test_simple_200(self, serve_in_thread): def app(environ, start_response): start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "5")]) return [b"hello"] - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - t.join(timeout=5) + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 200 assert resp.headers["content-type"] == "text/plain" assert resp.text == "hello" - def test_multi_chunk_response(self, server_config): + def test_multi_chunk_response(self, serve_in_thread): def app(environ, start_response): start_response("200 OK", [("Content-Type", "text/plain"), ("Transfer-Encoding", "chunked")]) return [b"foo", b"bar", b"baz"] - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - t.join(timeout=5) + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 200 assert resp.content == b"foobarbaz" - def test_404(self, server_config): + def test_404(self, serve_in_thread): def app(environ, start_response): body = b"nope" start_response("404 Not Found", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))]) return [body] - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/anything") - t.join(timeout=5) + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/anything", timeout=5) assert resp.status_code == 404 assert resp.text == "nope" - def test_environ_path_and_query(self, server_config): + def test_environ_path_and_query(self, serve_in_thread): seen = {} def app(environ, start_response): @@ -85,22 +56,20 @@ def app(environ, start_response): start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "2")]) return [b"ok"] - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) - t.start() + with serve_in_thread(wrap_wsgi(app)) as port: httpx.post( - f"http://127.0.0.1:{server.port}/api/items?q=1", + f"http://127.0.0.1:{port}/api/items?q=1", content=b"", headers={"Content-Type": "application/json"}, + timeout=5, ) - t.join(timeout=5) assert seen["method"] == "POST" assert seen["path"] == "/api/items" assert seen["query"] == "q=1" assert seen["content_type"] == "application/json" - def test_request_body_streaming(self, server_config): + def test_request_body_streaming(self, serve_in_thread): received = bytearray() def app(environ, start_response): @@ -113,17 +82,14 @@ def app(environ, start_response): start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(received)))]) return [bytes(received)] - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) - t.start() - resp = httpx.post(f"http://127.0.0.1:{server.port}/", content=b"hello wsgi body") - t.join(timeout=5) + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/", content=b"hello wsgi body", timeout=5) assert resp.status_code == 200 assert resp.content == b"hello wsgi body" assert bytes(received) == b"hello wsgi body" - def test_generator_close_called(self, server_config): + def test_generator_close_called(self, serve_in_thread): close_called = threading.Event() class Body: @@ -146,11 +112,8 @@ def app(environ, start_response): ) return Body() - with start_http_server(server_config) as server: - t = threading.Thread(target=_run_iterations, args=(server, wrap_wsgi(app))) - t.start() - resp = httpx.get(f"http://127.0.0.1:{server.port}/") - t.join(timeout=5) + with serve_in_thread(wrap_wsgi(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) assert resp.status_code == 200 assert resp.content == b"onetwo" From 4e9a8fea9b68b85bff47e7e1d16c75cdde758b3f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 20:59:59 +0400 Subject: [PATCH 075/286] test: pytest-timeout safety net (30s default) Hung selector / socket tests should fail in 30s, not block CI for the full pytest deadlock. Per-test override available via the @pytest.mark.timeout marker. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 ++ uv.lock | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e8a9237..80cdb2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ examples = [ tests = [ "pytest ~=9.0", "pytest-xdist", + "pytest-timeout ~=2.3", "setproctitle", # For pytest-xdist, to have descriptive process names ] tests-unit = [ @@ -243,6 +244,7 @@ build-backend.module-name = "localpost" [tool.pytest.ini_options] anyio_mode = "auto" #addopts = "-q -m 'not integration'" +timeout = 30 testpaths = [ "tests", ] diff --git a/uv.lock b/uv.lock index d6aeed2..0d6c56c 100644 --- a/uv.lock +++ b/uv.lock @@ -1141,6 +1141,7 @@ examples = [ ] tests = [ { name = "pytest" }, + { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "setproctitle" }, ] @@ -1218,6 +1219,7 @@ examples = [ ] tests = [ { name = "pytest", specifier = "~=9.0" }, + { name = "pytest-timeout", specifier = "~=2.3" }, { name = "pytest-xdist" }, { name = "setproctitle" }, ] @@ -1867,6 +1869,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" From 2af57e61778fa20685a92f25ceb37907b4d63837 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:06:46 +0400 Subject: [PATCH 076/286] test(http): pin server edge cases (protocol errors, expect-100, HEAD, pipelining) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add characterization tests covering paths the previous suite never touched. Several pin *current* behavior — the server has no try/except around handler invocation or h11 parsing (TODOs in HTTPConn.__call__), so malformed requests, handler exceptions, and incomplete bodies kill the loop. The new tests assert that via cm.expect_loop_error so the contract is visible and will fail loudly when the server is hardened. Tests added: - TestProtocolErrors: malformed request, handler raises (both crash today) - TestClientDisconnects: clean close, half-close after partial body (crashes today) - TestExpect100Continue: 100 Continue intermediate response, with borrow() - TestHeadAndPipelining: HEAD-aware handler, two pipelined requests on one conn To support crash-pinning, _ServerCM now captures uncaught loop exceptions in `loop_error`. By default the captured error re-raises on exit; tests opt in via `cm.expect_loop_error = True`. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/conftest.py | 17 +++- tests/http/server.py | 211 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index a4ffcaf..913f49f 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -34,7 +34,9 @@ class _ServerCM: """Returned by ``serve_in_thread(handler)``; use as a context manager. Yields the live port; on exit, signals the worker to stop, joins it, - and asserts no thread leak. + and asserts no thread leak. Uncaught exceptions from the server loop + are captured in ``loop_error`` and re-raised on exit unless the test + sets ``expect_loop_error = True`` (used to characterize crash paths). """ def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: @@ -43,6 +45,8 @@ def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: self._stop = threading.Event() self._thread: threading.Thread | None = None self._cm = start_http_server(config) + self.loop_error: BaseException | None = None + self.expect_loop_error: bool = False def __enter__(self) -> int: server = self._cm.__enter__() @@ -55,6 +59,9 @@ def loop() -> None: server.run(handler, timeout=0.05) except OSError: return # listening socket closed + except BaseException as e: # noqa: BLE001 + self.loop_error = e + return self._thread = threading.Thread(target=loop, daemon=True) self._thread.start() @@ -70,6 +77,14 @@ def __exit__(self, exc_type, exc, tb) -> None: finally: self._cm.__exit__(exc_type, exc, tb) + # Surface unexpected loop errors only if the test body succeeded — + # otherwise the original failure is more informative. + if exc_type is None: + if self.loop_error is not None and not self.expect_loop_error: + raise self.loop_error + if self.loop_error is None and self.expect_loop_error: + raise AssertionError("expected a server-loop error but none was raised") + @pytest.fixture def serve_in_thread(server_config: ServerConfig) -> Iterator[ServeInThread]: diff --git a/tests/http/server.py b/tests/http/server.py index c725444..8ccc756 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -2,7 +2,9 @@ from __future__ import annotations +import contextlib import socket +import threading import h11 import httpx @@ -11,6 +13,21 @@ from localpost.http import HTTPReqCtx, ServerConfig, start_http_server +def _drain(sock: socket.socket, deadline: float = 2.0) -> bytes: + """Read until the peer closes or the deadline elapses.""" + sock.settimeout(deadline) + out = bytearray() + while True: + try: + chunk = sock.recv(4096) + except (TimeoutError, socket.timeout): + break + if not chunk: + break + out.extend(chunk) + return bytes(out) + + # --- Listening-socket lifecycle (no requests) --------------------------------- @@ -192,3 +209,197 @@ def handler(ctx: HTTPReqCtx): httpx.get(f"http://127.0.0.1:{port}/", headers={"Connection": "close"}, timeout=5) assert call_count == 1 + + +# --- Edge cases --------------------------------------------------------------- + + +def _ok_handler(ctx: HTTPReqCtx) -> None: + body = b"ok" + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + body, + ) + + +class TestProtocolErrors: + """Server's reaction to malformed input / handler crashes. + + These pin *current* behavior — the server has no try/except around + handler invocation or h11 parsing (see TODOs in server.py:__call__), + so both paths kill the loop. When the server is hardened the assertions + here will need to flip from ``expect_loop_error=True`` to a 4xx/5xx check. + """ + + def test_malformed_request_kills_loop_today(self, serve_in_thread): + """A garbage request line propagates h11.RemoteProtocolError out of the loop.""" + cm = serve_in_thread(_ok_handler) + cm.expect_loop_error = True + + with cm as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"NOT_A_VALID_HTTP_REQUEST\r\n\r\n") + _drain(sock, deadline=1.0) + + assert isinstance(cm.loop_error, h11.RemoteProtocolError) + + def test_handler_exception_kills_loop_today(self, serve_in_thread): + """An unhandled handler exception propagates out of the loop.""" + + def boom(_: HTTPReqCtx) -> None: + raise RuntimeError("handler crashed") + + cm = serve_in_thread(boom) + cm.expect_loop_error = True + + with cm as port: + with contextlib.suppress(httpx.HTTPError): + httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert isinstance(cm.loop_error, RuntimeError) + assert str(cm.loop_error) == "handler crashed" + + +class TestClientDisconnects: + def test_client_closes_before_request_complete(self, serve_in_thread): + """Client opens the conn, sends nothing, closes — server keeps serving others.""" + with serve_in_thread(_ok_handler) as port: + sock = socket.create_connection(("127.0.0.1", port), timeout=2) + sock.close() + # Sanity: the next request still works. + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 200 + + def test_client_half_close_after_partial_body_kills_loop_today(self, serve_in_thread): + """Headers say Content-Length: 100, client sends 5 bytes then half-closes. + + h11 raises on incomplete body and the loop has no try/except around + the parser, so the server thread dies. Pin as expected behavior until + ``HTTPConn.__call__`` gains protocol-error handling (TODO at server.py). + """ + cm = serve_in_thread(_ok_handler) + cm.expect_loop_error = True + + with cm as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall( + b"POST / HTTP/1.1\r\n" + b"Host: x\r\n" + b"Content-Length: 100\r\n" + b"\r\n" + b"hello" + ) + sock.shutdown(socket.SHUT_WR) + _drain(sock, deadline=1.0) + + assert isinstance(cm.loop_error, h11.RemoteProtocolError) + + +class TestExpect100Continue: + def test_handler_reads_body_triggers_100_continue(self, serve_in_thread): + """When the handler calls receive() with Expect:100, the server emits 100 first. + + The handler must ``borrow()`` so the socket is in blocking mode while + it waits for the body — otherwise the non-blocking recv races the + client's post-100 send. + """ + captured = bytearray() + + def handler(ctx: HTTPReqCtx) -> None: + with ctx.borrow(): + while True: + chunk = ctx.receive() + if not chunk: + break + captured.extend(chunk) + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall( + b"POST / HTTP/1.1\r\n" + b"Host: x\r\n" + b"Content-Length: 5\r\n" + b"Expect: 100-continue\r\n" + b"\r\n" + ) + # Read the 100 Continue intermediate response. + sock.settimeout(2) + pre = b"" + while b"\r\n\r\n" not in pre: + chunk = sock.recv(4096) + assert chunk, "connection closed before 100 Continue" + pre += chunk + assert b"100 Continue" in pre, f"expected 100 Continue, got: {pre!r}" + + sock.sendall(b"hello") + final = _drain(sock, deadline=2.0) + + assert b"HTTP/1.1 200" in final + assert b"\r\n\r\nok" in final + assert bytes(captured) == b"hello" + + +class TestHeadAndPipelining: + def test_head_request(self, serve_in_thread): + """HEAD response carries headers but no body bytes. + + Note: a HEAD-aware handler must skip ``send(body)`` — h11 raises + ``Too much data for declared Content-Length`` if the server tries to + write a body for HEAD. This test pins that contract. + """ + body = b"this would be the body" + + def handler(ctx: HTTPReqCtx) -> None: + headers = [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode()), + ] + response = h11.Response(status_code=200, headers=headers) + if ctx.request.method == b"HEAD": + ctx.complete(response, None) + else: + ctx.complete(response, body) + + with serve_in_thread(handler) as port: + resp = httpx.head(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.headers["content-length"] == str(len(body)) + assert resp.content == b"" + + def test_pipelined_requests_on_one_connection(self, serve_in_thread): + """Two requests written in one TCP send are both served in order.""" + served: list[bytes] = [] + served_lock = threading.Lock() + + def handler(ctx: HTTPReqCtx) -> None: + with served_lock: + served.append(ctx.request.target) + body = b"resp-for-" + ctx.request.target + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + body, + ) + + with serve_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall( + b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n" + b"GET /b HTTP/1.1\r\nHost: x\r\n\r\n" + ) + # Read until both bodies have arrived. + sock.settimeout(2) + data = b"" + while b"resp-for-/a" not in data or b"resp-for-/b" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + + assert served == [b"/a", b"/b"] + assert b"resp-for-/a" in data + assert b"resp-for-/b" in data From c090da814cc3ab845acc6ab48696c21b8b5b7586 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:07:46 +0400 Subject: [PATCH 077/286] test(http): property-test URITemplate round-trip with hypothesis Five generative properties for URITemplate.parse / match: - round-trip: substituting values into a template and matching the resulting URI yields the original values - variable_names preserve declaration order - literal-only templates match exactly themselves - a path with extra trailing segments never matches - a value containing '/' never matches a single-var slot Adds hypothesis to the tests-unit dependency group. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 1 + tests/http/uri_template_props.py | 106 +++++++++++++++++++++++++++++++ uv.lock | 14 ++++ 3 files changed, 121 insertions(+) create mode 100644 tests/http/uri_template_props.py diff --git a/pyproject.toml b/pyproject.toml index 80cdb2b..af0eeb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,6 +149,7 @@ tests-unit = [ "pytest-mock ~=3.14", "pytest-cov ~=7.0", "coverage[toml] ~=7.6", + "hypothesis ~=6.100", ] tests-integration = [ "boto3 ~=1.38", diff --git a/tests/http/uri_template_props.py b/tests/http/uri_template_props.py new file mode 100644 index 0000000..c121b3a --- /dev/null +++ b/tests/http/uri_template_props.py @@ -0,0 +1,106 @@ +"""Property-based tests for ``URITemplate`` (RFC 6570 Level 1).""" + +from __future__ import annotations + +import re + +from hypothesis import assume, given +from hypothesis import strategies as st + +from localpost.http import URITemplate + +# --- Strategies --------------------------------------------------------------- + +# Path segment chars: visible ASCII minus '/' and '{' / '}' (which would +# accidentally re-introduce template syntax). +_segment_chars = st.characters( + min_codepoint=33, + max_codepoint=126, + blacklist_characters="/{}", +) + +_literal_segment = st.text(_segment_chars, min_size=1, max_size=8) +_variable_name = st.from_regex(r"\A[a-zA-Z_][a-zA-Z0-9_]{0,7}\Z") + + +@st.composite +def _template_with_values(draw) -> tuple[str, dict[str, str], str]: + """Build (template, values, concrete_uri) where values substitute cleanly.""" + n_segments = draw(st.integers(min_value=1, max_value=5)) + template_parts: list[str] = [] + uri_parts: list[str] = [] + values: dict[str, str] = {} + used_names: set[str] = set() + + for _ in range(n_segments): + kind = draw(st.sampled_from(["literal", "variable"])) + if kind == "literal": + seg = draw(_literal_segment) + template_parts.append(seg) + uri_parts.append(seg) + else: + name = draw(_variable_name.filter(lambda n: n not in used_names)) + used_names.add(name) + value = draw(st.text(_segment_chars, min_size=1, max_size=8)) + template_parts.append("{" + name + "}") + uri_parts.append(value) + values[name] = value + + template = "/" + "/".join(template_parts) + uri = "/" + "/".join(uri_parts) + return template, values, uri + + +# --- Properties --------------------------------------------------------------- + + +class TestURITemplateProperties: + @given(payload=_template_with_values()) + def test_round_trip(self, payload): + """match(uri_built_from_values) should yield the same values back.""" + template, values, uri = payload + t = URITemplate.parse(template) + result = t.match(uri) + assert result == values, f"template={template!r} uri={uri!r} expected={values!r} got={result!r}" + + @given(payload=_template_with_values()) + def test_variable_names_in_declaration_order(self, payload): + template, values, _ = payload + t = URITemplate.parse(template) + expected_order = tuple(re.findall(r"\{([^}]+)\}", template)) + assert t.variable_names == expected_order + # And every name we substituted is present in variable_names. + assert set(values).issubset(set(t.variable_names)) + + @given(template=_literal_segment.map(lambda s: f"/{s}")) + def test_literal_template_matches_self_only(self, template): + t = URITemplate.parse(template) + assert t.match(template) == {} + assert t.match(template + "/extra") is None + assert t.match(template[:-1]) is None # one char short + + @given(payload=_template_with_values(), extra=_literal_segment) + def test_extra_segments_do_not_match(self, payload, extra): + """A path that has more segments than the template should not match.""" + template, _, uri = payload + t = URITemplate.parse(template) + assert t.match(uri + "/" + extra) is None + + @given(payload=_template_with_values()) + def test_variable_does_not_span_slash(self, payload): + """A value with a '/' in it should never match a single-var slot.""" + template, values, _ = payload + # Pick the test only when there is at least one variable. + assume(values) + # Build a uri where one var value contains '/' — should fail to match. + bad_values = {**values, next(iter(values)): "x/y"} + bad_uri_parts = [] + for part in re.split(r"(\{[^}]+\})", template): + if part.startswith("{") and part.endswith("}"): + name = part[1:-1] + bad_uri_parts.append(bad_values[name]) + else: + bad_uri_parts.append(part) + bad_uri = "".join(bad_uri_parts) + t = URITemplate.parse(template) + assert t.match(bad_uri) is None diff --git a/uv.lock b/uv.lock index 0d6c56c..bb4e670 100644 --- a/uv.lock +++ b/uv.lock @@ -951,6 +951,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] +[[package]] +name = "hypothesis" +version = "6.152.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/7f/67a06c5f19368e0fa04612bbc446c535bf45b0b51bc6aa56055b112f7604/hypothesis-6.152.2.tar.gz", hash = "sha256:11fd5725958fe75597d1b831f703fdf7e636b7cf1f249117f381ad5cee4d888f", size = 466358, upload-time = "2026-04-24T04:26:18.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/ed/77eed094bceae845a994f936721293afae40a346c0005d85208407fd40e8/hypothesis-6.152.2-py3-none-any.whl", hash = "sha256:1ad5b87f0e6c0ab7a9a35b1378cc4963d23eaf0cb1e47e94f1d574b41155907a", size = 532034, upload-time = "2026-04-24T04:26:16.394Z" }, +] + [[package]] name = "icecream" version = "2.1.10" @@ -1151,6 +1163,7 @@ tests-integration = [ ] tests-unit = [ { name = "coverage" }, + { name = "hypothesis" }, { name = "pytest-cov" }, { name = "pytest-mock" }, ] @@ -1229,6 +1242,7 @@ tests-integration = [ ] tests-unit = [ { name = "coverage", extras = ["toml"], specifier = "~=7.6" }, + { name = "hypothesis", specifier = "~=6.100" }, { name = "pytest-cov", specifier = "~=7.0" }, { name = "pytest-mock", specifier = "~=3.14" }, ] From 3ac12a9acf1730e11615b66ac4ce919ff1711421 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:09:15 +0400 Subject: [PATCH 078/286] test(http): pin Router method/edge behaviors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests covering paths the existing router suite skipped: - TestMethodDispatch: - OPTIONS on a registered route returns 405 with the matched route's Allow header (no automatic OPTIONS handling) - Unknown method strings (e.g. "FAKEVERB") fall through to 405 - TestPathVariableEncoding: - %20-encoded values are passed through to path_args verbatim (not URL-decoded) — pins current behavior - Hyphens, dots, UUIDs match without escaping - Allow header sorted across five methods (GET, POST, PUT, PATCH, DELETE) - Duplicate-template guard in Router.from_routes — only reachable by constructing URITemplate instances directly with differing _regex, documents what the (otherwise dead) check protects against. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/router.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/http/router.py b/tests/http/router.py index 6842966..2a6e18d 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -6,6 +6,7 @@ from io import BytesIO import httpx +import pytest from localpost.http import ( RequestCtx, @@ -388,3 +389,99 @@ def test_allow_header_precomputed(self): # Methods are sorted for a stable header; both methods end up in the header. assert router.routes[0].allow_header in ("GET, POST", "POST, GET") assert set(router.routes[0].allow_header.split(", ")) == {"GET", "POST"} + + def test_allow_header_sorted_across_many_methods(self): + routes = Routes() + for m in ("PATCH", "GET", "DELETE", "POST", "PUT"): + routes.add(m, "/r", lambda ctx: Response(200, {}, [])) + router = routes.build() + # Allow header: methods sorted lexicographically by value. + assert router.routes[0].allow_header == "DELETE, GET, PATCH, POST, PUT" + + def test_duplicate_template_raises(self): + """Router.from_routes guards against hand-built paths dicts with duplicate templates. + + ``Routes.add`` dedupes by template string, and two ``URITemplate.parse`` results + compare equal (replacing the dict entry on insert), so the only way to hit this + branch is to construct two URITemplate instances directly that share a ``.template`` + but differ in another field. This pins the guard. + """ + import re + + t1 = URITemplate(template="/dup", variable_names=(), _regex=re.compile("^/dup$")) + t2 = URITemplate(template="/dup", variable_names=(), _regex=re.compile("^/dup\\Z")) + routes = Routes() + routes.paths[t1] = {HTTPMethod.GET: lambda ctx: Response(200, {}, [])} + routes.paths[t2] = {HTTPMethod.POST: lambda ctx: Response(200, {}, [])} + assert len(routes.paths) == 2 # different regex objects → different hash → two keys + + with pytest.raises(ValueError, match="duplicate template"): + routes.build() + + +# --- Method dispatch quirks -------------------------------------------------- + + +class TestMethodDispatch: + def test_options_on_registered_route_is_405(self): + """No automatic OPTIONS handling — unregistered method falls through to 405.""" + routes = Routes() + routes.add("GET", "/r", lambda ctx: Response(200, {}, [])) + router = routes.build() + + status, headers, body = _fake_wsgi(router, "OPTIONS", "/r") + assert status.startswith("405") + assert body == b"Method Not Allowed" + allow = {n.lower(): v for n, v in headers}["allow"] + assert allow == "GET" + + def test_unknown_method_string_is_405(self): + """A method name outside http.HTTPMethod (e.g., 'FAKEVERB') routes to 405.""" + routes = Routes() + routes.add("GET", "/r", lambda ctx: Response(200, {}, [])) + router = routes.build() + + status, _, body = _fake_wsgi(router, "FAKEVERB", "/r") + assert status.startswith("405") + assert body == b"Method Not Allowed" + + +# --- Path-variable encoding --------------------------------------------------- + + +class TestPathVariableEncoding: + def test_percent_encoded_value_is_not_decoded(self): + """URITemplate matches against the raw path; %20 stays literal in path_args.""" + routes = Routes() + captured: dict = {} + + @routes.get("/hello/{name}") + def hello(ctx: RequestCtx) -> Response: + captured["name"] = ctx.path_args["name"] + return Response(200, {}, [b"ok"]) + + assert hello + router = routes.build() + + status, _, _ = _fake_wsgi(router, "GET", "/hello/Bob%20Smith") + assert status.startswith("200") + assert captured["name"] == "Bob%20Smith" + + def test_value_with_dots_and_dashes_matches(self): + """Common id formats (uuid, dotted) match without escaping.""" + routes = Routes() + captured: dict = {} + + @routes.get("/files/{id}") + def get_file(ctx: RequestCtx) -> Response: + captured["id"] = ctx.path_args["id"] + return Response(200, {}, [b"ok"]) + + assert get_file + router = routes.build() + + for raw_id in ("a-b-c", "1.2.3", "550e8400-e29b-41d4-a716-446655440000"): + captured.clear() + status, _, _ = _fake_wsgi(router, "GET", f"/files/{raw_id}") + assert status.startswith("200") + assert captured["id"] == raw_id From 674e2da4cb303b37a1cf62597b4b4dd50bdce69a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:10:48 +0400 Subject: [PATCH 079/286] test(http): strengthen hosted http_server invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TestServiceRobustness suite alongside the existing service tests: - max_concurrency=N caps actual parallelism to N (peak in-flight observed under 5 concurrent requests) - handler exceptions tear down the service today (pin: exit_code != 0) — the missing try/except in HTTPConn.__call__ surfaces as a service crash, so test will flip to a 500 assertion when the server hardens - max_concurrency=1 service survives 5 sequential requests, indirectly confirming req_slots.release runs on the happy path Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/service.py | 95 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/http/service.py b/tests/http/service.py index 007ecaf..8cf80c8 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -227,6 +227,101 @@ async def test_invalid_max_concurrency(self): http_server(ServerConfig(), _handler_200(), max_concurrency=0) +class TestServiceRobustness: + async def test_max_concurrency_caps_parallelism(self, free_port): + """N+1 requests against max_concurrency=N: peak in-flight is exactly N.""" + in_flight = 0 + peak = 0 + lock = threading.Lock() + gate = threading.Event() + + def handler(ctx: HTTPReqCtx): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + gate.wait(timeout=5.0) + with lock: + in_flight -= 1 + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, handler, max_concurrency=3) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + async with anyio.create_task_group() as tg: + for _ in range(5): + tg.start_soon(_get, f"http://127.0.0.1:{free_port}/") + + # Once 3 handlers are in flight, the cap is observable. Wait for it + # before releasing the gate so we don't race the "still ramping up" state. + async def wait_for_peak() -> None: + while True: + with lock: + if peak >= 3: + return + await anyio.sleep(0.02) + + with anyio.fail_after(5.0): + await wait_for_peak() + gate.set() + + assert peak == 3, f"expected peak 3, got {peak}" + + lt.shutdown() + await lt.stopped + + async def test_handler_exception_crashes_service_today(self, free_port): + """Pin: a handler exception escapes ``handle_request`` and bubbles to the task group. + + The TODO at server.py's ``HTTPConn.__call__`` (no try/except around + ``h(req_ctx)``) means a single bad handler tears down the whole service. + Test pins the failure mode — exit_code != 0. When the server gains + per-request error handling, this test should flip to assert a 500. + """ + + def boom(_: HTTPReqCtx) -> None: + raise RuntimeError("handler crashed") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, boom, max_concurrency=2) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + with contextlib.suppress(Exception): + await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + + await lt.stopped + + assert lt.exit_code != 0 + + async def test_slot_released_after_normal_request(self, free_port): + """Repeatedly hitting a max_concurrency=1 service must keep working. + + Indirectly confirms ``req_slots.release()`` runs on the success path — + if it didn't, the second request would block forever on the semaphore. + """ + + def handler(ctx: HTTPReqCtx): + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, handler, max_concurrency=1) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(5): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + + lt.shutdown() + await lt.stopped + + class TestDispatchLoad: async def test_many_requests_served_from_worker_threads(self, free_port): cfg = ServerConfig(host="127.0.0.1", port=free_port) From ba60356e5e2dce12b2b54abed45a95099018a83c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:14:57 +0400 Subject: [PATCH 080/286] test(http): expand integration test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inlined subprocess script from tests/http/integration.py into tests/http/_integration_app.py — gains real tracebacks and modes for router / wsgi / flask handlers selected via LP_TEST_MODE. Tests added: - TestRouterIntegration parameterized over asyncio + trio backends (LP_TEST_BACKEND), exercising the full hosting + http stack on both. - test_clean_shutdown_via_signal parameterized over SIGTERM and SIGINT. - test_inflight_request_during_shutdown_today: pins current behavior where SIGTERM during a non-cancellation-aware time.sleep handler causes a non-zero exit. Will flip to rc == 0 when graceful drain lands. - test_alternate_handler_modes_serve: smoke run for wsgi_server and flask_server under run_app to make sure those code paths boot end-to-end (only http_server was covered before). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/_integration_app.py | 98 ++++++++++++++++++ tests/http/integration.py | 176 ++++++++++++++++++++------------- 2 files changed, 206 insertions(+), 68 deletions(-) create mode 100644 tests/http/_integration_app.py diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py new file mode 100644 index 0000000..887d932 --- /dev/null +++ b/tests/http/_integration_app.py @@ -0,0 +1,98 @@ +"""Subprocess entry point for integration tests. + +Runs a hosted localpost HTTP server (Router-based by default; switchable +to wsgi_server / flask_server via env vars) on the port given by +``LP_TEST_PORT`` and the anyio backend given by ``LP_TEST_BACKEND`` +(``asyncio`` or ``trio``). +""" + +from __future__ import annotations + +import logging +import os +import sys +import threading +import time + +import anyio + + +def _build_handler(): + mode = os.environ.get("LP_TEST_MODE", "router") + if mode == "router": + from localpost.http import RequestCtx, Response, Routes + + def _ping(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"pong"]) + + def _slow(_: RequestCtx) -> Response: + time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) + body = str(threading.get_ident()).encode() + return Response(200, {"content-type": "text/plain"}, [body]) + + def _hello(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [f"hi {ctx.path_args['name']}".encode()]) + + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/slow")(_slow) + routes.get("/hello/{name}")(_hello) + return routes.build().as_handler() + + if mode == "wsgi": + from localpost.http import wrap_wsgi + + def wsgi_app(environ, start_response): + path = environ.get("PATH_INFO", "/") + body = f"wsgi:{path}".encode() + start_response( + "200 OK", + [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))], + ) + return [body] + + return wrap_wsgi(wsgi_app) + + if mode == "flask": + from flask import Flask + + from localpost.http.flask import flask_handler + + flask_app = Flask(__name__) + + @flask_app.route("/ping") + def ping(): + return "pong-flask" + + @flask_app.route("/hello/") + def hello(name: str): + return f"hi {name} (flask)" + + assert ping + assert hello + return flask_handler(flask_app) + + raise SystemExit(f"unknown LP_TEST_MODE: {mode!r}") + + +def _main() -> int: + logging.basicConfig(level=logging.INFO) + + from localpost.hosting import run + + # Honor LP_TEST_BACKEND so tests can pin asyncio vs trio. + backend = os.environ.get("LP_TEST_BACKEND", "asyncio") + port = int(os.environ["LP_TEST_PORT"]) + handler = _build_handler() + + from localpost.hosting.middleware import shutdown_on_signal + from localpost.http import ServerConfig, http_server + + cfg = ServerConfig(host="127.0.0.1", port=port) + svc = shutdown_on_signal()(http_server(cfg, handler, max_concurrency=8)) + + return anyio.run(run, svc, None, backend=backend) + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/http/integration.py b/tests/http/integration.py index cdbd831..df5968b 100644 --- a/tests/http/integration.py +++ b/tests/http/integration.py @@ -1,8 +1,9 @@ -"""End-to-end integration test for the HTTP flow. +"""End-to-end integration tests for the HTTP flow. -Boots the full ``run_app`` + ``http_server`` + ``Router`` stack in a subprocess, -fires concurrent HTTP requests, confirms requests are served from multiple worker -threads, and then verifies a clean shutdown via SIGTERM. +Boots the full ``run`` + ``http_server`` stack in a subprocess (see +``_integration_app.py``), fires HTTP requests, and verifies clean shutdown +via SIGTERM and SIGINT, on both anyio backends, and across the router / +WSGI / Flask handlers. """ from __future__ import annotations @@ -13,8 +14,8 @@ import socket import subprocess import sys -import textwrap import time +from collections.abc import Iterator import httpx import pytest @@ -22,52 +23,6 @@ pytestmark = pytest.mark.integration -_APP_SCRIPT = textwrap.dedent( - """ - import logging - import os - import sys - import threading - import time - - from localpost.hosting import run_app - from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server - - - logging.basicConfig(level=logging.INFO) - - - def _root(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"pong"]) - - - def _slow(_: RequestCtx) -> Response: - time.sleep(0.2) - body = str(threading.get_ident()).encode() - return Response(200, {"content-type": "text/plain"}, [body]) - - - def _hello(ctx: RequestCtx) -> Response: - return Response( - 200, - {"content-type": "text/plain"}, - [f"hi {ctx.path_args['name']}".encode()], - ) - - - routes = Routes() - routes.get("/ping")(_root) - routes.get("/slow")(_slow) - routes.get("/hello/{name}")(_hello) - router = routes.build() - - port = int(os.environ["LP_TEST_PORT"]) - cfg = ServerConfig(host="127.0.0.1", port=port) - sys.exit(run_app(http_server(cfg, router.as_handler(), max_concurrency=8))) - """ -) - - def _pick_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) @@ -85,23 +40,35 @@ def _wait_ready(port: int, deadline: float = 10.0) -> bool: return False -@pytest.fixture -def app_process(): - port = _pick_free_port() - env = {**os.environ, "LP_TEST_PORT": str(port)} - proc = subprocess.Popen( # noqa: S603 - [sys.executable, "-u", "-c", _APP_SCRIPT], +def _spawn(port: int, *, backend: str = "asyncio", mode: str = "router", **extra_env: str) -> subprocess.Popen: + env = { + **os.environ, + "LP_TEST_PORT": str(port), + "LP_TEST_BACKEND": backend, + "LP_TEST_MODE": mode, + **extra_env, + } + return subprocess.Popen( # noqa: S603 + [sys.executable, "-u", "-m", "tests.http._integration_app"], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + + +@pytest.fixture +def app_process(request) -> Iterator[tuple[int, subprocess.Popen]]: + """Start the integration app; parameterize via indirect request.param dict.""" + params: dict[str, str] = getattr(request, "param", {}) or {} + port = _pick_free_port() + proc = _spawn(port, **params) try: if not _wait_ready(port): stdout, stderr = b"", b"" if proc.poll() is not None: stdout, stderr = proc.communicate(timeout=1) pytest.fail( - f"Server did not become ready on port {port}.\n" + f"Server did not become ready on port {port} (params={params}).\n" f"stdout: {stdout!r}\nstderr: {stderr!r}" ) yield port, proc @@ -115,7 +82,11 @@ def app_process(): proc.wait() -class TestHttpIntegration: +# --- Router-handler integration (asyncio + trio) ------------------------------ + + +@pytest.mark.parametrize("app_process", [{"backend": "asyncio"}, {"backend": "trio"}], indirect=True) +class TestRouterIntegration: def test_simple_get(self, app_process): port, _ = app_process r = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2) @@ -137,23 +108,92 @@ def test_concurrent_requests_span_multiple_threads(self, app_process): port, _ = app_process n = 8 with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex: - futures = [ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) for _ in range(n)] + futures = [ + ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) for _ in range(n) + ] responses = [f.result() for f in futures] assert all(r.status_code == 200 for r in responses) thread_ids = {r.text for r in responses} assert len(thread_ids) >= 2, f"expected multiple worker threads, saw: {thread_ids}" - def test_clean_shutdown_via_sigterm(self, app_process): - port, proc = app_process - # Sanity check +# --- Shutdown signaling ------------------------------------------------------- + + +@pytest.mark.parametrize("sig", [signal.SIGTERM, signal.SIGINT]) +def test_clean_shutdown_via_signal(sig): + port = _pick_free_port() + proc = _spawn(port) + try: + assert _wait_ready(port), "server did not start" assert httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2).status_code == 200 - proc.send_signal(signal.SIGTERM) + proc.send_signal(sig) rc = proc.wait(timeout=5) - assert rc == 0, f"process exited with code {rc}" + assert rc == 0, f"process exited with code {rc} on {sig.name}" + + # Port should be free now. + with pytest.raises(ConnectionRefusedError): + socket.create_connection(("127.0.0.1", port), timeout=1) + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +def test_inflight_request_during_shutdown_today(): + """SIGTERM while the slow handler is sleeping. + + Pin: today the worker thread is still in ``time.sleep`` when shutdown + cancels the task group, and the eventual write/close races with the + closed listener. The service exits non-zero. When in-flight handling + is hardened (graceful drain), this test should flip to ``rc == 0``. + """ + port = _pick_free_port() + proc = _spawn(port, LP_TEST_SLOW_S="1.0") + stderr = b"" + try: + assert _wait_ready(port), "server did not start" + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + future = ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) + time.sleep(0.2) # request is in flight + proc.send_signal(signal.SIGTERM) + try: + _, stderr = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + _, stderr = proc.communicate() + raise + rc = proc.returncode - # Port should be free now — expect ConnectionRefusedError once the server is down. - with pytest.raises(ConnectionRefusedError), socket.create_connection(("127.0.0.1", port), timeout=1): - pass + try: + future.result(timeout=5) + except (httpx.HTTPError, OSError): + pass + + # Process must terminate within the deadline (no hangs). Exit code + # is currently non-zero — see docstring. + assert rc != 0, f"unexpected clean exit, stderr={stderr.decode()!r}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +# --- WSGI + Flask end-to-end smoke tests -------------------------------------- + + +@pytest.mark.parametrize( + "app_process", + [{"mode": "wsgi"}, {"mode": "flask"}], + indirect=True, + ids=["wsgi", "flask"], +) +def test_alternate_handler_modes_serve(app_process): + port, _ = app_process + r = httpx.get(f"http://127.0.0.1:{port}/ping", timeout=2) + assert r.status_code == 200 + # Both modes return non-empty bodies; exact text differs by mode. + assert r.text From 38da1df9cd8bf7cea550d090e170109426fb9e38 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:40:36 +0400 Subject: [PATCH 081/286] refactor(http): bind handler at server construction, add select_timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler was passed into Server.run() on every iteration, even though every caller uses the same handler for the server's lifetime. Move it to start_http_server(config, handler) where it's stored on the Server instance; Server.run() no longer takes the handler. Also split the overloaded rw_timeout into two distinct config fields: - ServerConfig.rw_timeout — borrowed-socket I/O timeout (kept) - ServerConfig.select_timeout — selector.select bound per Server.run iteration (new; was conflated with rw_timeout) Server.run(timeout=…) still allows per-iteration overrides for tests that want a tight loop. Updated callers: localpost.http._service, the HTTP test fixture, the direct lifecycle tests in tests/http/server.py, the simple_server example, and the README quick-start. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/simple_server.py | 4 ++-- localpost/http/README.md | 16 +++++++++------- localpost/http/_service.py | 4 ++-- localpost/http/config.py | 4 ++++ localpost/http/server.py | 18 +++++++++++++----- tests/http/conftest.py | 5 ++--- tests/http/server.py | 14 +++++++++++--- 7 files changed, 43 insertions(+), 22 deletions(-) diff --git a/examples/http/simple_server.py b/examples/http/simple_server.py index 50eab38..8c25a61 100644 --- a/examples/http/simple_server.py +++ b/examples/http/simple_server.py @@ -10,9 +10,9 @@ def _main(): def simple_app(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"Hello, World!\n") - with start_http_server(ServerConfig()) as server: + with start_http_server(ServerConfig(), simple_app) as server: while True: - server.run(simple_app) + server.run() if __name__ == "__main__": diff --git a/localpost/http/README.md b/localpost/http/README.md index 11bedfa..a7a6460 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -31,9 +31,9 @@ def simple_app(ctx: HTTPReqCtx): ) -with start_http_server(ServerConfig()) as server: +with start_http_server(ServerConfig(), simple_app) as server: while True: - server.run(simple_app) + server.run() ``` See [`examples/http/simple_server.py`](../../examples/http/simple_server.py), @@ -51,14 +51,16 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) ## Key concepts -- **`ServerConfig`** — host, port, backlog, `keep_alive_timeout`, `max_body_size`. -- **`start_http_server(config)`** — context manager; yields a `Server` bound to - a non-blocking listening socket with a `selectors` poller. +- **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, + `keep_alive_timeout`, `max_body_size`. +- **`start_http_server(config, handler)`** — context manager; yields a `Server` + bound to a non-blocking listening socket with a `selectors` poller. The + handler is fixed for the server's lifetime. - **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, the raw socket, headers, and `complete(response, body)`. Request bodies are streamed via `receive(n_bytes)`. - **`RequestHandler = Callable[[HTTPReqCtx], None]`** — the handler - interface. The server calls `server.run(handler)` once per accepted request. + interface. `Server.run()` dispatches each accepted request to it. - **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style variables, matched with a generated regex). `match(uri) → dict | None`. - **`Routes`** — mutable builder. Accumulate routes via decorators @@ -76,7 +78,7 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) | Symbol | Notes | | ------------------------- | ------------------------------------------ | -| `start_http_server(cfg)` | Context manager yielding a `Server` | +| `start_http_server(cfg, handler)` | Context manager yielding a `Server` bound to ``handler`` | | `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`) | | `RequestHandler` | `Callable[[HTTPReqCtx], None]` | diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 90665b3..10ec0e6 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -60,11 +60,11 @@ def dispatch(ctx: HTTPReqCtx) -> None: raise def run_server() -> None: - with start_http_server(config) as server: + with start_http_server(config, dispatch) as server: lt.set_started() while not lt.shutting_down.is_set(): from_thread.check_cancelled() - server.run(dispatch) + server.run() return to_thread.run_sync(run_server, limiter=CapacityLimiter(1)) diff --git a/localpost/http/config.py b/localpost/http/config.py index f730f52..eb1d52a 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -24,6 +24,10 @@ class ServerConfig: rw_timeout: float = 1.0 """Timeout (seconds) for receive/send operations on a borrowed client connection, and for the keep-alive read deadline extended after each chunk arrives.""" + select_timeout: float = 1.0 + """Default upper bound (seconds) on ``selector.select`` per ``Server.run`` iteration. + Caps how long the loop blocks before returning to the caller for a cancellation / + shutdown check. Callers may override per-iteration via ``Server.run(timeout=…)``.""" keep_alive_timeout: float = 15.0 # TODO add it to the response """Timeout (seconds) for idle connections.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB diff --git a/localpost/http/server.py b/localpost/http/server.py index 8094de5..30ebb5e 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -26,7 +26,12 @@ @contextmanager -def start_http_server(config: ServerConfig) -> Iterator[Server]: +def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Iterator[Server]: + """Open a listening socket and yield a ``Server`` bound to ``handler``. + + The handler is fixed for the lifetime of the server — every accepted request + is dispatched to it. Per-iteration overrides are not supported. + """ logger = logging.getLogger(LOGGER_NAME) server_sock = socket.create_server( (config.host, config.port), @@ -40,7 +45,7 @@ def start_http_server(config: ServerConfig) -> Iterator[Server]: # server_sock.close() # Safe to call it from another thread, will cause accept() to raise OSError with closing(server_sock), closing(selector): - server = Server(config, logger, server_sock, selector) + server = Server(config, handler, logger, server_sock, selector) # logger.info(f"Serving on {config.host}:{server.port}") logger.info("Serving on %s:%d", config.host, server.port) yield server @@ -57,6 +62,7 @@ class Server: def __init__( self, config: ServerConfig, + handler: RequestHandler, logger: logging.Logger, server_sock: socket.socket, selector: selectors.BaseSelector, @@ -70,6 +76,7 @@ def __init__( """ self.selector = selector self.config = config + self.handler = handler self.logger = logger self._lock = threading.Lock() @@ -100,16 +107,17 @@ def stop_tracking(self, conn: HTTPConn) -> None: sock.settimeout(self.config.rw_timeout) conn.tracked = False - def run(self, h: RequestHandler, /, *, timeout: float | None = None) -> None: + def run(self, *, timeout: float | None = None) -> None: """One iteration of the server loop. Should be called repeatedly until the server is stopped. ``timeout`` bounds the underlying ``selector.select`` call — it caps how long this method blocks before returning to the caller, giving the caller a chance to check - for shutdown / cancellation. Defaults to ``config.rw_timeout``. + for shutdown / cancellation. Defaults to ``config.select_timeout``. """ if timeout is None: - timeout = self.config.rw_timeout + timeout = self.config.select_timeout server_sock = self.sock + h = self.handler self._cleanup_stale() # TODO Take iteration payload (pending connections) and set it empty, under the lock # TODO Add selector.select() to the current payload (chain) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 913f49f..b09daff 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -44,19 +44,18 @@ def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: self._handler = handler self._stop = threading.Event() self._thread: threading.Thread | None = None - self._cm = start_http_server(config) + self._cm = start_http_server(config, handler) self.loop_error: BaseException | None = None self.expect_loop_error: bool = False def __enter__(self) -> int: server = self._cm.__enter__() - handler = self._handler stop = self._stop def loop() -> None: while not stop.is_set(): try: - server.run(handler, timeout=0.05) + server.run(timeout=0.05) except OSError: return # listening socket closed except BaseException as e: # noqa: BLE001 diff --git a/tests/http/server.py b/tests/http/server.py index 8ccc756..885ccb4 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -31,23 +31,31 @@ def _drain(sock: socket.socket, deadline: float = 2.0) -> bytes: # --- Listening-socket lifecycle (no requests) --------------------------------- +def _noop_handler(ctx: HTTPReqCtx) -> None: + pass + + class TestStartHttpServer: def test_creates_server_with_auto_port(self): - with start_http_server(ServerConfig(host="127.0.0.1", port=0)) as server: + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: assert server.port > 0 assert server.port != 8000 # auto-assigned, should differ from the default def test_server_socket_is_listening(self): - with start_http_server(ServerConfig(host="127.0.0.1", port=0)) as server: + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: with socket.create_connection(("127.0.0.1", server.port), timeout=2): pass def test_server_socket_closed_after_context(self): - with start_http_server(ServerConfig(host="127.0.0.1", port=0)) as server: + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: port = server.port with pytest.raises(ConnectionRefusedError): socket.create_connection(("127.0.0.1", port), timeout=1) + def test_handler_stored_on_server(self): + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + assert server.handler is _noop_handler + # --- Request / response basics ----------------------------------------------- From 27c9184d6dbe1289a03bbb774cfe25c793ea155a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:50:33 +0400 Subject: [PATCH 082/286] feat(http): catch handler exceptions, return 500 instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler raise no longer kills the connection loop or the hosted service. New ``emit_handler_error`` helper: - If the response hasn't started, sends a 500 Internal Server Error (text/plain, Connection: close) so the client gets a real status. - If the response is mid-stream, closes the connection cleanly — bytes are already on the wire and the status line can't be rewound. - Swallows secondary I/O failures to avoid amplifying one error into another. HTTPConn.__call__ wraps the handler call with this; the hosted http_server's handle_request mirrors the same pattern around to_thread.run_sync(handler, …) so user-handler exceptions in the threaded path also turn into 500s. Tests flipped: - test_handler_exception_kills_loop_today → test_handler_exception_returns_500 - test_handler_exception_crashes_service_today → test_handler_exception_returns_500_and_service_stays_up New: test_handler_exception_after_start_response_closes_conn pins the mid-stream-crash recovery path. Resolves the TODO at HTTPConn.__call__ ("Handle exceptions..."). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_service.py | 13 ++++++++++--- localpost/http/server.py | 40 ++++++++++++++++++++++++++++++++++---- tests/http/server.py | 38 +++++++++++++++++++++++++++--------- tests/http/service.py | 18 ++++++++--------- 4 files changed, 84 insertions(+), 25 deletions(-) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 10ec0e6..8502ee6 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Awaitable from contextlib import ExitStack from wsgiref.types import WSGIApplication @@ -8,8 +9,8 @@ from localpost import hosting, threadtools from localpost.hosting import ServiceLifetime -from localpost.http.config import ServerConfig -from localpost.http.server import HTTPReqCtx, RequestHandler, start_http_server +from localpost.http.config import LOGGER_NAME, ServerConfig +from localpost.http.server import HTTPReqCtx, RequestHandler, emit_handler_error, start_http_server from localpost.http.wsgi import wrap_wsgi __all__ = ["http_server", "wsgi_server"] @@ -37,13 +38,19 @@ def http_server( if max_concurrency < 1: raise ValueError("max_concurrency must be >= 1") + logger = logging.getLogger(LOGGER_NAME) + def run(lt: ServiceLifetime) -> Awaitable[None]: req_slots = threadtools.cancellable_semaphore(max_concurrency) handler_limiter = CapacityLimiter(max_concurrency) async def handle_request(ctx: HTTPReqCtx, borrow_stack: ExitStack) -> None: try: - await to_thread.run_sync(handler, ctx, limiter=handler_limiter) + try: + await to_thread.run_sync(handler, ctx, limiter=handler_limiter) + except Exception: + logger.exception("Handler raised for %s %r", ctx.request.method, ctx.request.target) + await to_thread.run_sync(emit_handler_error, ctx, limiter=handler_limiter) finally: borrow_stack.close() req_slots.release() diff --git a/localpost/http/server.py b/localpost/http/server.py index 30ebb5e..efc95bf 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -25,6 +25,35 @@ __all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] +_INTERNAL_ERROR_BODY = b"Internal Server Error" +_INTERNAL_ERROR_RESPONSE = h11.Response( + status_code=500, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_INTERNAL_ERROR_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + + +def emit_handler_error(ctx: HTTPReqCtx) -> None: + """Best-effort recovery when a request handler raises. + + Emits a 500 response if no headers have been sent yet; otherwise closes + the connection (we can't go back and prepend a status line to bytes + already on the wire). All I/O failures are swallowed — the goal is to + avoid amplifying one error into another. + """ + logger = logging.getLogger(LOGGER_NAME) + if ctx.response_status is None: + try: + ctx.complete(_INTERNAL_ERROR_RESPONSE, _INTERNAL_ERROR_BODY) + return + except Exception: + logger.exception("Failed to send 500 after handler error; closing") + ctx._conn.close() + + @contextmanager def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Iterator[Server]: """Open a listening socket and yield a ``Server`` bound to ``handler``. @@ -170,9 +199,6 @@ def __call__(self, h: RequestHandler) -> None: # TODO Handler LocalProtocolError, it will send respo automatically - # TODO Check state: if our_state is not h11.DONE, then send 500 Internal Server Error and give back - # (a handler should always finish with a response) - while self.tracked: if parser.our_state is h11.MUST_CLOSE: self.close() # TODO Proper half close, later @@ -198,9 +224,15 @@ def __call__(self, h: RequestHandler) -> None: continue # Drain the request body elif isinstance(event, h11.Request): req_ctx = HTTPReqCtx(self.server, self, event) - h(req_ctx) + try: + h(req_ctx) + except Exception: + self.server.logger.exception("Handler raised for %s %r", event.method, event.target) + emit_handler_error(req_ctx) if req_ctx.borrowed: return + if not self.tracked: + return # connection was closed during error recovery elif isinstance(event, h11.ConnectionClosed): self.server.logger.debug("Client closed connection") self.close() diff --git a/tests/http/server.py b/tests/http/server.py index 885ccb4..7248ec1 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -251,21 +251,41 @@ def test_malformed_request_kills_loop_today(self, serve_in_thread): assert isinstance(cm.loop_error, h11.RemoteProtocolError) - def test_handler_exception_kills_loop_today(self, serve_in_thread): - """An unhandled handler exception propagates out of the loop.""" + def test_handler_exception_returns_500(self, serve_in_thread): + """An unhandled handler exception is caught and returned as 500.""" def boom(_: HTTPReqCtx) -> None: raise RuntimeError("handler crashed") - cm = serve_in_thread(boom) - cm.expect_loop_error = True + with serve_in_thread(boom) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) - with cm as port: - with contextlib.suppress(httpx.HTTPError): - httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 500 + assert resp.text == "Internal Server Error" + + def test_handler_exception_after_start_response_closes_conn(self, serve_in_thread): + """If the handler crashes after start_response, the conn is closed cleanly.""" + + def boom(ctx: HTTPReqCtx) -> None: + ctx.start_response( + h11.Response( + status_code=200, + headers=[(b"transfer-encoding", b"chunked")], + ) + ) + ctx.send(b"first chunk") + raise RuntimeError("crashed mid-stream") - assert isinstance(cm.loop_error, RuntimeError) - assert str(cm.loop_error) == "handler crashed" + with serve_in_thread(boom) as port: + with contextlib.suppress(httpx.HTTPError): + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + # Response started with 200, but body is truncated when conn closes. + assert resp.status_code == 200 + + # Server still serves a follow-up request — the loop survived. + with serve_in_thread(_ok_handler) as second_port: + resp2 = httpx.get(f"http://127.0.0.1:{second_port}/", timeout=2) + assert resp2.status_code == 200 class TestClientDisconnects: diff --git a/tests/http/service.py b/tests/http/service.py index 8cf80c8..e287b97 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -273,13 +273,10 @@ async def wait_for_peak() -> None: lt.shutdown() await lt.stopped - async def test_handler_exception_crashes_service_today(self, free_port): - """Pin: a handler exception escapes ``handle_request`` and bubbles to the task group. + async def test_handler_exception_returns_500_and_service_stays_up(self, free_port): + """A handler exception is caught at the connection level and returned as 500. - The TODO at server.py's ``HTTPConn.__call__`` (no try/except around - ``h(req_ctx)``) means a single bad handler tears down the whole service. - Test pins the failure mode — exit_code != 0. When the server gains - per-request error handling, this test should flip to assert a 500. + The service must remain healthy and serve subsequent requests. """ def boom(_: HTTPReqCtx) -> None: @@ -291,12 +288,15 @@ def boom(_: HTTPReqCtx) -> None: await lt.started await _wait_server_ready(free_port) - with contextlib.suppress(Exception): - await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + r1 = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + assert r1.status_code == 500 + r2 = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + assert r2.status_code == 500 # service still serving + lt.shutdown() await lt.stopped - assert lt.exit_code != 0 + assert lt.exit_code == 0 async def test_slot_released_after_normal_request(self, free_port): """Repeatedly hitting a max_concurrency=1 service must keep working. From 6189c2d94a8c79a3ae38a3369fcf4f8501a777c5 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 21:54:14 +0400 Subject: [PATCH 083/286] feat(http): handle h11 protocol errors, isolate per-connection failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap HTTPConn.__call__ around try/except for h11.RemoteProtocolError and h11.LocalProtocolError. On RemoteProtocolError (malformed input, incomplete body, peer truncation): log a warning, best-effort emit a 400 Bad Request if the parser is still in IDLE/SEND_RESPONSE state, then close. On LocalProtocolError: log + best-effort 500 + close. Belt-and-braces in Server.run: wrap conn(h) in a last-resort try/except so any unhandled exception inside connection handling takes down only that connection, never the accept loop. Tests flipped to current-correct behavior: - test_malformed_request_kills_loop_today → test_malformed_request_returns_400_and_keeps_loop_alive - test_client_half_close_after_partial_body_kills_loop_today → test_client_half_close_after_partial_body_keeps_loop_alive Both new tests assert that a follow-up valid request still serves, proving the loop survived. Resolves the TODOs at HTTPConn.__call__ ("Handle LocalProtocolError", "h11.RemoteProtocolError: peer closed connection without sending complete message body"). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server.py | 54 +++++++++++++++++++++++++++++++++++----- tests/http/server.py | 43 ++++++++++++++------------------ 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index efc95bf..4ddfd45 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -35,6 +35,16 @@ ], ) +_BAD_REQUEST_BODY = b"Bad Request" +_BAD_REQUEST_RESPONSE = h11.Response( + status_code=400, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_BAD_REQUEST_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + def emit_handler_error(ctx: HTTPReqCtx) -> None: """Best-effort recovery when a request handler raises. @@ -48,9 +58,10 @@ def emit_handler_error(ctx: HTTPReqCtx) -> None: if ctx.response_status is None: try: ctx.complete(_INTERNAL_ERROR_RESPONSE, _INTERNAL_ERROR_BODY) - return except Exception: logger.exception("Failed to send 500 after handler error; closing") + else: + return ctx._conn.close() @@ -156,7 +167,14 @@ def run(self, *, timeout: float | None = None) -> None: conn = HTTPConn(self, client_sock, client_addr) self.track(conn) elif (conn := key.data) and isinstance(conn, HTTPConn): - conn(h) # TODO Handle exceptions... + try: + conn(h) + except Exception: + self.logger.exception("Unhandled exception from connection %s", conn.addr) + try: + conn.close() + except Exception: # noqa: BLE001, S110 + pass else: raise RuntimeError(f"Unexpected selector key: {key!r}") @@ -195,14 +213,23 @@ def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11. total_sent = total_sent + sent def __call__(self, h: RequestHandler) -> None: + try: + self._loop(h) + except h11.RemoteProtocolError as e: + self.server.logger.warning("Bad client input from %s: %s", self.addr, e) + self._try_send_status(_BAD_REQUEST_RESPONSE, _BAD_REQUEST_BODY) + self.close() + except h11.LocalProtocolError: + self.server.logger.exception("Local protocol error from %s", self.addr) + self._try_send_status(_INTERNAL_ERROR_RESPONSE, _INTERNAL_ERROR_BODY) + self.close() + + def _loop(self, h: RequestHandler) -> None: parser = self.parser - # TODO Handler LocalProtocolError, it will send respo automatically - while self.tracked: if parser.our_state is h11.MUST_CLOSE: self.close() # TODO Proper half close, later - # FIXME Remove the socket from the selector return if parser.our_state is h11.DONE and parser.their_state is h11.DONE: parser.start_next_cycle() @@ -214,7 +241,6 @@ def __call__(self, h: RequestHandler) -> None: if parser.they_are_waiting_for_100_continue: # Drain the request body self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) continue - # TODO h11.RemoteProtocolError: peer closed connection without sending complete message body try: self.receive() except BlockingIOError: @@ -240,6 +266,22 @@ def __call__(self, h: RequestHandler) -> None: else: raise RuntimeError(f"Unexpected {event!r} in the connection loop") + def _try_send_status(self, response: h11.Response, body: bytes) -> None: + """Best-effort: try to send a response if the parser is still in a writable state. + + Used as a recovery path when the connection is about to be closed due to a + protocol error. Failures are silently swallowed. + """ + if self.parser.our_state is not h11.IDLE and self.parser.our_state is not h11.SEND_RESPONSE: + return + try: + self.send(response) + if body: + self.send(h11.Data(data=body)) + self.send(h11.EndOfMessage()) + except Exception: # noqa: BLE001, S110 — connection is being closed anyway + pass + @dataclass(eq=False, frozen=True, slots=True) class HTTPReqCtx: diff --git a/tests/http/server.py b/tests/http/server.py index 7248ec1..f4575d8 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -231,25 +231,19 @@ def _ok_handler(ctx: HTTPReqCtx) -> None: class TestProtocolErrors: - """Server's reaction to malformed input / handler crashes. + """Server's reaction to malformed input / handler crashes.""" - These pin *current* behavior — the server has no try/except around - handler invocation or h11 parsing (see TODOs in server.py:__call__), - so both paths kill the loop. When the server is hardened the assertions - here will need to flip from ``expect_loop_error=True`` to a 4xx/5xx check. - """ - - def test_malformed_request_kills_loop_today(self, serve_in_thread): - """A garbage request line propagates h11.RemoteProtocolError out of the loop.""" - cm = serve_in_thread(_ok_handler) - cm.expect_loop_error = True - - with cm as port: + def test_malformed_request_returns_400_and_keeps_loop_alive(self, serve_in_thread): + """A garbage request line is caught; the server replies 400 and stays up.""" + with serve_in_thread(_ok_handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: sock.sendall(b"NOT_A_VALID_HTTP_REQUEST\r\n\r\n") - _drain(sock, deadline=1.0) + data = _drain(sock, deadline=1.0) + assert b"HTTP/1.1 400" in data, f"expected 400 in response, got: {data!r}" - assert isinstance(cm.loop_error, h11.RemoteProtocolError) + # Sanity: a follow-up valid request is still served. + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 200 def test_handler_exception_returns_500(self, serve_in_thread): """An unhandled handler exception is caught and returned as 500.""" @@ -298,17 +292,14 @@ def test_client_closes_before_request_complete(self, serve_in_thread): resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) assert resp.status_code == 200 - def test_client_half_close_after_partial_body_kills_loop_today(self, serve_in_thread): - """Headers say Content-Length: 100, client sends 5 bytes then half-closes. + def test_client_half_close_after_partial_body_keeps_loop_alive(self, serve_in_thread): + """Headers say Content-Length: 100; client sends 5 bytes then half-closes. - h11 raises on incomplete body and the loop has no try/except around - the parser, so the server thread dies. Pin as expected behavior until - ``HTTPConn.__call__`` gains protocol-error handling (TODO at server.py). + h11 raises RemoteProtocolError on the incomplete body. The server logs, + closes the conn, and the accept loop survives — verified by a follow-up + request. """ - cm = serve_in_thread(_ok_handler) - cm.expect_loop_error = True - - with cm as port: + with serve_in_thread(_ok_handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: sock.sendall( b"POST / HTTP/1.1\r\n" @@ -320,7 +311,9 @@ def test_client_half_close_after_partial_body_kills_loop_today(self, serve_in_th sock.shutdown(socket.SHUT_WR) _drain(sock, deadline=1.0) - assert isinstance(cm.loop_error, h11.RemoteProtocolError) + # Server still serves a follow-up valid request. + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert resp.status_code == 200 class TestExpect100Continue: From 292d56ded66b5b0ce12b0ddb9f465e0f92640408 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 23:04:53 +0400 Subject: [PATCH 084/286] =?UTF-8?q?feat(http):=20graceful=20shutdown=20?= =?UTF-8?q?=E2=80=94=20close=20active=20connections=20on=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_http_server.__exit__ now signals shutdown on the Server (a new shutting_down flag, guarded by the existing _lock) and closes any client connections still in the selector — idle keep-alive ones and those still mid-request. Borrowed connections held by handler threads aren't reachable from the selector, so they're closed when the handler returns and tries to re-register them: track() observes shutting_down and closes instead of registering. The track() path now also tolerates an already-closed socket (settimeout-on-closed-fd raises OSError; emit_handler_error closes the conn before its borrow.__exit__ runs). Marking conn.tracked = True on every termination path makes _maybe_give_back idempotent so the double-call path (finish_response + borrow.__exit__) doesn't retry. Tests: - test_inflight_request_during_shutdown_today flipped to test_inflight_request_during_shutdown_completes_cleanly (rc == 0). - New TestGracefulShutdown: pins the shutting_down flag transition and that an idle keep-alive client sees an EOF after shutdown. Resolves the TODO at start_http_server (close active client connections on context exit). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server.py | 65 +++++++++++++++++++++++++++++++++------ tests/http/integration.py | 16 +++++----- tests/http/server.py | 42 +++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index 4ddfd45..576354c 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -71,6 +71,12 @@ def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Itera The handler is fixed for the lifetime of the server — every accepted request is dispatched to it. Per-iteration overrides are not supported. + + On context exit the server signals shutdown, closes any active client + connections still registered in the selector (idle keep-alive or + mid-request), and tears down the listening socket. Borrowed connections + held by handler threads are closed when the handler returns and tries to + re-register them on the (now shutting-down) server. """ logger = logging.getLogger(LOGGER_NAME) server_sock = socket.create_server( @@ -86,15 +92,11 @@ def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Itera # server_sock.close() # Safe to call it from another thread, will cause accept() to raise OSError with closing(server_sock), closing(selector): server = Server(config, handler, logger, server_sock, selector) - # logger.info(f"Serving on {config.host}:{server.port}") logger.info("Serving on %s:%d", config.host, server.port) - yield server - # TODO Close all the client connections that are currently registered in the selector - # They can be in either: - # - Idle state (keep-alive) — just close the socket, no need to send anything - # - Request being sent by the client, but not fully received yet — send 503 Service Unavailable and close the socket - # or simply close the socket for now - # - Draining request body, but not fully received yet — response already sent, just close the socket + try: + yield server + finally: + server._shutdown_active_connections() @final @@ -119,6 +121,10 @@ def __init__( self.handler = handler self.logger = logger self._lock = threading.Lock() + self.shutting_down: bool = False + """Set to True on context-manager exit. Once set, ``track`` rejects + new registrations and ``_maybe_give_back`` closes connections + instead of returning them to the selector.""" def _find_stale(self): now = time.monotonic() @@ -135,8 +141,23 @@ def _cleanup_stale(self): # TODO Add self-pipe wakeup trick and a queue def track(self, conn: HTTPConn) -> None: sock = conn.sock - sock.settimeout(0) + try: + sock.settimeout(0) + except OSError: + # The socket was already closed (e.g., by a previous error-recovery + # path). Mark the conn as no longer borrowed so a second + # _maybe_give_back call doesn't try again. + conn.tracked = True + return with self._lock: + if self.shutting_down: + # Server is going down — don't re-register, just close. + try: + sock.close() + except OSError: + pass + conn.tracked = True + return self.selector.register(sock, selectors.EVENT_READ, data=conn) conn.tracked = True @@ -147,6 +168,32 @@ def stop_tracking(self, conn: HTTPConn) -> None: sock.settimeout(self.config.rw_timeout) conn.tracked = False + def _shutdown_active_connections(self) -> None: + """Set the shutdown flag and close any connections still in the selector. + + Called from ``start_http_server.__exit__``. Borrowed connections (held + by handler threads) are not in the selector — they are closed by their + handlers on the next ``_maybe_give_back`` after we set the flag. + """ + with self._lock: + self.shutting_down = True + keys = list(self.selector.get_map().values()) + for key in keys: + if key.fileobj is self.sock: + continue # listening socket — closed by the outer CM + conn = key.data + if not isinstance(conn, HTTPConn): + continue + try: + self.selector.unregister(conn.sock) + except (KeyError, ValueError): + pass + try: + conn.sock.close() + except OSError: + pass + conn.tracked = False + def run(self, *, timeout: float | None = None) -> None: """One iteration of the server loop. Should be called repeatedly until the server is stopped. diff --git a/tests/http/integration.py b/tests/http/integration.py index df5968b..57f442b 100644 --- a/tests/http/integration.py +++ b/tests/http/integration.py @@ -142,13 +142,13 @@ def test_clean_shutdown_via_signal(sig): proc.wait() -def test_inflight_request_during_shutdown_today(): - """SIGTERM while the slow handler is sleeping. +def test_inflight_request_during_shutdown_completes_cleanly(): + """SIGTERM while the slow handler is sleeping → service exits cleanly. - Pin: today the worker thread is still in ``time.sleep`` when shutdown - cancels the task group, and the eventual write/close races with the - closed listener. The service exits non-zero. When in-flight handling - is hardened (graceful drain), this test should flip to ``rc == 0``. + The handler's ``time.sleep`` is not cancellation-aware, so shutdown waits + for it to finish. When the handler eventually writes its response and + tries to re-register the connection, the server (in shutting-down state) + closes the connection instead. Exit code must be 0. """ port = _pick_free_port() proc = _spawn(port, LP_TEST_SLOW_S="1.0") @@ -173,9 +173,7 @@ def test_inflight_request_during_shutdown_today(): except (httpx.HTTPError, OSError): pass - # Process must terminate within the deadline (no hangs). Exit code - # is currently non-zero — see docstring. - assert rc != 0, f"unexpected clean exit, stderr={stderr.decode()!r}" + assert rc == 0, f"unexpected non-zero exit; stderr={stderr.decode()!r}" finally: if proc.poll() is None: proc.kill() diff --git a/tests/http/server.py b/tests/http/server.py index f4575d8..9b5b4f3 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -424,3 +424,45 @@ def handler(ctx: HTTPReqCtx) -> None: assert served == [b"/a", b"/b"] assert b"resp-for-/a" in data assert b"resp-for-/b" in data + + +# --- Graceful shutdown ------------------------------------------------------- + + +class TestGracefulShutdown: + def test_shutting_down_flag_set_on_exit(self): + """``Server.shutting_down`` flips to True on context-manager exit.""" + captured: dict = {} + with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: + captured["server"] = server + assert server.shutting_down is False + assert captured["server"].shutting_down is True + + def test_idle_keep_alive_connection_closed_on_exit(self, serve_in_thread): + """A keep-alive socket sees an EOF (recv → b"") once the server exits.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + sock = socket.create_connection(("127.0.0.1", port), timeout=2) + try: + # Send a request and read its response — connection is kept alive. + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + _drain(sock, deadline=0.5) + finally: + # Pull socket reference outside the with block so we can recv after server exit. + pass + + # Outside `serve_in_thread`: server has exited and closed the keep-alive conn. + sock.settimeout(2.0) + try: + data = sock.recv(64) + except (ConnectionResetError, OSError): + data = b"" + finally: + sock.close() + assert data == b"", f"expected EOF after shutdown, got: {data!r}" From 9fe6afa5084e329e54c0b7d4480ce11dad07b033 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 23:11:55 +0400 Subject: [PATCH 085/286] feat(http): enforce ServerConfig.max_body_size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The max_body_size field was previously dead config. Now enforced via: - Up-front check on Content-Length when the request arrives — if the declared size exceeds max_body_size, send 413 Payload Too Large before invoking the handler. - Cumulative byte counter (HTTPConn.body_bytes_received) updated in both body-consumption paths: the connection loop's drain when the handler doesn't read the body, and HTTPReqCtx.receive when it does. Counter resets on parser.start_next_cycle. - New BodyTooLarge exception bubbles up to HTTPConn.__call__'s outer except, which emits 413 (best-effort) and closes. Fix-along-the-way: HTTPReqCtx.receive now retries with a blocking recv (bounded by rw_timeout) when sock.recv hits BlockingIOError. This was a pre-existing race where sync handlers reading the body on a non-blocking, tracked connection would crash if body bytes hadn't all arrived in the first kernel chunk; flaky body-streaming tests now pass deterministically. Tests: TestBodyLimit covers both the up-front Content-Length 413 and the streaming/chunked 413 path that goes through the byte counter. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server.py | 71 +++++++++++++++++++++++++++++++++++-- tests/http/server.py | 75 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index 576354c..d408460 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -45,6 +45,35 @@ ], ) +_PAYLOAD_TOO_LARGE_BODY = b"Payload Too Large" +_PAYLOAD_TOO_LARGE_RESPONSE = h11.Response( + status_code=413, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_PAYLOAD_TOO_LARGE_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + + +class BodyTooLarge(Exception): + """Raised when an incoming request body would exceed ``ServerConfig.max_body_size``. + + Surfaces both from ``HTTPReqCtx.receive`` (when the handler is reading the body) + and from ``HTTPConn``'s drain path (when the handler skipped reading it). The + connection loop converts it into a 413 Payload Too Large response. + """ + + +def _content_length(headers) -> int | None: + for name, value in headers: + if bytes(name).lower() == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + def emit_handler_error(ctx: HTTPReqCtx) -> None: """Best-effort recovery when a request handler raises. @@ -236,6 +265,10 @@ class HTTPConn: parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) close_at: float | None = None # Used only when tracked, to enforce keep-alive and read timeouts tracked: bool = False + body_bytes_received: int = 0 + """Cumulative body bytes received for the current request — reset on + ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` + to enforce the upload cap.""" def close(self) -> None: if self.tracked: @@ -270,6 +303,12 @@ def __call__(self, h: RequestHandler) -> None: self.server.logger.exception("Local protocol error from %s", self.addr) self._try_send_status(_INTERNAL_ERROR_RESPONSE, _INTERNAL_ERROR_BODY) self.close() + except BodyTooLarge: + self.server.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size + ) + self._try_send_status(_PAYLOAD_TOO_LARGE_RESPONSE, _PAYLOAD_TOO_LARGE_BODY) + self.close() def _loop(self, h: RequestHandler) -> None: parser = self.parser @@ -280,6 +319,7 @@ def _loop(self, h: RequestHandler) -> None: return if parser.our_state is h11.DONE and parser.their_state is h11.DONE: parser.start_next_cycle() + self.body_bytes_received = 0 self.close_at = time.monotonic() + self.server.config.keep_alive_timeout event = parser.next_event() @@ -293,12 +333,22 @@ def _loop(self, h: RequestHandler) -> None: except BlockingIOError: return # Wait for it in the selector self.close_at = time.monotonic() + self.server.config.rw_timeout - elif isinstance(event, h11.Data | h11.EndOfMessage): + elif isinstance(event, h11.Data): + self.body_bytes_received += len(event.data) + if self.body_bytes_received > self.server.config.max_body_size: + raise BodyTooLarge(self.body_bytes_received) continue # Drain the request body + elif isinstance(event, h11.EndOfMessage): + continue elif isinstance(event, h11.Request): + cl = _content_length(event.headers) + if cl is not None and cl > self.server.config.max_body_size: + raise BodyTooLarge(cl) req_ctx = HTTPReqCtx(self.server, self, event) try: h(req_ctx) + except BodyTooLarge: + raise # outer handler emits 413 except Exception: self.server.logger.exception("Handler raised for %s %r", event.method, event.target) emit_handler_error(req_ctx) @@ -375,8 +425,25 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: while True: event = parser.next_event() if event is h11.NEED_DATA: - self._conn.receive(size) + # Sync handlers can't tolerate BlockingIOError on a non-blocking + # socket: switch to a brief blocking read bounded by rw_timeout, + # then restore. Borrowed connections are already blocking, so + # the settimeout calls are no-ops on the rw_timeout value there. + sock = self._conn.sock + rw = self._server.config.rw_timeout + try: + self._conn.receive(size) + except BlockingIOError: + sock.settimeout(rw) + try: + self._conn.receive(size) + finally: + if self._conn.tracked: + sock.settimeout(0) elif isinstance(event, h11.Data): + self._conn.body_bytes_received += len(event.data) + if self._conn.body_bytes_received > self._server.config.max_body_size: + raise BodyTooLarge(self._conn.body_bytes_received) return event.data elif isinstance(event, h11.EndOfMessage): return b"" diff --git a/tests/http/server.py b/tests/http/server.py index 9b5b4f3..c53b085 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -426,6 +426,81 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"resp-for-/b" in data +# --- Body limit --------------------------------------------------------------- + + +class TestBodyLimit: + def test_oversized_content_length_returns_413(self): + """Server short-circuits on Content-Length > max_body_size.""" + captured: dict = {"called": False} + + def handler(ctx: HTTPReqCtx) -> None: + captured["called"] = True + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=0, max_body_size=10) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread( + target=lambda: _run_until(server, stop), + daemon=True, + ) + t.start() + try: + resp = httpx.post( + f"http://127.0.0.1:{server.port}/", + content=b"x" * 1000, + timeout=2, + ) + finally: + stop.set() + t.join(timeout=2) + + assert resp.status_code == 413 + assert resp.text == "Payload Too Large" + assert captured["called"] is False # handler never invoked + + def test_oversized_streaming_body_returns_413(self): + """A handler that reads the body sees BodyTooLarge once the cap is crossed.""" + captured: dict = {"raised": False} + + def handler(ctx: HTTPReqCtx) -> None: + try: + while ctx.receive(): + pass + except Exception as e: + captured["raised"] = type(e).__name__ + raise + + cfg = ServerConfig(host="127.0.0.1", port=0, max_body_size=8) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread(target=lambda: _run_until(server, stop), daemon=True) + t.start() + try: + # No Content-Length: send chunked to bypass the up-front check. + with socket.create_connection(("127.0.0.1", server.port), timeout=3) as sock: + sock.sendall( + b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + b"10\r\n" + (b"x" * 16) + b"\r\n0\r\n\r\n" + ) + data = _drain(sock, deadline=2.0) + finally: + stop.set() + t.join(timeout=2) + + assert b"HTTP/1.1 413" in data, f"expected 413, got: {data!r}" + assert captured["raised"] == "BodyTooLarge" + + +def _run_until(server, stop: threading.Event) -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + + # --- Graceful shutdown ------------------------------------------------------- From 4c2774258b9f8a29ec439f812a2a649234e352c0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 25 Apr 2026 23:16:43 +0400 Subject: [PATCH 086/286] feat(http): emit 408 Request Timeout on stalled mid-request connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _cleanup_stale used to silently close every stale connection. Now it distinguishes: - Idle keep-alive (between requests): silent close — sending 408 to a quiescent client would look like an unsolicited response. - Mid-request stall (some bytes received but no complete request yet): emit 408 Request Timeout + Connection: close, then close. To distinguish the two states (h11 keeps their_state==IDLE for partial headers and the parser doesn't expose receive-buffer status), add an explicit ``HTTPConn.idle`` flag — True between requests / before the first byte, flipped to False on any non-empty recv, reset on parser.start_next_cycle. I/O during cleanup is now performed outside the selector lock; the selector unregister still happens under the lock. Tests: TestStaleCleanup pins both paths — idle keep-alive sees an EOF, mid-request stall sees an HTTP/1.1 408. Resolves the TODO at _cleanup_stale ("Send 408 Request Timeout"). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server.py | 54 ++++++++++++++++++++++++++++++++++--- tests/http/server.py | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index d408460..e465676 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -55,6 +55,16 @@ ], ) +_REQUEST_TIMEOUT_BODY = b"Request Timeout" +_REQUEST_TIMEOUT_RESPONSE = h11.Response( + status_code=408, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_REQUEST_TIMEOUT_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + class BodyTooLarge(Exception): """Raised when an incoming request body would exceed ``ServerConfig.max_body_size``. @@ -163,9 +173,37 @@ def _find_stale(self): def _cleanup_stale(self): with self._lock: - for conn in list(self._find_stale()): - self.selector.unregister(conn.sock) - conn.close() # TODO Send 408 Request Timeout with Connection: close, then close the socket + stale = list(self._find_stale()) + for conn in stale: + try: + self.selector.unregister(conn.sock) + except (KeyError, ValueError): + pass + conn.tracked = False + # Drop the lock for any I/O — closing a socket can block, and we don't + # want to hold the selector lock while talking to the kernel. + for conn in stale: + # Stalled mid-request (some bytes received but no complete request + # yet, and no response started) gets a 408; idle keep-alive gets + # silently dropped. + if not conn.idle and conn.parser.our_state is h11.IDLE: + conn.sock.settimeout(self.config.rw_timeout) + try: + payload = conn.parser.send(_REQUEST_TIMEOUT_RESPONSE) + if payload: + conn.sock.sendall(payload) + payload = conn.parser.send(h11.Data(data=_REQUEST_TIMEOUT_BODY)) + if payload: + conn.sock.sendall(payload) + payload = conn.parser.send(h11.EndOfMessage()) + if payload: + conn.sock.sendall(payload) + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + try: + conn.sock.close() + except OSError: + pass # TODO Add self-pipe wakeup trick and a queue def track(self, conn: HTTPConn) -> None: @@ -269,6 +307,11 @@ class HTTPConn: """Cumulative body bytes received for the current request — reset on ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` to enforce the upload cap.""" + idle: bool = True + """``True`` between requests (after ``start_next_cycle``) or before the + first byte arrives. Flips to ``False`` once any byte has been received + for the current request. Distinguishes idle keep-alive (close silently) + from a stalled mid-request (emit 408 Request Timeout).""" def close(self) -> None: if self.tracked: @@ -280,6 +323,8 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: self.parser.receive_data(data) if not data: self.recv_closed = True + else: + self.idle = False # Helper method when a req (conn) is borrowed def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11.EndOfMessage) -> None: @@ -316,10 +361,11 @@ def _loop(self, h: RequestHandler) -> None: while self.tracked: if parser.our_state is h11.MUST_CLOSE: self.close() # TODO Proper half close, later - return + return # close() already unregisters from the selector if parser.our_state is h11.DONE and parser.their_state is h11.DONE: parser.start_next_cycle() self.body_bytes_received = 0 + self.idle = True self.close_at = time.monotonic() + self.server.config.keep_alive_timeout event = parser.next_event() diff --git a/tests/http/server.py b/tests/http/server.py index c53b085..3655583 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -426,6 +426,63 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"resp-for-/b" in data +# --- Stale-connection cleanup ------------------------------------------------ + + +class TestStaleCleanup: + def test_idle_keep_alive_silently_closed_after_timeout(self): + """An idle keep-alive client sees an EOF (no 408) once keep_alive_timeout elapses.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + cfg = ServerConfig(host="127.0.0.1", port=0, keep_alive_timeout=0.1) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread(target=lambda: _run_until(server, stop), daemon=True) + t.start() + try: + with socket.create_connection(("127.0.0.1", server.port), timeout=2) as sock: + # First request — server then keeps the connection alive. + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + _drain(sock, deadline=0.3) + # Now sit idle. After keep_alive_timeout (0.1s) + a few iterations, + # _cleanup_stale should silently close the conn. + sock.settimeout(2.0) + data = sock.recv(64) + finally: + stop.set() + t.join(timeout=2) + + assert data == b"", f"expected EOF on idle close, got: {data!r}" + + def test_mid_request_stale_returns_408(self): + """Client starts sending headers and stops; server emits 408 once stale.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=0, keep_alive_timeout=0.1, rw_timeout=0.1) + with start_http_server(cfg, handler) as server: + stop = threading.Event() + t = threading.Thread(target=lambda: _run_until(server, stop), daemon=True) + t.start() + try: + with socket.create_connection(("127.0.0.1", server.port), timeout=2) as sock: + # Send only part of a request and then sit idle. + sock.sendall(b"GET /slow HTTP/1.1\r\nHost: x\r\n") + sock.settimeout(2.0) + data = _drain(sock, deadline=1.5) + finally: + stop.set() + t.join(timeout=2) + + assert b"HTTP/1.1 408" in data, f"expected 408 in response, got: {data!r}" + + # --- Body limit --------------------------------------------------------------- From c97be64e6a9f64e4270b3753d3d7ed833dbc7205 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 26 Apr 2026 11:39:19 +0400 Subject: [PATCH 087/286] feat(http): accept Buffer in send + advertise Keep-Alive timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPReqCtx.send now takes any collections.abc.Buffer (memoryview, bytearray, …) instead of strictly bytes. h11 still wants bytes, so internally we cast — the API change just spares callers an explicit copy when they're handing in a buffer object. start_response now injects "Keep-Alive: timeout=N" on HTTP/1.1 responses when neither side opted out (Connection: close on the incoming request or already-set on the outgoing response). Lets clients size their keep-alive pool to ServerConfig.keep_alive_timeout instead of guessing. New tests: - TestSendBuffer: ctx.send accepts a memoryview slice. - TestKeepAliveHeader: header present on default HTTP/1.1, absent when request or response carries Connection: close. Resolves the TODOs at HTTPReqCtx.send and on ServerConfig.keep_alive_timeout. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/config.py | 6 ++-- localpost/http/server.py | 33 +++++++++++++++--- tests/http/server.py | 74 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/localpost/http/config.py b/localpost/http/config.py index eb1d52a..2d1615e 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -28,7 +28,9 @@ class ServerConfig: """Default upper bound (seconds) on ``selector.select`` per ``Server.run`` iteration. Caps how long the loop blocks before returning to the caller for a cancellation / shutdown check. Callers may override per-iteration via ``Server.run(timeout=…)``.""" - keep_alive_timeout: float = 15.0 # TODO add it to the response - """Timeout (seconds) for idle connections.""" + keep_alive_timeout: float = 15.0 + """Timeout (seconds) for idle connections. Advertised to HTTP/1.1 clients + via the ``Keep-Alive: timeout=N`` response header so they can size their + keep-alive pool to match.""" max_body_size: int = 10 * 1024 * 1024 # 10 MiB """Maximum request body size (bytes).""" diff --git a/localpost/http/server.py b/localpost/http/server.py index e465676..797d489 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -13,7 +13,7 @@ import socket import threading import time -from collections.abc import Callable, Iterator +from collections.abc import Buffer, Callable, Iterator from contextlib import closing, contextmanager from dataclasses import dataclass, field from typing import final @@ -499,11 +499,36 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: def start_response(self, response: h11.Response | h11.InformationalResponse, /) -> None: if isinstance(response, h11.Response): object.__setattr__(self, "response_status", response.status_code) + response = self._maybe_inject_keep_alive(response) self._conn.send(response) - # TODO Accept any Buffer, later - def send(self, chunk: bytes, /) -> None: - self._conn.send(h11.Data(chunk)) + def _maybe_inject_keep_alive(self, response: h11.Response) -> h11.Response: + """Append ``Keep-Alive: timeout=N`` to the response on persistent HTTP/1.1 + connections so clients can size their keep-alive pool to our deadline.""" + timeout = int(self._server.config.keep_alive_timeout) + if timeout < 1: + return response + if self.request.http_version != b"1.1": + return response + for name, value in self.request.headers: + if bytes(name).lower() == b"connection" and b"close" in bytes(value).lower(): + return response + for name, value in response.headers: + nl = bytes(name).lower() + if nl == b"connection" and b"close" in bytes(value).lower(): + return response + if nl == b"keep-alive": + return response # caller already set it + return h11.Response( + status_code=response.status_code, + headers=[*response.headers, (b"keep-alive", f"timeout={timeout}".encode("ascii"))], + reason=response.reason, + ) + + def send(self, chunk: Buffer, /) -> None: + # h11 wants bytes; widen the public API to any Buffer (memoryview, + # bytearray, …) so callers can avoid an explicit copy. + self._conn.send(h11.Data(bytes(chunk) if not isinstance(chunk, bytes) else chunk)) def finish_response(self) -> None: self._conn.send(h11.EndOfMessage()) diff --git a/tests/http/server.py b/tests/http/server.py index 3655583..7d54db6 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -426,6 +426,80 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"resp-for-/b" in data +# --- Buffer-protocol body / Keep-Alive header -------------------------------- + + +class TestSendBuffer: + def test_send_accepts_memoryview(self, serve_in_thread): + """``ctx.send`` accepts any Buffer (memoryview, bytearray, …).""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.start_response( + h11.Response(status_code=200, headers=[(b"content-length", b"5")]) + ) + payload = bytearray(b"hello") + ctx.send(memoryview(payload)[:5]) + ctx.finish_response() + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"hello" + + +class TestKeepAliveHeader: + def test_keep_alive_header_added_on_http11(self, serve_in_thread): + """HTTP/1.1 responses get a Keep-Alive: timeout=N header by default.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + ka = resp.headers.get("keep-alive") + assert ka is not None and ka.startswith("timeout="), f"unexpected Keep-Alive: {ka!r}" + + def test_keep_alive_header_skipped_when_request_says_close(self, serve_in_thread): + """Don't advertise keep-alive when the client opted out via Connection: close.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + resp = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"Connection": "close"}, + timeout=5, + ) + + assert "keep-alive" not in resp.headers + + def test_keep_alive_header_skipped_when_response_says_close(self, serve_in_thread): + """Don't advertise keep-alive when the handler set Connection: close itself.""" + + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + h11.Response( + status_code=200, + headers=[(b"content-length", b"2"), (b"connection", b"close")], + ), + b"ok", + ) + + with serve_in_thread(handler) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + + assert "keep-alive" not in resp.headers + + # --- Stale-connection cleanup ------------------------------------------------ From f2f8dde03c4e9432c816a4c8247207bf94cf00c7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 26 Apr 2026 11:41:07 +0400 Subject: [PATCH 088/286] =?UTF-8?q?feat(http):=20proper=20half-close=20?= =?UTF-8?q?=E2=80=94=20shutdown(SHUT=5FWR)=20before=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPConn.close now sends a FIN via socket.shutdown(SHUT_WR) before calling sock.close(). With the previous straight close(), if there were unread bytes in the kernel receive buffer, the kernel would send a TCP RST instead of a clean FIN — some clients log this as "Connection reset by peer" rather than a normal close. Errors from shutdown are swallowed; an already-broken socket (ENOTCONN, EBADF) is fine to ignore here. Tightened test_connection_close_header to drive the request via a raw socket and assert that the trailing recv returns b"" cleanly without ConnectionResetError, pinning the FIN-not-RST behavior. Resolves the TODO at HTTPConn._loop's MUST_CLOSE branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server.py | 11 +++++++++-- tests/http/server.py | 24 +++++++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/localpost/http/server.py b/localpost/http/server.py index 797d489..61d4e29 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -316,6 +316,13 @@ class HTTPConn: def close(self) -> None: if self.tracked: self.server.selector.unregister(self.sock) + # Send a FIN before close() so the client sees a clean half-close + # rather than a possible RST when there's unread data in the kernel + # receive buffer. Errors are expected on already-broken sockets. + try: + self.sock.shutdown(socket.SHUT_WR) + except OSError: + pass self.sock.close() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: @@ -360,8 +367,8 @@ def _loop(self, h: RequestHandler) -> None: while self.tracked: if parser.our_state is h11.MUST_CLOSE: - self.close() # TODO Proper half close, later - return # close() already unregisters from the selector + self.close() # close() shuts down WR + unregisters + return if parser.our_state is h11.DONE and parser.their_state is h11.DONE: parser.start_next_cycle() self.body_bytes_received = 0 diff --git a/tests/http/server.py b/tests/http/server.py index 7d54db6..263b462 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -202,19 +202,37 @@ def handler(ctx: HTTPReqCtx): assert call_count == 2 def test_connection_close_header(self, serve_in_thread): - """When client sends Connection: close, server should close after one request.""" + """When client sends Connection: close, server should close after one request. + + Also pins the half-close behavior: the next ``recv`` must return a + clean EOF (b""), not raise ConnectionResetError, since the server + sends a FIN via shutdown(SHUT_WR) before closing. + """ call_count = 0 def handler(ctx: HTTPReqCtx): nonlocal call_count call_count += 1 ctx.complete( - h11.Response(status_code=200, headers=[(b"Content-Length", b"2")]), + h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) with serve_in_thread(handler) as port: - httpx.get(f"http://127.0.0.1:{port}/", headers={"Connection": "close"}, timeout=5) + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall( + b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + ) + # Read response. + data = _drain(sock, deadline=1.0) + assert b"HTTP/1.1 200" in data + # Now expect a clean EOF (FIN), not a reset. + sock.settimeout(2.0) + try: + eof = sock.recv(64) + except ConnectionResetError as e: + pytest.fail(f"server sent RST instead of FIN: {e}") + assert eof == b"" assert call_count == 1 From 529b229d71c0719292367fd05c557fbe099ec945 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 26 Apr 2026 11:42:40 +0400 Subject: [PATCH 089/286] docs(http): roadmap entry for self-pipe wakeup, drop matching TODOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-pipe / per-iteration op-queue refactor is an architectural clean-up rather than a bug fix — kqueue and epoll observe selector modifications during a wait, so the current code works correctly on macOS and Linux. Capture the design in a Roadmap section in the HTTP README so the rationale and proposed implementation aren't lost, and drop the matching # TODO comments from server.py (track and run). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/README.md | 36 ++++++++++++++++++++++++++++++++++++ localpost/http/server.py | 3 --- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index a7a6460..31accfb 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -179,6 +179,42 @@ on the documented public Flask API. The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown is driven by `sl.shutting_down` via `threadtools.check_cancelled()`. +## Roadmap + +Items that are not currently bugs but where there's a known better +implementation worth tackling later. + +### Self-pipe wakeup + per-iteration op queue + +Today `Server.track` / `Server.stop_tracking` mutate the selector under +`Server._lock` from any thread, and the accept-loop thread reads it via +`selector.select(timeout=config.select_timeout)`. The shipped selectors on +macOS (`kqueue`) and Linux (`epoll`) observe set modifications during a wait, +so newly tracked fds are picked up immediately and correctness is fine. On +selectors that don't (`SelectSelector`), the loop only sees the new fd once +the current `select()` returns — up to `select_timeout` of latency on a +keep-alive return. + +The fix is the standard **self-pipe trick** plus a single-writer model: + +1. Allocate an internal `os.pipe()` registered for `EVENT_READ` alongside the + listening socket. +2. Worker threads stop touching the selector. `track` / `stop_tracking` + enqueue an op (e.g. `("track", conn)`) onto a thread-safe deque, then + write one byte to the pipe to wake the loop. +3. `Server.run`, at the start of each iteration, drains the pipe, drains the + op queue (under the lock), applies the ops, and only then calls + `select()`. + +Benefits: portable across all selectors, sub-millisecond wakeup on +track / untrack regardless of `select_timeout`, and a cleaner threading +model where the selector has exactly one writer. + +Cost: ~80–120 LoC of careful threading, an extra fd pair per server, and a +new stress test for rapid track / untrack churn. Deferred for now since the +default selector on supported platforms doesn't suffer from the latency in +practice. + ## How is it different from… - **Flask** — Flask is web-first (templates, Jinja, sessions) with no built-in diff --git a/localpost/http/server.py b/localpost/http/server.py index 61d4e29..6c6b167 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -205,7 +205,6 @@ def _cleanup_stale(self): except OSError: pass - # TODO Add self-pipe wakeup trick and a queue def track(self, conn: HTTPConn) -> None: sock = conn.sock try: @@ -273,8 +272,6 @@ def run(self, *, timeout: float | None = None) -> None: server_sock = self.sock h = self.handler self._cleanup_stale() - # TODO Take iteration payload (pending connections) and set it empty, under the lock - # TODO Add selector.select() to the current payload (chain) for key, _ in self.selector.select(timeout=timeout): if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() From ab57a734ee35cb7c2cb906dc7be33d7541deb7f4 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 26 Apr 2026 07:48:48 +0000 Subject: [PATCH 090/286] WIP --- localpost/http/TODO.md | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 localpost/http/TODO.md diff --git a/localpost/http/TODO.md b/localpost/http/TODO.md deleted file mode 100644 index cf47a4f..0000000 --- a/localpost/http/TODO.md +++ /dev/null @@ -1,26 +0,0 @@ -# Vision - -## http server -- HTTP 1.1 only, no other versions (e.g. HTTP/2, HTTP/3) - - so we expose h11 directly -- no WebSocket support (for now) - -## Router - -- Simple router, very slim layer like Werkzeug or Starlette - -## OpenAPI - -- build API from Python types - - -# TODOs - -- Brotli middleware - -- Converters (for fluent handler, in OpenAPI) - - werkzeug.Request - - Pydantic - - msgspec - - dataclass - - attrs From ac562e4605e2526ad388cb1a87708d260f101158 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 26 Apr 2026 07:58:44 +0000 Subject: [PATCH 091/286] WIP on Flask --- localpost/http/flask.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/localpost/http/flask.py b/localpost/http/flask.py index b87ab4d..a3bab42 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -24,7 +24,7 @@ from localpost.http._service import http_server from localpost.http.config import ServerConfig from localpost.http.server import HTTPReqCtx, RequestHandler -from localpost.http.wsgi import _build_environ # intra-package helper +from localpost.http.wsgi import _build_environ __all__ = ["flask_handler", "flask_server"] @@ -33,7 +33,7 @@ def flask_handler(app: Flask) -> RequestHandler: """Wrap a Flask app as a native :class:`RequestHandler`. See the module docstring for the behavior differences vs. WSGI — notably, - the request context stays active during response-body streaming. + the request context stays active during response body streaming. """ def handle(http_ctx: HTTPReqCtx) -> None: @@ -49,22 +49,20 @@ def handle(http_ctx: HTTPReqCtx) -> None: try: _write_response(http_ctx, response) finally: - response.close() # fire werkzeug's call_on_close callbacks + response.close() # Fire werkzeug's call_on_close callbacks return handle def _write_response(http_ctx: HTTPReqCtx, response) -> None: - # response is a werkzeug.Response (Flask's Response subclasses it). - reason = response.status.split(" ", 1)[1] if " " in response.status else "" - h11_headers = [ - (name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items() - ] + # response is a werkzeug.Response (Flask's Response subclasses it) + reason = (response.status.split(" ", 1)[1] if " " in response.status else "").encode("iso-8859-1") + h11_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items()] http_ctx.start_response( h11.Response( status_code=response.status_code, headers=h11_headers, - reason=reason.encode("iso-8859-1") if reason else b"", + reason=reason, ) ) for chunk in response.iter_encoded(): From 367d49782f278990bd845bfe825a5fb264783c7f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 26 Apr 2026 12:31:01 +0400 Subject: [PATCH 092/286] review follow-ups: fix mid-stream-crash test + resolve stale markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 1a: tighten test_handler_exception_after_start_response_closes_conn The previous version spawned a fresh server with _ok_handler for the "follow-up" check, so it tested a different server, not loop survival. Now fires two requests against the SAME boom-handler server and asserts both reach the handler — proves the accept loop kept serving after the first mid-stream crash. Group 1b: resolve stale TODO/FIXME markers - _host.py: wire up _app_lt in _serve_root and run() (root path), so current_app() actually works. The "FIXME Set" comment was correct — the contextvar was declared but never set. Adds tokens reset on exit. (Tests for current_app land in the hosting-coverage commit.) - _host.py service decorator: drop the "FIXME Wrap sync functions" comment — sync functions are wrapped via to_thread.run_sync and async generators via _service_cm; the four signatures the README documents all work today. - _host.py _serve_in: rephrase the open question about parent-binding behavior into a clearer note. - _host.py _run_many: add a docstring noting the sibling-shutdown limitation, point at the upcoming test in tests/hosting. - config.py: drop the "TODO Access logger?.." stub — open question, not a tracked task. Group 1c: README polish - Update server.py line count (~540 lines, was "About 400"). - Fix the "Running under hosting" snippet (missing import sys; pull from the stable public re-export `localpost.http`, not _service). - Hosting integration table: add Module column so flask_server is attributed to localpost.http.flask, not _service. - Typo: sl.shutting_down -> lt.shutting_down. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/hosting/_host.py | 50 ++++++++++++++++++++++++++------------ localpost/http/README.md | 24 +++++++++--------- localpost/http/config.py | 1 - tests/http/server.py | 29 ++++++++++++++-------- 4 files changed, 67 insertions(+), 37 deletions(-) diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 91628e7..4d70ddd 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -70,9 +70,12 @@ class Stopped: ServiceState = Starting | Running | ShuttingDown | Stopped -# FIXME Set _app_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_app") +"""The root service lifetime — set by ``_serve_root`` / ``run`` (when no parent +is given) and read by ``current_app``.""" _svc_lt: ContextVar[ServiceLifetime] = ContextVar("localpost.current_service") +"""The innermost service lifetime — set by ``_run`` and read by +``current_service``.""" def current_app() -> ServiceLifetimeView: @@ -307,7 +310,6 @@ def service[**P]() -> Callable[[Callable[P, Any]], Callable[P, _ResolvedService] def service(target: Callable[..., Any] | None = None): - # FIXME Wrap sync functions, wrap context managers def decorator(func: Callable[..., Any]) -> Callable[..., _ResolvedService]: @wraps(func) def wrapper(*args, **kwargs): @@ -386,20 +388,27 @@ async def wait_stopped(): async with BlockingPortal() as portal: child_lt = ServiceLifetime(portal) - tg = portal._task_group - tg.start_soon(_run, svc, child_lt) - # Wait for either started or stopped (service may fail before starting) - async with create_task_group() as wait_tg: - wait_tg.start_soon(wait_started) - wait_tg.start_soon(wait_stopped) - yield child_lt.view - child_lt.view.shutdown() - await child_lt.stopped + app_token = _app_lt.set(child_lt) + try: + tg = portal._task_group + tg.start_soon(_run, svc, child_lt) + # Wait for either started or stopped (service may fail before starting) + async with create_task_group() as wait_tg: + wait_tg.start_soon(wait_started) + wait_tg.start_soon(wait_stopped) + yield child_lt.view + child_lt.view.shutdown() + await child_lt.stopped + finally: + _app_lt.reset(app_token) @asynccontextmanager async def _serve_in(svc: ServiceF, parent: ServiceLifetime) -> AsyncIterator[ServiceLifetimeView]: - # TODO Just throw an exception if the service stops with an error?.. Instead of binding the parent lifetime + # Open question: when a child service crashes, we currently only shut down + # the parent. An alternative is to re-raise the child's exception so the + # parent's task group sees it. Both have callers; revisit when there's a + # concrete need. async def bind_parent_to(csl: ServiceLifetimeView): await csl.stopped parent.view.shutdown() @@ -430,14 +439,25 @@ async def wait_stopped(): async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: async with nullcontext(parent.portal) if parent else BlockingPortal() as portal: lt = ServiceLifetime(portal) - await _run(svc_f, lt) - return lt.exit_code + app_token = _app_lt.set(lt) if parent is None else None + try: + await _run(svc_f, lt) + return lt.exit_code + finally: + if app_token is not None: + _app_lt.reset(app_token) def _run_many(*svcs: ServiceF) -> ServiceF: + """Compose multiple services into one. Children run concurrently in the + parent's task group; the parent waits for all of them to stop. + + Known limitation: when one child crashes, sibling services keep running. + Tracked at the test level — see ``tests/hosting/multi_service.py``. + """ + async def _run(lt: ServiceLifetime) -> None: children = [lt.start(svc) for svc in svcs] - # TODO Shutdown everything in case of an exception in any child await wait_all(c.stopped for c in children) return _run diff --git a/localpost/http/README.md b/localpost/http/README.md index 31accfb..b06d242 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -3,8 +3,8 @@ > **Status:** stable — public API is not expected to break in patch/minor releases. A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), -plus a router for URI-template-based request dispatch, and a WSGI bridge. About -400 lines of focused, sync code — easy to read, easy to embed. +plus a router for URI-template-based request dispatch, and a WSGI bridge. The +server core is ~540 lines of focused, sync code — easy to read, easy to embed. Pair it with `localpost.hosting` for lifecycle management, or run it standalone. For OpenAPI / content negotiation / validation, see @@ -42,10 +42,12 @@ See [`examples/http/simple_server.py`](../../examples/http/simple_server.py), Running under hosting: ```python +import sys + from localpost.hosting import run_app -from localpost.http._service import http_server -from localpost.http.config import ServerConfig +from localpost.http import http_server, ServerConfig +# `simple_app` from the Quick start above sys.exit(run_app(http_server(ServerConfig(), simple_app))) ``` @@ -166,18 +168,18 @@ on the documented public Flask API. | `ServerConfig` | Frozen dataclass of server tuning parameters | | `LOGGER_NAME` | `"localpost.http"` | -### Hosting integration — `localpost.http._service` +### Hosting integration `@hosting.service` wrappers for running the server inside a hosted app: -| Service | Notes | -| -------------------------------------------- | ------------------------------------------- | -| `http_server(config, handler)` | Runs the server loop with your handler | -| `wsgi_server(config, app)` | Same, for a generic WSGI app | -| `flask_server(config, app)` | Native Flask — see `localpost.http.flask` | +| Service | Module | Notes | +| -------------------------------------------- | ---------------------------- | ------------------------------------------- | +| `http_server(config, handler)` | `localpost.http._service` | Runs the server loop with your handler | +| `wsgi_server(config, app)` | `localpost.http._service` | Same, for a generic WSGI app | +| `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask` | The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown -is driven by `sl.shutting_down` via `threadtools.check_cancelled()`. +is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. ## Roadmap diff --git a/localpost/http/config.py b/localpost/http/config.py index 2d1615e..11d579a 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -11,7 +11,6 @@ DEFAULT_BUFFER_SIZE: Final = 64 * 1024 # 64 KiB LOGGER_NAME: Final = "localpost.http" -# TODO Access logger?.. @final diff --git a/tests/http/server.py b/tests/http/server.py index 263b462..2b7de42 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -276,9 +276,16 @@ def boom(_: HTTPReqCtx) -> None: assert resp.text == "Internal Server Error" def test_handler_exception_after_start_response_closes_conn(self, serve_in_thread): - """If the handler crashes after start_response, the conn is closed cleanly.""" + """If the handler crashes after start_response, the conn is closed cleanly + AND the accept loop keeps serving subsequent connections to the same server. + """ + crash_count = 0 + crash_lock = threading.Lock() def boom(ctx: HTTPReqCtx) -> None: + nonlocal crash_count + with crash_lock: + crash_count += 1 ctx.start_response( h11.Response( status_code=200, @@ -289,15 +296,17 @@ def boom(ctx: HTTPReqCtx) -> None: raise RuntimeError("crashed mid-stream") with serve_in_thread(boom) as port: - with contextlib.suppress(httpx.HTTPError): - resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) - # Response started with 200, but body is truncated when conn closes. - assert resp.status_code == 200 - - # Server still serves a follow-up request — the loop survived. - with serve_in_thread(_ok_handler) as second_port: - resp2 = httpx.get(f"http://127.0.0.1:{second_port}/", timeout=2) - assert resp2.status_code == 200 + # Two separate TCP connections to the SAME server — the second one + # only succeeds in reaching the handler if the accept loop is + # still alive after the first crash. + for _ in range(2): + with contextlib.suppress(httpx.HTTPError): + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + # The response started with 200; httpx may still surface it + # despite the truncated body. + assert resp.status_code == 200 + + assert crash_count == 2, f"expected 2 handler invocations, got {crash_count}" class TestClientDisconnects: From 29fb178a76427a0e6f474fa6b02d45f3b9d92cce Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 11:26:04 +0400 Subject: [PATCH 093/286] test(hosting): cover run_app, _run_many, defer/adefer, signals, contextvars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosting package documented several public surfaces (per README) that no test exercised. Add four files: - tests/hosting/run_app.py — synchronous tests for the headline run_app entrypoint: single service, failing service, multiple services. - tests/hosting/multi_service.py — _run_many composition. Pins the documented limitation that a child crash doesn't cancel siblings as a "_today" test (will flip if the behavior is hardened). - tests/hosting/lifetime_extras.py — current_service / current_app contextvar accessors, defer / adefer (sync CM, closable, async CM), observe_services, cancel_on_shutdown, cancel_on_stop. 20 tests parameterized over asyncio + trio. - tests/hosting/signal_handling.py + _signal_app.py — subprocess-based shutdown_on_signal coverage: SIGTERM and SIGINT both produce clean exits with the service's STOPPED log line; second SIGINT triggers the forced-stop path. Also fix a real bug uncovered while writing the defer tests: the ServiceLifetime.scope AsyncExitStack was created but never entered or closed, so deferred resources that the README claimed were "released when the service stops" actually leaked. _run now wraps the task group in `async with lt.scope:` (outer) so cleanups run after the tg has finished. Net: 153 -> 183 passing tests in ~35 s. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/hosting/_host.py | 10 +- tests/hosting/_signal_app.py | 23 +++ tests/hosting/lifetime_extras.py | 231 +++++++++++++++++++++++++++++++ tests/hosting/multi_service.py | 86 ++++++++++++ tests/hosting/run_app.py | 55 ++++++++ tests/hosting/signal_handling.py | 80 +++++++++++ 6 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 tests/hosting/_signal_app.py create mode 100644 tests/hosting/lifetime_extras.py create mode 100644 tests/hosting/multi_service.py create mode 100644 tests/hosting/run_app.py create mode 100644 tests/hosting/signal_handling.py diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 4d70ddd..3759ed0 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -357,9 +357,13 @@ async def observe_services(*lifetimes: ServiceLifetimeView): async def _run(svc_f: ServiceF, lt: ServiceLifetime) -> None: parent_svc_lt = _svc_lt.set(lt) try: - async with lt.tg as tg: - await svc_f(lt) - tg.cancel_scope.cancel() # Cancel any remaining tasks + # ``lt.scope`` is the outer context: deferred cleanups run AFTER + # the task group has finished, so handlers can still use deferred + # resources during their cancellation window. + async with lt.scope: + async with lt.tg as tg: + await svc_f(lt) + tg.cancel_scope.cancel() # Cancel any remaining tasks except get_cancelled_exc_class() as exc: # BaseException lt.exception = exc raise # Always reraise cancellations diff --git a/tests/hosting/_signal_app.py b/tests/hosting/_signal_app.py new file mode 100644 index 0000000..9ab845a --- /dev/null +++ b/tests/hosting/_signal_app.py @@ -0,0 +1,23 @@ +"""Subprocess entrypoint for signal-handling tests. + +Boots a tiny long-running service under ``run_app`` (which wires +``shutdown_on_signal``) and prints a marker on shutdown so the parent test +can verify it observed the signal cleanly. +""" + +from __future__ import annotations + +import sys + +from localpost.hosting import ServiceLifetime, run_app + + +async def long_running(lt: ServiceLifetime) -> None: + print("READY", flush=True) + lt.set_started() + await lt.shutting_down.wait() + print("STOPPED", flush=True) + + +if __name__ == "__main__": + sys.exit(run_app(long_running)) diff --git a/tests/hosting/lifetime_extras.py b/tests/hosting/lifetime_extras.py new file mode 100644 index 0000000..5493949 --- /dev/null +++ b/tests/hosting/lifetime_extras.py @@ -0,0 +1,231 @@ +"""Tests for the smaller pieces of the hosting public surface that the +existing host.py / hosted_service.py tests don't cover: contextvar accessors, +defer/adefer, observe_services, cancel_on_shutdown / cancel_on_stop. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager, contextmanager + +import anyio +import pytest + +from localpost.hosting import ( + ServiceLifetime, + ServiceLifetimeView, + observe_services, + serve, +) +from localpost.hosting._host import current_app, current_service + +pytestmark = pytest.mark.anyio + + +# --- current_service / current_app ------------------------------------------- + + +class TestCurrentLifetimeAccessors: + async def test_current_service_outside_hosting_raises(self): + with pytest.raises(RuntimeError, match="Not in hosting context"): + current_service() + + async def test_current_app_outside_hosting_raises(self): + with pytest.raises(RuntimeError, match="Not in hosting context"): + current_app() + + async def test_current_service_inside_hosting_returns_view(self): + seen: dict = {} + + async def svc(lt: ServiceLifetime) -> None: + seen["view"] = current_service() + seen["is_view"] = isinstance(seen["view"], ServiceLifetimeView) + lt.set_started() + + async with serve(svc) as lt: + await lt.stopped + + assert seen["is_view"] is True + + async def test_current_app_inside_hosting_returns_root(self): + """``current_app`` in a child service points at the root, not the child.""" + seen: dict = {} + + async def child(lt: ServiceLifetime) -> None: + seen["child"] = current_service() + seen["app"] = current_app() + lt.set_started() + await lt.shutting_down.wait() + + async def parent(lt: ServiceLifetime) -> None: + child_lt = lt.start(child) + await child_lt.started + lt.set_started() + await lt.shutting_down.wait() + child_lt.shutdown() + await child_lt.stopped + + async with serve(parent) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + # In the child, current_service was the child; current_app was the root. + assert seen["child"] is not seen["app"] + + +# --- defer / adefer ---------------------------------------------------------- + + +class TestDefer: + async def test_defer_releases_sync_cm_on_stop(self): + events: list[str] = [] + + @contextmanager + def resource(): + events.append("acquire") + try: + yield "value" + finally: + events.append("release") + + async def svc(lt: ServiceLifetime) -> None: + value = lt.defer(resource()) + assert value == "value" + events.append(f"running:{value}") + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + assert events == ["acquire", "running:value"] + lt.shutdown() + await lt.stopped + + assert events == ["acquire", "running:value", "release"] + + async def test_defer_releases_closable(self): + closed: list[bool] = [False] + + class Closable: + def close(self) -> None: + closed[0] = True + + async def svc(lt: ServiceLifetime) -> None: + lt.defer(Closable()) + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert closed[0] is True + + async def test_adefer_releases_async_cm_on_stop(self): + events: list[str] = [] + + @asynccontextmanager + async def resource(): + events.append("acquire") + try: + yield "value" + finally: + events.append("release") + + async def svc(lt: ServiceLifetime) -> None: + value = await lt.adefer(resource()) + assert value == "value" + events.append(f"running:{value}") + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + assert events == ["acquire", "running:value"] + lt.shutdown() + await lt.stopped + + assert events == ["acquire", "running:value", "release"] + + +# --- observe_services -------------------------------------------------------- + + +class TestObserveServices: + async def test_waits_for_all_started_then_shuts_down_on_exit(self): + timeline: list[str] = [] + + async def make_svc(label: str): + async def svc(lt: ServiceLifetime) -> None: + timeline.append(f"{label}:starting") + lt.set_started() + timeline.append(f"{label}:running") + await lt.shutting_down.wait() + timeline.append(f"{label}:shutting-down") + + return svc + + a_svc, b_svc = await make_svc("a"), await make_svc("b") + + async with serve(a_svc) as a_lt, serve(b_svc) as b_lt: + async with observe_services(a_lt, b_lt): + # Both must be running by the time observe_services yields. + assert "a:running" in timeline + assert "b:running" in timeline + + # On exit both are shut down. + await a_lt.stopped + await b_lt.stopped + + assert "a:shutting-down" in timeline + assert "b:shutting-down" in timeline + + +# --- cancel_on_shutdown / cancel_on_stop ------------------------------------- + + +class TestCancelOnLifetimeEvent: + async def test_cancel_on_shutdown_unblocks_when_service_shuts_down(self): + completed = anyio.Event() + + async def svc(lt: ServiceLifetime) -> None: + async def long_task() -> None: + try: + await anyio.sleep(60) + finally: + completed.set() + + lt.tg.start_soon(lt.view.cancel_on_shutdown(long_task)) + lt.set_started() + await lt.shutting_down.wait() + + async with serve(svc) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + # cancel_on_shutdown wraps the task so its outer scope cancels when + # the service starts shutting down. + assert completed.is_set() + + async def test_cancel_on_stop_unblocks_after_full_stop(self): + completed = anyio.Event() + + async def child(lt: ServiceLifetime) -> None: + async def long_task() -> None: + try: + await anyio.sleep(60) + finally: + completed.set() + + lt.tg.start_soon(lt.view.cancel_on_stop(long_task)) + lt.set_started() + await lt.shutting_down.wait() + + async with serve(child) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert completed.is_set() diff --git a/tests/hosting/multi_service.py b/tests/hosting/multi_service.py new file mode 100644 index 0000000..d3eefaf --- /dev/null +++ b/tests/hosting/multi_service.py @@ -0,0 +1,86 @@ +"""Tests for multi-service composition (run_app(*svcs) → _run_many). + +The composed parent service started by ``_run_many`` doesn't call +``set_started`` itself — it just spawns each child via ``lt.start`` and waits +for them to stop. So the natural shape for these tests is to give each child +a finite task and observe that the composition reaches ``stopped`` only after +every child has. +""" + +from __future__ import annotations + +import anyio +import pytest + +from localpost.hosting import ServiceLifetime, run +from localpost.hosting._host import _run_many + +pytestmark = pytest.mark.anyio + + +class TestRunMany: + async def test_runs_all_children_to_completion(self): + """All children start and complete; the composition stops cleanly.""" + events: list[str] = [] + events_lock = anyio.Lock() + + async def make_finite_child(label: str, sleep: float): + async def svc(lt: ServiceLifetime) -> None: + async with events_lock: + events.append(f"{label}:start") + lt.set_started() + await anyio.sleep(sleep) + async with events_lock: + events.append(f"{label}:stop") + + return svc + + a = await make_finite_child("a", 0.05) + b = await make_finite_child("b", 0.10) + c = await make_finite_child("c", 0.02) + + composed = _run_many(a, b, c) + exit_code = await run(composed) + + assert exit_code == 0 + # All children have started and stopped. + assert {"a:start", "b:start", "c:start", "a:stop", "b:stop", "c:stop"} <= set(events) + + +class TestRunManyChildCrashSiblingsKeepRunningToday: + """Pin the documented limitation of ``_run_many``: when one child crashes, + siblings keep running until they finish on their own. + + See the docstring on ``_run_many``. When the behavior is hardened (so a + crashed child cancels its siblings), this test should flip — assert that + ``b`` was cancelled instead of allowed to finish. + """ + + async def test_sibling_runs_to_completion_when_other_child_crashes(self): + b_finished_naturally = anyio.Event() + a_crashed = anyio.Event() + + async def a(lt: ServiceLifetime) -> None: + lt.set_started() + a_crashed.set() + raise RuntimeError("a crashed") + + async def b(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.sleep(0.2) # finite; long enough to outlive a's crash + b_finished_naturally.set() + + composed = _run_many(a, b) + exit_code = await run(composed) + + # `a` crashed (errors absorbed at the lifetime level → exit_code stays 0 + # for the composition since the parent's _run isn't the one raising; + # the child's own exit_code is 1, but _run_many doesn't propagate that). + assert a_crashed.is_set() + # Today: b ran to natural completion despite a's crash. + assert b_finished_naturally.is_set(), ( + "If this fails, _run_many learned to cancel siblings on child error — " + "flip the assertion." + ) + # exit_code reflects the composed parent, which didn't itself raise. + assert exit_code == 0 diff --git a/tests/hosting/run_app.py b/tests/hosting/run_app.py new file mode 100644 index 0000000..04bee01 --- /dev/null +++ b/tests/hosting/run_app.py @@ -0,0 +1,55 @@ +"""Tests for ``localpost.hosting.run_app`` — the documented sync entrypoint. + +``run_app`` is the headline API users call from ``__main__``. It composes +``shutdown_on_signal`` middleware with the supplied services and drives them +under ``anyio.run`` with the platform-default backend. + +These tests run synchronously (not under pytest-anyio) because ``run_app`` +itself takes care of starting an event loop. +""" + +from __future__ import annotations + +import threading + +import anyio + +from localpost.hosting import ServiceLifetime, run_app + + +def test_run_app_with_single_service_returns_zero(): + """A finite single service runs to completion and ``run_app`` returns 0.""" + + async def finite_svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.sleep(0.05) + + assert run_app(finite_svc) == 0 + + +def test_run_app_with_failing_service_returns_nonzero(): + """If the service raises, exit_code is 1.""" + + async def boom(lt: ServiceLifetime) -> None: + lt.set_started() + raise RuntimeError("boom") + + assert run_app(boom) == 1 + + +def test_run_app_with_multiple_services_runs_all(): + """``run_app(*svcs)`` composes via _run_many and runs every service.""" + finished: list[str] = [] + lock = threading.Lock() + + def make_svc(label: str): + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.sleep(0.02) + with lock: + finished.append(label) + + return svc + + assert run_app(make_svc("a"), make_svc("b"), make_svc("c")) == 0 + assert set(finished) == {"a", "b", "c"} diff --git a/tests/hosting/signal_handling.py b/tests/hosting/signal_handling.py new file mode 100644 index 0000000..e56bba4 --- /dev/null +++ b/tests/hosting/signal_handling.py @@ -0,0 +1,80 @@ +"""Tests for ``localpost.hosting.middleware.shutdown_on_signal``. + +Signals are process-wide, so we exercise the middleware from a subprocess. +``run_app`` wires ``shutdown_on_signal`` automatically, so these tests +double as smoke coverage for the full integration. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time + +import pytest + + +def _spawn() -> subprocess.Popen: + return subprocess.Popen( # noqa: S603 + [sys.executable, "-u", "-m", "tests.hosting._signal_app"], + env={**os.environ}, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def _wait_for_marker(proc: subprocess.Popen, marker: bytes, timeout: float = 5.0) -> bool: + """Read stdout line-by-line waiting for ``marker``. Returns True on hit.""" + end = time.monotonic() + timeout + assert proc.stdout is not None + while time.monotonic() < end: + line = proc.stdout.readline() + if not line: + time.sleep(0.02) + continue + if marker in line: + return True + return False + + +@pytest.mark.parametrize("sig", [signal.SIGTERM, signal.SIGINT]) +def test_shutdown_on_signal_triggers_clean_exit(sig): + """SIGTERM and SIGINT both trigger ``lt.shutdown`` and return exit code 0.""" + proc = _spawn() + try: + assert _wait_for_marker(proc, b"READY"), "service never reported READY" + proc.send_signal(sig) + rc = proc.wait(timeout=5) + assert rc == 0, f"unexpected exit code {rc} on {sig.name}" + + # The service printed "STOPPED" before exiting cleanly. + assert proc.stdout is not None + remaining = proc.stdout.read() + assert b"STOPPED" in remaining, f"service did not log clean stop: {remaining!r}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + +def test_second_sigint_forces_immediate_stop(): + """Two consecutive SIGINTs: the second one triggers ``lt.stop`` (forced).""" + proc = _spawn() + try: + assert _wait_for_marker(proc, b"READY"), "service never reported READY" + + # First SIGINT: graceful shutdown begins. Service is in + # ``await lt.shutting_down.wait()`` and will move to the print. + proc.send_signal(signal.SIGINT) + # Second SIGINT immediately: ``stop`` cancels the run scope. + proc.send_signal(signal.SIGINT) + + rc = proc.wait(timeout=5) + # Either path produces a successful exit — the cancellation is internal. + assert rc in (0, 1), f"unexpected exit {rc}" + finally: + if proc.poll() is None: + proc.kill() + proc.wait() From 30132b0058fcf185795fa22bf283794e428de3aa Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 13:20:07 +0400 Subject: [PATCH 094/286] bench: add HTTP macro + micro benchmark suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macro: oha-driven runner under benchmarks/http/, comparing LocalPost's native handler / wsgi_server / flask_server against Flask on Cheroot, Gunicorn, Granian, and Starlette on Uvicorn / Granian. Three scenarios (plaintext, path_param, json_post). Output goes to benchmarks/http/results/ (gitignored). Micro: pytest-benchmark suite under benchmarks/micro/ for URITemplate and Router build/dispatch. Run via `just bench-micro`; kept out of the default test run. Drops the four stale localpost/http/_benchmark* sketches (broken since the http.worker → http._service rename). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + benchmarks/README.md | 120 ++++++++++ benchmarks/__init__.py | 0 benchmarks/http/__init__.py | 0 benchmarks/http/apps/__init__.py | 0 benchmarks/http/apps/_cli.py | 14 ++ benchmarks/http/apps/_flask_app.py | 26 +++ benchmarks/http/apps/_starlette_app.py | 34 +++ benchmarks/http/apps/flask_cheroot.py | 24 ++ benchmarks/http/apps/flask_granian.py | 32 +++ benchmarks/http/apps/flask_gunicorn.py | 47 ++++ benchmarks/http/apps/localpost_flask.py | 20 ++ benchmarks/http/apps/localpost_native.py | 36 +++ benchmarks/http/apps/localpost_wsgi.py | 19 ++ benchmarks/http/apps/starlette_granian.py | 30 +++ benchmarks/http/apps/starlette_uvicorn.py | 26 +++ benchmarks/http/runner.py | 270 ++++++++++++++++++++++ benchmarks/http/scenarios.py | 75 ++++++ benchmarks/micro/__init__.py | 0 benchmarks/micro/bench_router.py | 109 +++++++++ benchmarks/micro/bench_uri_template.py | 35 +++ benchmarks/micro/conftest.py | 1 + justfile | 12 + localpost/http/_benchmark.py | 29 --- localpost/http/_benchmark.sh | 6 - localpost/http/_benchmark_app.py | 30 --- localpost/http/_benchmark_uvicorn.py | 19 -- pyproject.toml | 10 + uv.lock | 107 +++++++++ 29 files changed, 1050 insertions(+), 84 deletions(-) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/http/__init__.py create mode 100644 benchmarks/http/apps/__init__.py create mode 100644 benchmarks/http/apps/_cli.py create mode 100644 benchmarks/http/apps/_flask_app.py create mode 100644 benchmarks/http/apps/_starlette_app.py create mode 100644 benchmarks/http/apps/flask_cheroot.py create mode 100644 benchmarks/http/apps/flask_granian.py create mode 100644 benchmarks/http/apps/flask_gunicorn.py create mode 100644 benchmarks/http/apps/localpost_flask.py create mode 100644 benchmarks/http/apps/localpost_native.py create mode 100644 benchmarks/http/apps/localpost_wsgi.py create mode 100644 benchmarks/http/apps/starlette_granian.py create mode 100644 benchmarks/http/apps/starlette_uvicorn.py create mode 100644 benchmarks/http/runner.py create mode 100644 benchmarks/http/scenarios.py create mode 100644 benchmarks/micro/__init__.py create mode 100644 benchmarks/micro/bench_router.py create mode 100644 benchmarks/micro/bench_uri_template.py create mode 100644 benchmarks/micro/conftest.py delete mode 100644 localpost/http/_benchmark.py delete mode 100644 localpost/http/_benchmark.sh delete mode 100644 localpost/http/_benchmark_app.py delete mode 100644 localpost/http/_benchmark_uvicorn.py diff --git a/.gitignore b/.gitignore index 15201ac..dc9de15 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,6 @@ cython_debug/ # PyPI configuration file .pypirc + +# Benchmark output — regenerated by `just bench-http`, not authoritative. +benchmarks/http/results/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..777e110 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,120 @@ +# LocalPost benchmarks + +Two independent suites: + +- **`http/`** — macro HTTP load benchmarks. Compare LocalPost against peer + servers (Gunicorn, Cheroot, Granian, Uvicorn) on a fixed Flask / Starlette + workload using [`oha`](https://github.com/hatoo/oha) as the load generator. + Goal: publishable comparison numbers. +- **`micro/`** — `pytest-benchmark` micro-benchmarks for `URITemplate` and + `Router`. Goal: catch perf regressions in the deterministic core. + +Both are kept out of the default test run (`just tests`). + +## Quick start + +```bash +# macro (HTTP load) — 8 stacks × 3 scenarios at 20s each = ~8 minutes +brew install oha # one-time prereq +just bench-http + +# faster sanity sweep +just bench-http --duration 5 --stacks localpost_native,flask_gunicorn + +# micro (pytest-benchmark) +just bench-micro +``` + +## What's compared (macro) + +Three "app types", each implementing the same three scenarios: + +| App type | Stacks | +|------------|-------------------------------------------------------------------------------------------| +| Native | `localpost_native` | +| WSGI/Flask | `localpost_wsgi`, `localpost_flask`, `flask_cheroot`, `flask_gunicorn`, `flask_granian` | +| ASGI | `starlette_uvicorn`, `starlette_granian` | + +Scenarios — defined in [`http/scenarios.py`](http/scenarios.py): + +| Scenario | Method | Path | Concurrency | +|--------------|--------|------------------|-------------| +| `plaintext` | GET | `/ping` | 64 | +| `path_param` | GET | `/hello/{name}` | 64 | +| `json_post` | POST | `/echo` | 32 | + +All servers are configured to be roughly comparable — single process, sized +worker pool (`max_concurrency=32` for LocalPost, `--threads 32` for +Gunicorn/Granian-WSGI, etc.). We're measuring server overhead, not the +multiplicative effect of more processes. + +### How a run works + +`benchmarks/http/runner.py`, for each (stack, scenario): +1. `subprocess.Popen` the stack as `python -m benchmarks.http.apps.`. +2. Poll `127.0.0.1:` with TCP connect until ready (≤ 10 s). +3. Fire `oha --no-tui -j -z s -c ...`. +4. Parse JSON → RPS, p50/p90/p99, status histogram. +5. SIGTERM the stack, wait, move on. + +Output: +- `http/results/latest.json` — raw cells (re-parseable). +- `http/results/RESULTS.md` — markdown summary, one table per scenario. + +### Caveats + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous; consider + running with everything else closed. The relative ordering on the *same* + run is what matters; absolute RPS will shift run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally / on a + dedicated machine. +- **Single process by design.** All servers configured `workers=1` to + isolate the server-layer overhead. Real deployments multiply by N + workers; the relative ordering still holds. + +## What's compared (micro) + +`pytest-benchmark`-driven, runs in-process: + +- `bench_uri_template.py` — `URITemplate.parse`, `.match` (hit / miss / + multi-var). +- `bench_router.py` — `Routes.build`, `Router.wsgi` dispatch (literal hit, + parameterised hit, 404, 405). + +Use `--benchmark-compare` / `--benchmark-autosave` to compare against a +saved baseline. See [pytest-benchmark +docs](https://pytest-benchmark.readthedocs.io/en/latest/comparing.html). + +```bash +# Save a baseline before changing code +just bench-micro --benchmark-autosave + +# Then later, compare +just bench-micro --benchmark-compare --benchmark-compare-fail=mean:10% +``` + +Micro-benchmarks are **not** wired to a CI check. If you want CI-stable +regression gates later, swap `pytest-benchmark` for +[`pytest-codspeed`](https://docs.codspeed.io/) — it runs the same fixtures +under deterministic instrumentation on Codspeed's infra. No code changes +beyond the dependency. + +## Adding a new stack / scenario + +- **Stack:** drop a `python -m`-runnable module under `http/apps/` that + takes `--port`, binds 127.0.0.1, and serves the routes from + `scenarios.py`. Add the module name to `STACKS` in `http/runner.py`. +- **Scenario:** add a `Scenario(...)` to `SCENARIOS` in `scenarios.py`, + then implement the route in every app (typically by extending + `_flask_app.py` and `_starlette_app.py`). + +## Roadmap (not committed) + +- A FastAPI app variant — same engine as Starlette, but pulls in Pydantic + overhead. Useful as a third app type. +- Streaming + keep-alive scenarios. +- Plotly-rendered HTML chart from `latest.json`. +- A scheduler micro-bench suite (trigger composition, `ScheduledTask` + setup/teardown). diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/http/__init__.py b/benchmarks/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/http/apps/__init__.py b/benchmarks/http/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/http/apps/_cli.py b/benchmarks/http/apps/_cli.py new file mode 100644 index 0000000..23ec187 --- /dev/null +++ b/benchmarks/http/apps/_cli.py @@ -0,0 +1,14 @@ +"""Tiny shared CLI helper for app modules. + +Each app reads a ``--port`` (default 8000) and binds 127.0.0.1. +""" + +from __future__ import annotations + +import argparse + + +def parse_port(default: int = 8000) -> int: + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=default) + return p.parse_args().port diff --git a/benchmarks/http/apps/_flask_app.py b/benchmarks/http/apps/_flask_app.py new file mode 100644 index 0000000..2e1208e --- /dev/null +++ b/benchmarks/http/apps/_flask_app.py @@ -0,0 +1,26 @@ +"""Flask app shared by every WSGI-side stack.""" + +from __future__ import annotations + +from flask import Flask, Response +from flask import request as flask_request + +from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY + + +def build_app() -> Flask: + app = Flask(__name__) + + @app.get("/ping") + def ping() -> Response: + return Response(PING_BODY, mimetype="text/plain") + + @app.get("/hello/") + def hello(name: str) -> Response: + return Response(f"{HELLO_PREFIX}{name}".encode(), mimetype="text/plain") + + @app.post("/echo") + def echo() -> Response: + return Response(flask_request.get_data(cache=False), mimetype="application/json") + + return app diff --git a/benchmarks/http/apps/_starlette_app.py b/benchmarks/http/apps/_starlette_app.py new file mode 100644 index 0000000..a5c55ef --- /dev/null +++ b/benchmarks/http/apps/_starlette_app.py @@ -0,0 +1,34 @@ +"""Starlette app shared by every ASGI-side stack.""" + +from __future__ import annotations + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route + +from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY + + +async def _ping(_: Request) -> Response: + return Response(PING_BODY, media_type="text/plain") + + +async def _hello(req: Request) -> Response: + name = req.path_params["name"] + return Response(f"{HELLO_PREFIX}{name}".encode(), media_type="text/plain") + + +async def _echo(req: Request) -> Response: + body = await req.body() + return Response(body, media_type="application/json") + + +def build_app() -> Starlette: + return Starlette( + routes=[ + Route("/ping", _ping, methods=["GET"]), + Route("/hello/{name}", _hello, methods=["GET"]), + Route("/echo", _echo, methods=["POST"]), + ] + ) diff --git a/benchmarks/http/apps/flask_cheroot.py b/benchmarks/http/apps/flask_cheroot.py new file mode 100644 index 0000000..4aadf3a --- /dev/null +++ b/benchmarks/http/apps/flask_cheroot.py @@ -0,0 +1,24 @@ +"""Flask app served by Cheroot's WSGI server.""" + +from __future__ import annotations + +import sys + +from cheroot.wsgi import Server as WSGIServer + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._flask_app import build_app + + +def main() -> int: + port = parse_port() + server = WSGIServer(("127.0.0.1", port), build_app(), numthreads=32) + try: + server.start() + except KeyboardInterrupt: + server.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/flask_granian.py b/benchmarks/http/apps/flask_granian.py new file mode 100644 index 0000000..5334591 --- /dev/null +++ b/benchmarks/http/apps/flask_granian.py @@ -0,0 +1,32 @@ +"""Flask app served by Granian (WSGI mode, 1 worker, 32 blocking threads).""" + +from __future__ import annotations + +import sys + +from granian import Granian +from granian.constants import Interfaces + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._flask_app import build_app + +# Granian re-imports this module in each worker; the worker uses ``app`` directly. +app = build_app() + + +def main() -> int: + port = parse_port() + Granian( + target=f"{__name__}:app", + address="127.0.0.1", + port=port, + interface=Interfaces.WSGI, + workers=1, + blocking_threads=32, + log_enabled=False, + ).serve() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/flask_gunicorn.py b/benchmarks/http/apps/flask_gunicorn.py new file mode 100644 index 0000000..999b17c --- /dev/null +++ b/benchmarks/http/apps/flask_gunicorn.py @@ -0,0 +1,47 @@ +"""Flask app served by Gunicorn (1 worker, gthread, 32 threads). + +Single-worker on purpose: we measure server overhead, not the multiplicative +effect of more processes. +""" + +from __future__ import annotations + +import sys + +from gunicorn.app.base import BaseApplication + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._flask_app import build_app + + +class _GunicornApp(BaseApplication): + def __init__(self, app, options: dict): + self._app = app + self._options = options + super().__init__() + + def load_config(self) -> None: + for k, v in self._options.items(): + self.cfg.set(k, v) + + def load(self): + return self._app + + +def main() -> int: + port = parse_port() + options = { + "bind": f"127.0.0.1:{port}", + "workers": 1, + "threads": 32, + "worker_class": "gthread", + "accesslog": None, + "errorlog": "-", + "loglevel": "warning", + } + _GunicornApp(build_app(), options).run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_flask.py b/benchmarks/http/apps/localpost_flask.py new file mode 100644 index 0000000..a2c2308 --- /dev/null +++ b/benchmarks/http/apps/localpost_flask.py @@ -0,0 +1,20 @@ +"""LocalPost ``flask_server`` (native Flask adapter) hosting the shared Flask app.""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._flask_app import build_app +from localpost.hosting import run_app +from localpost.http import ServerConfig +from localpost.http.flask import flask_server + + +def main() -> int: + port = parse_port() + return run_app(flask_server(ServerConfig(host="127.0.0.1", port=port), build_app(), max_concurrency=32)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py new file mode 100644 index 0000000..7c6c764 --- /dev/null +++ b/benchmarks/http/apps/localpost_native.py @@ -0,0 +1,36 @@ +"""LocalPost native handler — Router + http_server, no framework.""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.scenarios import PING_BODY, hello_body +from localpost.hosting import run_app +from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server + + +def _ping(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [PING_BODY]) + + +def _hello(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) + + +def _echo(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "application/json"}, [ctx.body()]) + + +def main() -> int: + port = parse_port() + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/hello/{name}")(_hello) + routes.post("/echo")(_echo) + handler = routes.build().as_handler() + return run_app(http_server(ServerConfig(host="127.0.0.1", port=port), handler, max_concurrency=32)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_wsgi.py b/benchmarks/http/apps/localpost_wsgi.py new file mode 100644 index 0000000..33c6650 --- /dev/null +++ b/benchmarks/http/apps/localpost_wsgi.py @@ -0,0 +1,19 @@ +"""LocalPost ``wsgi_server`` hosting the shared Flask app.""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._flask_app import build_app +from localpost.hosting import run_app +from localpost.http import ServerConfig, wsgi_server + + +def main() -> int: + port = parse_port() + return run_app(wsgi_server(ServerConfig(host="127.0.0.1", port=port), build_app(), max_concurrency=32)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/starlette_granian.py b/benchmarks/http/apps/starlette_granian.py new file mode 100644 index 0000000..a1c3c0a --- /dev/null +++ b/benchmarks/http/apps/starlette_granian.py @@ -0,0 +1,30 @@ +"""Starlette app served by Granian (ASGI mode).""" + +from __future__ import annotations + +import sys + +from granian import Granian +from granian.constants import Interfaces + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._starlette_app import build_app + +app = build_app() + + +def main() -> int: + port = parse_port() + Granian( + target=f"{__name__}:app", + address="127.0.0.1", + port=port, + interface=Interfaces.ASGI, + workers=1, + log_enabled=False, + ).serve() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/starlette_uvicorn.py b/benchmarks/http/apps/starlette_uvicorn.py new file mode 100644 index 0000000..bde48c8 --- /dev/null +++ b/benchmarks/http/apps/starlette_uvicorn.py @@ -0,0 +1,26 @@ +"""Starlette app served by Uvicorn.""" + +from __future__ import annotations + +import sys + +import uvicorn + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._starlette_app import build_app + + +def main() -> int: + port = parse_port() + uvicorn.run( + build_app(), + host="127.0.0.1", + port=port, + log_level="warning", + access_log=False, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py new file mode 100644 index 0000000..32f5aad --- /dev/null +++ b/benchmarks/http/runner.py @@ -0,0 +1,270 @@ +"""Macro HTTP benchmark runner. + +For each (stack, scenario) cell: + 1. Boot the stack as a subprocess (``python -m benchmarks.http.apps.``). + 2. Poll ``/ping`` until ready (or fail fast). + 3. Run ``oha --json -z s -c ...``. + 4. Parse JSON; record RPS + p50/p90/p99 + status histogram. + 5. SIGTERM the stack, wait, move on. + +Output: + results/latest.json — raw cells. + results/RESULTS.md — markdown summary, one table per scenario. + +Run from the repo root:: + + just bench-http # full matrix + just bench-http --duration 5 # quick sanity + just bench-http --stacks localpost_native,flask_gunicorn +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import signal +import socket +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path + +from benchmarks.http.scenarios import SCENARIOS, Scenario + +REPO_ROOT = Path(__file__).resolve().parents[2] +RESULTS_DIR = Path(__file__).parent / "results" + +STACKS: tuple[str, ...] = ( + "localpost_native", + "localpost_wsgi", + "localpost_flask", + "flask_cheroot", + "flask_gunicorn", + "flask_granian", + "starlette_uvicorn", + "starlette_granian", +) + + +@dataclass(slots=True) +class Cell: + stack: str + scenario: str + rps: float + p50_ms: float + p90_ms: float + p99_ms: float + total_requests: int + success_rate: float + status_2xx: int + status_other: int + + +@dataclass(slots=True) +class RunReport: + started_at: str + duration_s: int + host: str + python: str + cells: list[Cell] + + +def _pick_port(base: int, offset: int) -> int: + # Naive: a fixed offset per stack invocation. Conflicts are rare in practice + # and surface clearly via the readiness probe. + return base + offset + + +def _wait_ready(port: int, deadline_s: float = 10.0) -> bool: + end = time.monotonic() + deadline_s + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + +def _spawn_stack(stack: str, port: int) -> subprocess.Popen: + return subprocess.Popen( # noqa: S603 + [sys.executable, "-m", f"benchmarks.http.apps.{stack}", "--port", str(port)], + cwd=REPO_ROOT, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +def _kill(proc: subprocess.Popen) -> None: + if proc.poll() is not None: + return + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +def _run_oha(scenario: Scenario, port: int, duration_s: int) -> dict: + base = f"http://127.0.0.1:{port}" + cmd = [ + "oha", + "--no-tui", + "--output-format", + "json", + "-z", + f"{duration_s}s", + "-c", + str(scenario.concurrency), + "-m", + scenario.method, + ] + if scenario.body is not None: + cmd += ["-d", scenario.body.decode("utf-8")] + if scenario.content_type is not None: + cmd += ["-T", scenario.content_type] + cmd.append(scenario.url(base)) + + result = subprocess.run( # noqa: S603 + cmd, + check=False, + capture_output=True, + text=True, + timeout=duration_s + 30, + ) + if result.returncode != 0: + raise RuntimeError(f"oha exited {result.returncode}: {result.stderr.strip()}") + return json.loads(result.stdout) + + +def _parse_oha(raw: dict) -> dict: + summary = raw.get("summary", {}) + pct = raw.get("latencyPercentiles", {}) + status = raw.get("statusCodeDistribution", {}) + s2xx = sum(v for k, v in status.items() if k.startswith("2")) + sother = sum(v for k, v in status.items() if not k.startswith("2")) + total = s2xx + sother + return { + "rps": float(summary.get("requestsPerSec", 0.0)), + "p50_ms": float(pct.get("p50", 0.0)) * 1000, + "p90_ms": float(pct.get("p90", 0.0)) * 1000, + "p99_ms": float(pct.get("p99", 0.0)) * 1000, + "total_requests": total, + "success_rate": float(summary.get("successRate", 0.0)), + "status_2xx": s2xx, + "status_other": sother, + } + + +def _run_cell(stack: str, scenario: Scenario, port: int, duration_s: int) -> Cell | None: + proc = _spawn_stack(stack, port) + try: + if not _wait_ready(port): + stderr = proc.stderr.read().decode() if proc.stderr else "" + print(f" [{stack}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) + return None + raw = _run_oha(scenario, port, duration_s) + parsed = _parse_oha(raw) + return Cell(stack=stack, scenario=scenario.name, **parsed) + except Exception as e: # noqa: BLE001 + print(f" [{stack}/{scenario.name}] ERROR: {e}", file=sys.stderr) + return None + finally: + _kill(proc) + + +def _write_results(report: RunReport) -> None: + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + (RESULTS_DIR / "latest.json").write_text(json.dumps(asdict(report), indent=2) + "\n") + (RESULTS_DIR / "RESULTS.md").write_text(_render_markdown(report)) + + +def _render_markdown(report: RunReport) -> str: + out: list[str] = [] + out.append("# HTTP benchmark results\n") + out.append(f"- Run at: `{report.started_at}`") + out.append(f"- Host: `{report.host}`") + out.append(f"- Python: `{report.python}`") + out.append(f"- Duration per cell: `{report.duration_s}s`") + out.append("") + out.append("> Numbers are single-process, single-host. Don't read absolute RPS as gospel —") + out.append("> what matters is the relative ordering on the same machine in one run.") + out.append("") + + cells_by_scenario: dict[str, list[Cell]] = {} + for c in report.cells: + cells_by_scenario.setdefault(c.scenario, []).append(c) + + for scenario_name, cells in cells_by_scenario.items(): + cells.sort(key=lambda c: c.rps, reverse=True) + out.append(f"## {scenario_name}\n") + out.append("| Stack | RPS | p50 (ms) | p90 (ms) | p99 (ms) | 2xx | non-2xx |") + out.append("|---|---:|---:|---:|---:|---:|---:|") + out.extend( + f"| `{c.stack}` | {c.rps:,.0f} | {c.p50_ms:.2f} | {c.p90_ms:.2f} " + f"| {c.p99_ms:.2f} | {c.status_2xx:,} | {c.status_other:,} |" + for c in cells + ) + out.append("") + return "\n".join(out) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--duration", type=int, default=20, help="seconds per cell (default: 20)") + p.add_argument("--stacks", default="", help="comma-separated stack filter (default: all)") + p.add_argument("--scenarios", default="", help="comma-separated scenario filter (default: all)") + p.add_argument("--port-base", type=int, default=18800) + args = p.parse_args() + + if shutil.which("oha") is None: + print("error: 'oha' not found on PATH. Install via 'brew install oha'.", file=sys.stderr) + return 2 + + stacks = tuple(s for s in (args.stacks.split(",") if args.stacks else STACKS) if s) + unknown = [s for s in stacks if s not in STACKS] + if unknown: + print(f"error: unknown stack(s): {unknown}. Known: {list(STACKS)}", file=sys.stderr) + return 2 + + scenarios = tuple(s for s in SCENARIOS if not args.scenarios or s.name in args.scenarios.split(",")) + if not scenarios: + print("error: no scenarios selected.", file=sys.stderr) + return 2 + + cells: list[Cell] = [] + started_at = datetime.now(UTC).isoformat(timespec="seconds") + print(f"Running {len(stacks)} stack(s) x {len(scenarios)} scenario(s) @ {args.duration}s each.") + for stack_idx, stack in enumerate(stacks): + for scen_idx, scenario in enumerate(scenarios): + port = _pick_port(args.port_base, stack_idx * len(SCENARIOS) + scen_idx) + print(f" [{stack}/{scenario.name}] port={port} c={scenario.concurrency} ...", flush=True) + cell = _run_cell(stack, scenario, port, args.duration) + if cell is not None: + print( + f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " + f"({cell.status_2xx} 2xx / {cell.status_other} other)" + ) + cells.append(cell) + + report = RunReport( + started_at=started_at, + duration_s=args.duration, + host=f"{platform.system()} {platform.release()} {platform.machine()}", + python=sys.version.split()[0], + cells=cells, + ) + _write_results(report) + print(f"\nWrote {RESULTS_DIR / 'RESULTS.md'} and {RESULTS_DIR / 'latest.json'}.") + return 0 if cells else 1 + + +if __name__ == "__main__": + os.environ.setdefault("PYTHONUNBUFFERED", "1") + sys.exit(main()) diff --git a/benchmarks/http/scenarios.py b/benchmarks/http/scenarios.py new file mode 100644 index 0000000..ca410e5 --- /dev/null +++ b/benchmarks/http/scenarios.py @@ -0,0 +1,75 @@ +"""Shared scenario definitions: identical contracts every stack must implement. + +Three scenarios for v1: + +* ``plaintext`` — ``GET /ping`` → ``b"pong"``, text/plain. +* ``path_param`` — ``GET /hello/{name}`` → ``f"hi {name}".encode()``. +* ``json_post`` — ``POST /echo`` with JSON body → echo body verbatim, application/json. + +Every app module under ``benchmarks/http/apps/`` implements these three. The +runner uses ``SCENARIOS`` to know what to fire at each stack and how to verify +the response shape. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True, slots=True) +class Scenario: + name: str + method: str + path: str + body: bytes | None + content_type: str | None + expected_status: int + concurrency: int + """oha -c value.""" + + def url(self, base: str) -> str: + return f"{base}{self.path}" + + +_JSON_BODY: Final = b'{"name":"world","numbers":[1,2,3,4,5,6,7,8,9,10]}' + +SCENARIOS: Final[tuple[Scenario, ...]] = ( + Scenario( + name="plaintext", + method="GET", + path="/ping", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="path_param", + method="GET", + path="/hello/world", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="json_post", + method="POST", + path="/echo", + body=_JSON_BODY, + content_type="application/json", + expected_status=200, + concurrency=32, + ), +) + + +# Wire-format constants every app implementation reuses. +PING_BODY: Final = b"pong" +HELLO_PREFIX: Final = "hi " +JSON_ECHO_BODY: Final = _JSON_BODY + + +def hello_body(name: str) -> bytes: + return f"{HELLO_PREFIX}{name}".encode() diff --git a/benchmarks/micro/__init__.py b/benchmarks/micro/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/micro/bench_router.py b/benchmarks/micro/bench_router.py new file mode 100644 index 0000000..0a40039 --- /dev/null +++ b/benchmarks/micro/bench_router.py @@ -0,0 +1,109 @@ +"""Micro-benchmarks for ``Router`` build + dispatch. + +Run:: + + just bench-micro + # or + pytest benchmarks/micro/bench_router.py --benchmark-only + +Build cost is measured separately from dispatch. Dispatch is exercised via +the WSGI surface (synthetic ``environ`` dict) — it's the simplest synchronous +entry point that goes through the full match → handler → response pipeline. +""" + +from __future__ import annotations + +from io import BytesIO + +from localpost.http.router import RequestCtx, Response, Routes + + +def _ok(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [b"ok"]) + + +def _build_routes_20() -> Routes: + """A mixed set of 20 routes — 10 literal, 10 parameterised.""" + routes = Routes() + # Literal routes + for path in ( + "/", + "/health", + "/ready", + "/metrics", + "/version", + "/login", + "/logout", + "/me", + "/admin", + "/admin/dashboard", + ): + routes.get(path)(_ok) + # Parameterised routes + for path in ( + "/books/{id}", + "/users/{id}", + "/users/{id}/posts", + "/users/{id}/posts/{post}", + "/orgs/{org}/users/{user}", + "/orgs/{org}/users/{user}/posts/{post}", + "/files/{bucket}/{key}", + "/v1/items/{id}", + "/v1/items/{id}/comments", + "/v1/items/{id}/comments/{cid}", + ): + routes.get(path)(_ok) + return routes + + +def _wsgi_environ(method: str, path: str, body: bytes = b"") -> dict: + return { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "QUERY_STRING": "", + "wsgi.input": BytesIO(body), + } + + +def _noop_start_response(*_args, **_kwargs) -> None: + return None + + +def bench_routes_build_20(benchmark) -> None: + routes = _build_routes_20() + benchmark(routes.build) + + +def bench_dispatch_literal_first(benchmark) -> None: + """Dispatch a literal route — should be the cheapest path.""" + router = _build_routes_20().build() + env = _wsgi_environ("GET", "/health") + benchmark(router.wsgi, env, _noop_start_response) + + +def bench_dispatch_param_shallow(benchmark) -> None: + """Dispatch ``/books/{id}`` — one variable.""" + router = _build_routes_20().build() + env = _wsgi_environ("GET", "/books/00a7a2d4-18e4-11f1-899b-d33838f3bef0") + benchmark(router.wsgi, env, _noop_start_response) + + +def bench_dispatch_param_deep(benchmark) -> None: + """Dispatch ``/orgs/{org}/users/{user}/posts/{post}`` — three variables, longest path.""" + router = _build_routes_20().build() + env = _wsgi_environ("GET", "/orgs/anthropic/users/alex/posts/42") + benchmark(router.wsgi, env, _noop_start_response) + + +def bench_dispatch_404(benchmark) -> None: + """Path matches no route.""" + router = _build_routes_20().build() + env = _wsgi_environ("GET", "/nope/nothing/here") + benchmark(router.wsgi, env, _noop_start_response) + + +def bench_dispatch_405(benchmark) -> None: + """Path matches but method doesn't — exercises the method-not-allowed branch.""" + router = _build_routes_20().build() + env = _wsgi_environ("DELETE", "/health") + benchmark(router.wsgi, env, _noop_start_response) diff --git a/benchmarks/micro/bench_uri_template.py b/benchmarks/micro/bench_uri_template.py new file mode 100644 index 0000000..d7d8b76 --- /dev/null +++ b/benchmarks/micro/bench_uri_template.py @@ -0,0 +1,35 @@ +"""Micro-benchmarks for ``URITemplate``. + +Run:: + + just bench-micro + # or + pytest benchmarks/micro/bench_uri_template.py --benchmark-only +""" + +from __future__ import annotations + +from localpost.http.router import URITemplate + + +def bench_parse_simple(benchmark) -> None: + benchmark(URITemplate.parse, "/books/{book_id}") + + +def bench_parse_three_vars(benchmark) -> None: + benchmark(URITemplate.parse, "/orgs/{org}/users/{user}/posts/{post}") + + +def bench_match_hit_one_var(benchmark) -> None: + t = URITemplate.parse("/books/{book_id}") + benchmark(t.match, "/books/00a7a2d4-18e4-11f1-899b-d33838f3bef0") + + +def bench_match_hit_three_vars(benchmark) -> None: + t = URITemplate.parse("/orgs/{org}/users/{user}/posts/{post}") + benchmark(t.match, "/orgs/anthropic/users/alex/posts/42") + + +def bench_match_miss(benchmark) -> None: + t = URITemplate.parse("/books/{book_id}") + benchmark(t.match, "/users/alex/posts/42") diff --git a/benchmarks/micro/conftest.py b/benchmarks/micro/conftest.py new file mode 100644 index 0000000..85e67ac --- /dev/null +++ b/benchmarks/micro/conftest.py @@ -0,0 +1 @@ +"""pytest-benchmark autodiscovery — no shared fixtures yet.""" diff --git a/justfile b/justfile index fd6d2a4..d4da41e 100755 --- a/justfile +++ b/justfile @@ -47,6 +47,18 @@ unit-tests: integration-tests: pytest -m "integration" -n auto -v +[doc("Run macro HTTP benchmarks (oha-driven, requires `brew install oha`)")] +bench-http *args: + uv run --group bench --group dev-http --group dev-hosting-services \ + python -m benchmarks.http.runner {{ args }} + +[doc("Run micro-benchmarks (router, URI template) via pytest-benchmark")] +bench-micro *args: + uv run --group bench pytest benchmarks/micro/ \ + --benchmark-only \ + -o python_functions='bench_*' \ + {{ args }} + [doc("Inverse dependency tree for a package, to understand why it is installed")] why package: uv tree --invert --package {{ package }} diff --git a/localpost/http/_benchmark.py b/localpost/http/_benchmark.py deleted file mode 100644 index c4ab4db..0000000 --- a/localpost/http/_benchmark.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -import logging -import signal - -import anyio - -from localpost.http.config import WorkerConfig -from localpost.http.worker import http_server - -from ._benchmark_app import app - - -def _flask_app_bench(): - logging.basicConfig(level=logging.DEBUG) - - async def _run(): - async with http_server(app, WorkerConfig(max_requests=100)) as w: - with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: - async for _ in signals: - w.shutdown() - break - - # noinspection PyTypeChecker - anyio.run(_run) - - -if __name__ == "__main__": - _flask_app_bench() diff --git a/localpost/http/_benchmark.sh b/localpost/http/_benchmark.sh deleted file mode 100644 index 61ac6c1..0000000 --- a/localpost/http/_benchmark.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -# TODO For each option: Run hey, output CSV - - -# TODO After all: compare CSV results, output summary table diff --git a/localpost/http/_benchmark_app.py b/localpost/http/_benchmark_app.py deleted file mode 100644 index 4c8f878..0000000 --- a/localpost/http/_benchmark_app.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from flask import Flask, stream_with_context -from flask import request as flask_request - -app = Flask(__name__) - - -@app.route("/hello/") -def hello(name): - user_agent = flask_request.headers.get("User-Agent", "Unknown") - return f"Hello, {name}! Your User-Agent is: {user_agent}\n" - - -@app.route("/hello-stream/") -@stream_with_context -def hello_stream(name): - user_agent = flask_request.headers.get("User-Agent", "Unknown") - yield f"Hello, {name}! " - yield f"Your User-Agent is: {user_agent}\n" - - -@app.route("/hello-data/", methods=["POST"]) -@stream_with_context -def hello_data(name): - user_agent = flask_request.headers.get("User-Agent", "Unknown") - yield f"Hello, {name}! " - yield f"Your User-Agent is: {user_agent}\n" - json_data = flask_request.get_json(force=True, cache=False) - yield f"And you sent: {json_data}\n" diff --git a/localpost/http/_benchmark_uvicorn.py b/localpost/http/_benchmark_uvicorn.py deleted file mode 100644 index 7f78df0..0000000 --- a/localpost/http/_benchmark_uvicorn.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -import logging - -import uvicorn -from a2wsgi import WSGIMiddleware - -from ._benchmark_app import app - - -def _flask_app_bench(): - logging.basicConfig(level=logging.DEBUG) - - asgi_app = WSGIMiddleware(app) - uvicorn.run(asgi_app, host="0.0.0.0", port=8000, access_log=False) - - -if __name__ == "__main__": - _flask_app_bench() diff --git a/pyproject.toml b/pyproject.toml index af0eeb9..0331321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,15 @@ tests-integration = [ "boto3 ~=1.38", "testcontainers[google,localstack,nats] ~=4.10", ] +bench = [ + # Macro (HTTP load): peer servers + ASGI app stack. + # Flask, cheroot, uvicorn already pulled by dev-http / dev-hosting-services. + "gunicorn ~=23.0", + "granian ~=2.6", + "starlette ~=0.49", + # Micro (pytest-benchmark): regression smoke for router / URI template. + "pytest-benchmark ~=5.1", +] [tool.coverage.run] omit = ["tests/*"] @@ -237,6 +246,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] "examples/*" = ["T201"] +"benchmarks/*" = ["T201"] [tool.uv] build-backend.module-root = "" diff --git a/uv.lock b/uv.lock index bb4e670..3f3fd6e 100644 --- a/uv.lock +++ b/uv.lock @@ -747,6 +747,67 @@ grpc = [ { name = "grpcio" }, ] +[[package]] +name = "granian" +version = "2.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/0c/27aa25280b6c1f323312e83088304da8a7f3e5c1e568d3a560365ec6fa67/granian-2.7.4.tar.gz", hash = "sha256:1dc0530d7ae6b0ae43aafafe771ac0b8c38af68bbd71ab355828817faf13aac1", size = 128212, upload-time = "2026-04-23T11:55:55.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/d9/148024fd3a8bd974bb5c68a0cb48d15df7763fd1364bf090ccc2d423028a/granian-2.7.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c2f40aaecf2ba3d8232e55181c8f6db7bc68d9112a419ab8d5f9e2f33f631f5", size = 6374067, upload-time = "2026-04-23T11:54:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/c53b61a7cb67d33677d96913438eca3d79de1b1b7173a361fcdf2753ade7/granian-2.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8111d5e74b27721e0fdda3edba7c154d44c41b469466857ca3c51b088e3846b", size = 6046338, upload-time = "2026-04-23T11:54:08.684Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/5c9dc91b9c9a05bf6ed0b795d30f4bb8f290d61502779a89ed2fd75f9fb6/granian-2.7.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74adbb6c1920dbf4271b824135639318b2a20ff5e33bc35639a8e2928a777234", size = 7000585, upload-time = "2026-04-23T11:54:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7c/c770593b24a472ab5265a44546f56079757efbf89f8e8b2229a8443e453b/granian-2.7.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b778d356b61e0389c823016ad2be50a634b80d3d28a33922f7ac39553e828ad", size = 6255544, upload-time = "2026-04-23T11:54:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/15/46/796147587edb494a330294cb001cf68520ad8296a7da91d80ec672ac8615/granian-2.7.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3607b091c4ef225ee99150f3b02cb827de8d677b52fc75f0b28893244f7bab27", size = 6875124, upload-time = "2026-04-23T11:54:13.967Z" }, + { url = "https://files.pythonhosted.org/packages/c5/25/b867f624886e11053e7a6235244de26fd864a136e65d12295e728b3e5005/granian-2.7.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3d3cf4fe3cafd9b874d8b749c66c790cbf2b4225f2a7d9fb284c51b77a8e938d", size = 6982394, upload-time = "2026-04-23T11:54:15.733Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e1/5746bfe202bd2f6a1506346463ce52dd015c2b5d03d07a53ecf0fddefa3f/granian-2.7.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:846c9cbfea8684ab13d21d66855ad06dc077fb95b5590e7f5040e79994d6429d", size = 6991457, upload-time = "2026-04-23T11:54:17.325Z" }, + { url = "https://files.pythonhosted.org/packages/e0/45/fc6992839d367b6ae8fa8d88b5e70ec293162c3a2e0e6b90fc426f228df2/granian-2.7.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:d34d97cfe4a7805ecb5b1b1684f3f197bb4baf019d2a9f18e34fd1d697a03a7f", size = 7148499, upload-time = "2026-04-23T11:54:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/fe/12/16ffd64a1213858d4cf824767b398758be807dd1a6df5a303dc76994b6d6/granian-2.7.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f11336e4bcd8ef5c5143b075b5260e37e8431eb36d68564cc39416ca526c797f", size = 7006829, upload-time = "2026-04-23T11:54:20.804Z" }, + { url = "https://files.pythonhosted.org/packages/95/9a/f2fcda200f8739ddf25be72591b7a28897be0ffd952a76ec655e5f877144/granian-2.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:9e0a4370773ec4a0e92a55a33fc700b60003e335480e5c7fe941f4bc3dda2e18", size = 4026771, upload-time = "2026-04-23T11:54:22.36Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/fa7c63afedcb214edb96703cade360d946d5f1ca59ddb0b3d8e04587fb45/granian-2.7.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d11da4a4527ba8dc28b5533d5e3241d8d9212e593195d27c6e72c8a422010af5", size = 6373513, upload-time = "2026-04-23T11:54:24.246Z" }, + { url = "https://files.pythonhosted.org/packages/be/39/3088ce32d940f7982102ea3bdc230090e34ac56dc0bce04f2d03b56ea435/granian-2.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:057a3db87e93eca1a11255dd13b45b5dd83f798a750fd87f02e14d54db5741b6", size = 6045232, upload-time = "2026-04-23T11:54:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/ac/61/588f6b5397ea4f5bd9fc8de4b8cc092c555b8d95371c03d149b3bc419277/granian-2.7.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb63d64c686799cea850c0c328d21adf75e323991a20be04923afc729432d2b5", size = 7001059, upload-time = "2026-04-23T11:54:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/58/63/2affbcecfe96f940744c2086ea3793935d5f6898207590a579c92fc8588f/granian-2.7.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f406648c47569e983f0c58bd0853bac30a2bcdc6227428255ee5cc65a8ee62b6", size = 6255487, upload-time = "2026-04-23T11:54:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/87/ac/31f7155a467020e7640e91af15ca3a70b0e7da210de42e3d3344e5eba8d0/granian-2.7.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd56306eed06e293f4848c5ea997e1d019d1ad13b8252dde1f0bc773aca85ef", size = 6875068, upload-time = "2026-04-23T11:54:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/402cc903e5c4e82bd363177392d4e1dcab8b27c1f7006c5316c37c597056/granian-2.7.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:732639e612e6b6e8d481f399f367e8c9bbb6f0e1b7b0aa74db340c574ee3dd98", size = 6982487, upload-time = "2026-04-23T11:54:32.704Z" }, + { url = "https://files.pythonhosted.org/packages/d3/92/3878f977bda82fc3a66fc7e95a54366a7b82edd53e6c9fdb3ec053693280/granian-2.7.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:47b8fdbfb369d52bb3fb884514a6a3a7e4d8e81c65fd26e5232985f2b46ebe0f", size = 6990683, upload-time = "2026-04-23T11:54:34.301Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/a1239f3bc4e9034e07cb32403e6a6d26db01bba1c244dd654f6a76bf2612/granian-2.7.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:b679086082bfd7c1aa8c248ef673b715616a4ce58eec6fbeef8b83b30ac84283", size = 7148570, upload-time = "2026-04-23T11:54:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/fef781ea7356b21f671615dd0d53adc00fad81031a9ea506f80d1f46a43d/granian-2.7.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a29191e949a99ffae2807abb7a864f7493f7a744e4fe2ddd2b5cd8db9b71378d", size = 7006976, upload-time = "2026-04-23T11:54:38.135Z" }, + { url = "https://files.pythonhosted.org/packages/56/54/ae2979fc45c06fbb37f595ee10eb6b138b6056202163b8e274d140d3f87b/granian-2.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:07d26325cc69371ea2dc9d3a9cd0cc851c1c8e3dce40aca90e8c204547b5ba7e", size = 4027044, upload-time = "2026-04-23T11:54:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/21/51/10344430e495bfa128dccc114957b33e712e971f91668788c08fe791df73/granian-2.7.4-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4e093fe9511387313ad7ec9a76b0c78397cc584ef3dff47d46c336c5aee9cd8d", size = 6249290, upload-time = "2026-04-23T11:54:41.738Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/c7eda2e71a89a13e174598649f721c63ed3d908c0904b62621e8a433af0f/granian-2.7.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:227889f821526b8b60c5edf31b01fc987c4193bb0fc198c0998e0841e0cb719c", size = 5901799, upload-time = "2026-04-23T11:54:43.708Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/79e51f9f794389a9d6cab3d7c6b834b87d65fba72a43784eb5d2664a57a6/granian-2.7.4-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2b28d4aec5a9f2758a48da1897649a01b70ee1c00f2c4649db574527a3d00943", size = 6037594, upload-time = "2026-04-23T11:54:45.595Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d8/835873a407279435fa0c8e8ac52392d3ba5c9a652bb15c0036aa07d9c302/granian-2.7.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f708fea5024a40e0dfba1c17c1c4b09e02e00ac0ac9ac1e345b409f0c11b71e5", size = 6966672, upload-time = "2026-04-23T11:54:47.242Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/21eacdda27c38e4194de5f9bef36c4045058daf6d58533fadb7c54c70573/granian-2.7.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7006dfe9852cded794bc60008a168faf4dc2ecc18f1d74b5fde545685b699ec", size = 6563668, upload-time = "2026-04-23T11:54:49.751Z" }, + { url = "https://files.pythonhosted.org/packages/bd/06/9b19956d75277df44ee380e873a86b9890c431f2e2bcde32b3ba341f0efa/granian-2.7.4-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:77103af44034e30505fb5577b8214b0ad39cd6cbdc854ff980d4755faf93adaa", size = 6664285, upload-time = "2026-04-23T11:54:51.502Z" }, + { url = "https://files.pythonhosted.org/packages/85/33/740e0c9478be49c0778c4ea1773357680980e10e84b59bc19664033996dc/granian-2.7.4-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b23194e1e0652297086224212605edb4998442511637e732d6009506277f8ff9", size = 6820367, upload-time = "2026-04-23T11:54:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/3453fc1212268a01fee957122f2b1699af0efe50eca07ac570e11d1be12b/granian-2.7.4-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:f62941a4ffa1f1c2c5750cfc0b0ad96aa85d63b016125289779eef8888f5340d", size = 7132366, upload-time = "2026-04-23T11:54:55.123Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ca/8479e4d2a02f210ce68b5dc73c77953ec1dfd3769bf725d06e6ec420d502/granian-2.7.4-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ea6f97d2ade676f1bf49b79088fa4b5640b8b9804b7470218486df3d4be50046", size = 6842094, upload-time = "2026-04-23T11:54:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/0d/96/71f95c73220726aee3e908b3ad2745c4c44fbfba508cb5ed615a9d4d367f/granian-2.7.4-cp313-cp313t-win_amd64.whl", hash = "sha256:759140ceef02ef72e57a184461927d72bcc2ddd3664c3cbbf4def7516f818041", size = 3974523, upload-time = "2026-04-23T11:54:58.541Z" }, + { url = "https://files.pythonhosted.org/packages/98/5d/a0c3d8778cd8aa68131974d34c439a38a00a32953e71e3b549759a5e3cdb/granian-2.7.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c19ebe797d7383cbb3497c599b8201af71f9fff6b18deaf9965d106f61588ab8", size = 6322736, upload-time = "2026-04-23T11:55:00.292Z" }, + { url = "https://files.pythonhosted.org/packages/5e/99/211da053030574f2402c750f3e3e5dc587f5192eac4888affe6ca8894a9f/granian-2.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4cee0bdba9179537669c2fa0afab2ce89327a372f1b2a82f280798da321c996c", size = 6052103, upload-time = "2026-04-23T11:55:02.797Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/23ec1fd519a4c0db961b05d1821869ed6371cbaf8b3d3a0a85c04f89e6ca/granian-2.7.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4bc5b54845bfb5f87537483f25c8f8e6003c3c1b4b0eadf6b93a432d0604265", size = 7000868, upload-time = "2026-04-23T11:55:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/b8798c98c90d3293d9c85580ea6021f148d5ab73ab99d1f82a0e66f73131/granian-2.7.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b550fb98b89465c8192b6e506993de6bfb956838e715ffb58e944aec1afdae99", size = 6257266, upload-time = "2026-04-23T11:55:06.962Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4f/5574db17193d90a5831120a0ce2a2dc64a711110ccb9af5a3630260c3597/granian-2.7.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7100a6a6d3835fec2a207fef536a259dd42d9efdb5c46933cf6f9d55d5bfaad", size = 6849667, upload-time = "2026-04-23T11:55:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/90b85cc6a31cbee772fc8ee731479429a64169e389444a5fdd685d44a342/granian-2.7.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:034ac1bfe8c19b5a7916d35a1ca426845db9ac11215f1b367566aec3b6530549", size = 6902612, upload-time = "2026-04-23T11:55:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/ba203ca40bd406db0412bca70281e44712f941bc6aafb59a628f4811d517/granian-2.7.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:baf1c390a25d3d9840204c39e7b801c909e99e896ae2713d898c46b563cbf962", size = 6927025, upload-time = "2026-04-23T11:55:12.663Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/77e2abfba54523943eea275ebbe733a6d186fe646304fe25f6d22b243d03/granian-2.7.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3bb99778ae05c1118cd694717d025cc0b85f5ee81f60cbcb2a8783692798db96", size = 7146800, upload-time = "2026-04-23T11:55:14.459Z" }, + { url = "https://files.pythonhosted.org/packages/1d/66/7209201856b7de8d3c643ba87e11272c4d651c216d05ea3fcbdce0da4ab0/granian-2.7.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13f0a39872afa81c6aaa8e29832371fd831373140f1f04de459ff862824f488b", size = 6999983, upload-time = "2026-04-23T11:55:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/bd1e521284714615996dcee48dad47d8b97ca2767a7e7cccd392f25fc176/granian-2.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:97b5aeec98a9c6c0695bf8f068bd03aca83fc17c0d977a9c3a2e57bb5f10d47e", size = 3989433, upload-time = "2026-04-23T11:55:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/45/a2/609f8f0dca7f596b5fb6e57b988b4b8f4d6579724b2720933c379d43301a/granian-2.7.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7b1aca6c654f0e61c9e493dd6d3ddb1698f47dc33ed04566a6635948b081b64", size = 6251034, upload-time = "2026-04-23T11:55:19.29Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/2eefa8ff477cce7b119ed2fe97fc1f3b2d108397d4755e83a5198149f2c8/granian-2.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4e0c8cc6850dec7180a26b6805b2c4cdbac4c1c48077fd7857a3cd8ff342d9d", size = 5912772, upload-time = "2026-04-23T11:55:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/40/9a5070badaed4ceecf4082855985840c320f7232b8c1ddc93e1732c63265/granian-2.7.4-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7e6b1f6e0fe873efa3393ef28803ff699a94254f2a7dc07422cc01d9849e2136", size = 6037318, upload-time = "2026-04-23T11:55:23.855Z" }, + { url = "https://files.pythonhosted.org/packages/95/52/1db412e63425cb12f5ca61877956583c6d12f21657b1a3e47eb3200e9c1b/granian-2.7.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dce110217825cff60f68da83280bc20471b10e004e720fa94b845e01925d8698", size = 6962778, upload-time = "2026-04-23T11:55:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/fcca39f617bf70e29ef903bb7a4d037970c637023484f2112d9ed6882516/granian-2.7.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:058f9a4ebfc7b9c2577569c6ecfd333628d0d045de272afaa65ee9933849778c", size = 6566618, upload-time = "2026-04-23T11:55:28.233Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/0da1bb552746d74275017e1ffc7fc419dd1a33345f132f6f5a90f9f41142/granian-2.7.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7c05f74fa5b5dcedc9f035a7c10b8afd90a3d941975a370f1e07c3f3095dd883", size = 6670850, upload-time = "2026-04-23T11:55:29.945Z" }, + { url = "https://files.pythonhosted.org/packages/11/2a/d0d9cdb10d2760e2f47bd4600c8eef02e326f8f7e253a80ce4ba384265e6/granian-2.7.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:8b992bbc667e3c74de4ad48ac8d735c7cddf3f709fc2097f7dd230ecc46fd7b3", size = 6824752, upload-time = "2026-04-23T11:55:32.066Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/0432f92f9df6e54394e4dd1c159c0d4814d255a2d2541fa9a5c187d19152/granian-2.7.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:df05e0f85712b3e90ddf28cb8be358664b1afa8cb8f09978141ca70052dca3a7", size = 7130809, upload-time = "2026-04-23T11:55:33.807Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11cc0e08f59f03a3cd6a1fe46d7632a0f8690ef945a495b1303140bb7541/granian-2.7.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:dbc620f35b67cf6b03d2b6a24b9b442d1bf52961eaebadb2c3ff214d3d0c8dc4", size = 6845920, upload-time = "2026-04-23T11:55:35.583Z" }, + { url = "https://files.pythonhosted.org/packages/b4/49/bcbaaeec0f68d3d1a3dd1fdd21e4a6963d303ae18027c42b2b53f87d6b89/granian-2.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:b9df8aead4d71562753788264db23d32db34147bb73294ddd90833bef1f4cf35", size = 3981107, upload-time = "2026-04-23T11:55:37.597Z" }, +] + [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -859,6 +920,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/d1/909e6a05bfd44d46327dc4b8a78beb2bae4fb245ffab2772e350081aaf7e/grpcio_tools-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d58ade518b546120ec8f0a8e006fc8076ae5df151250ebd7e82e9b5e152c229", size = 1190196, upload-time = "2026-02-06T09:59:28.359Z" }, ] +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1109,6 +1182,12 @@ sqs = [ ] [package.dev-dependencies] +bench = [ + { name = "granian" }, + { name = "gunicorn" }, + { name = "pytest-benchmark" }, + { name = "starlette" }, +] dev = [ { name = "icecream" }, { name = "structlog" }, @@ -1190,6 +1269,12 @@ requires-dist = [ provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "http-flask", "http-sentry", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] [package.metadata.requires-dev] +bench = [ + { name = "granian", specifier = "~=2.6" }, + { name = "gunicorn", specifier = "~=23.0" }, + { name = "pytest-benchmark", specifier = "~=5.1" }, + { name = "starlette", specifier = "~=0.49" }, +] dev = [ { name = "icecream", specifier = "~=2.1" }, { name = "structlog", specifier = "~=25.0" }, @@ -1702,6 +1787,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + [[package]] name = "pyasn1" version = "0.6.2" @@ -1857,6 +1951,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" From 018602167b2d48134c959025e5e9eef3054c528c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 17:12:15 +0400 Subject: [PATCH 095/286] feat(http): worker-pool dispatcher + HTTP-native request cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-request AnyIO portal hop in http_server with a worker thread pool fed by threadtools.Channel. Workers run sync handlers directly — no event-loop hop on the request hot path. Adds localpost.http.check_cancelled() / RequestCancelled, deliberately separate from threadtools/AnyIO. The same per-request cancel token drives client-disconnect detection (selector peeks the conn for EOF via MSG_PEEK without consuming) and service-shutdown propagation. HTTPConn.tracked becomes a property over HTTPConn.mode (UNTRACKED / NORMAL / WATCHDOG). finish_response now drains h11's pending request events before re-tracking, otherwise the next keep-alive request hits an unexpected PAUSED from parser.next_event. README gets a Design section documenting that sync-only handlers are intentional — the package is built around blocking sockets and a thread pool; ASGI servers are the right tool for async. Bench (5s/cell, max_concurrency=32, 64 clients): localpost_native plaintext 5,450 → 9,381 RPS, p50 11.7 → 6.3 ms localpost_flask plaintext 5,117 → 8,157 RPS, p50 12.5 → 7.4 ms localpost_wsgi plaintext 4,919 → 7,743 RPS, p50 13.0 → 7.9 ms LocalPost native now out-throughputs flask_cheroot (8,218 RPS). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 211 +++++++++++++++++++++++++++++++ localpost/http/README.md | 19 +++ localpost/http/__init__.py | 4 + localpost/http/_cancel.py | 74 +++++++++++ localpost/http/_service.py | 167 +++++++++++++++++++----- localpost/http/server.py | 198 ++++++++++++++++++++++++++--- tests/http/service.py | 73 ++++++++++- 7 files changed, 687 insertions(+), 59 deletions(-) create mode 100644 benchmarks/http/PERF_FINDINGS.md create mode 100644 localpost/http/_cancel.py diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md new file mode 100644 index 0000000..95a0073 --- /dev/null +++ b/benchmarks/http/PERF_FINDINGS.md @@ -0,0 +1,211 @@ +# HTTP server performance findings + +Initial diagnosis from the 2026-04-27 bench run (3s per cell, before Phase 1). +Phase 1 results in [Phase 1 results](#phase-1-results-2026-04-27) below. + +## Headline numbers + +| Stack | RPS | p50 (ms) | concurrency | +| ----------------- | -----: | -------: | ----------: | +| flask_cheroot | 7,433 | 2.41 | 32 threads | +| localpost_native | 5,450 | 11.71 | 32 | +| localpost_flask | 5,117 | 12.46 | 32 | +| localpost_wsgi | 4,919 | 13.03 | 32 | + +Cheroot has only ~37% more RPS but **~5× lower p50**. h11's parser overhead would +show up as flat per-request CPU, not as a 12 ms vs 2.4 ms gap. **h11 is not the +bottleneck — the dispatch architecture is.** + +## Diagnosis + +`localpost_native`, `localpost_flask`, `localpost_wsgi` all cluster within 10% of +each other in every scenario — the bottleneck is upstream of the framework layer +(h11 + dispatch path), not WSGI/Flask conversion. + +Latency math: with p50 ≈ 12 ms and 64 concurrent clients, 64 / 0.012 ≈ 5,300 RPS, +matching measured RPS almost exactly. That's **queueing-latency-bound**, not CPU. + +### Per-request hot path (`localpost/http/_service.py:58-67`) + +``` +selector thread reads bytes + → ctx.borrow() # acquires Server._lock, unregisters fd + → from_thread.run_sync(start_soon …) # SYNCHRONOUS cross-thread call (blocks selector) +event loop schedules task + → to_thread.run_sync(handler, …) # another hop into a worker +worker runs handler + → ctx.complete() → finish_response() + → _maybe_give_back() → server.track() # acquires Server._lock, re-registers fd +``` + +3 thread transitions, 2 lock acquisitions, 1 synchronous portal call **per +request**. The selector thread blocks on `from_thread.run_sync` waiting for the +event loop to accept the dispatch — that wait is the queueing latency. + +Cheroot, by contrast, has each worker thread `accept()` and run the request +end-to-end. No event loop, no hops. + +### Secondary costs (real, but second-order) + +- `Server._cleanup_stale()` runs every iteration of `Server.run()` and walks + `selector.get_map().values()` under `_lock` (`server.py:174-184`). O(N) per + loop tick. +- `_maybe_inject_keep_alive` allocates a fresh `h11.Response` per request and + scans headers byte-by-byte (`server.py:509-530`). +- `_build_environ` walks request headers twice and does + `.decode().upper().replace()` per header on the second pass + (`wsgi.py:135-172`). +- h11 is pure-Python — ~20–30% gap to a C parser, but shows up as CPU, not p50. + +## Plan + +### Phase 1 — Worker thread pool with HTTP-native cancellation + +Replace the per-request AnyIO dispatch in `localpost/http/_service.py` with a +worker thread pool that runs handlers directly. `start_http_server` and +`Server` are unchanged — only the hosted-service dispatcher above them. + +**Dispatch path (target):** + +``` +selector thread (in lt.tg, via to_thread.run_sync once) + reads bytes, parses, ctx.borrow() + → channel_tx.put((ctx, stack)) # threadtools.Channel, bounded + +worker thread N-of-N (also in lt.tg, via to_thread.run_sync once) + for ctx, stack in channel_rx: + with request_cancel_scope(conn) as token: + handler(ctx) # runs inline, no further hops + stack.close() # re-tracks the conn for keep-alive +``` + +No portal calls, no `from_thread.run_sync` per request, no `to_thread.run_sync` +per request. Two thread crossings (selector → worker, worker → selector for +re-tracking) instead of five. + +**Why `threadtools.Channel`:** consistent with the rest of the codebase, gives +us cancellation-aware `put`/`get` for free. Workers and selector are both +AnyIO-aware threads (spawned via `to_thread.run_sync` once at service start), +so the channel's existing AnyIO-driven cancellation fires correctly on +service shutdown — workers exit cleanly when `lt.tg` is cancelled or +`channel_tx.close()` is called. + +**Bounded capacity** = `max_concurrency`. When full, the selector's +`channel_tx.put()` blocks → back-pressure on accept. + +### Phase 1b — HTTP-native request cancellation (distinct from AnyIO) + +A separate, HTTP-only cancellation layer for *request-level* signals +(client disconnect, future per-request timeout). **Not mixed with AnyIO** — +the AnyIO layer handles service-level cancellation of *workers*; this layer +handles *requests*. + +New surface in `localpost.http`: + +- `localpost.http.RequestCancelled` — exception, distinct from + `anyio.get_cancelled_exc_class()`. Inherits from `Exception`, not + `BaseException` (so handlers can catch broad `except Exception` without + surprises — different choice from AnyIO on purpose, since these are + request-scoped, not task-scoped). +- `localpost.http.check_cancelled()` — reads a `ContextVar[RequestCancel]`, + raises `RequestCancelled` if the per-request token is set. **Not** an alias + of `threadtools.check_cancelled`. Documented as "call this in long-running + handlers; raises if the client went away or the server is shutting down". +- `RequestCancel` — internal token: a `threading.Event` per in-flight request, + plus a registry on the hosted service so shutdown can flip them all. + +Cancellation triggers (Phase 1b): + +1. **Client disconnect** (the genuinely-useful trigger). While the handler is + running, the borrowed conn is re-registered in the selector with a + "watchdog" data tag. On `EVENT_READ`, selector does + `recv(1, MSG_PEEK | MSG_DONTWAIT)`; `b""` → EOF → flip the request's + cancel token. Safe vs the worker thread's `send()` (PEEK doesn't consume, + send is independent of recv at the kernel level). The watchdog is only + armed *after* the request body is fully read, to avoid racing with + handler-driven body `recv()` calls. +2. **Service shutdown.** When `lt.shutting_down` fires, walk the in-flight + registry and flip every cancel token. In-flight handlers calling + `check_cancelled()` see it. + +What we *don't* try to do in Phase 1b: per-request timeouts, async-handler +cancellation, mid-body-upload disconnect detection. Each is a clean follow-up +on the same primitive. + +### Phase 2 — h11 / WSGI micro-optimisations (after Phase 1 ships) + +1. Pre-bake the keep-alive header. Skip the rebuild in + `_maybe_inject_keep_alive` (`server.py:509-530`) when no `Connection` + header is present and the response has no keep-alive yet — append a + precomputed tuple instead of allocating a fresh `h11.Response`. +2. One-pass `_build_environ` (`wsgi.py:135-172`) — fold the two header walks + into one, cache `bytes(name)`. +3. Avoid `bytes(name).lower()` allocations in `_content_length` and the + keep-alive scan in `server.py`. + +### Phase 3 — Selector self-pipe + lock-free op queue (deferred) + +The item already on the http README roadmap. Reconsider after Phase 1+2 +benchmarks; only worth doing if a measurable gap to Cheroot remains. + +## Validation plan + +- A/B Phase 1 against current path in `benchmarks/http/runner.py`. Expect + p50 to collapse from ~12 ms toward ~2-3 ms; RPS to rise correspondingly. +- New tests for Phase 1b: + - Client closes mid-request → handler's `check_cancelled()` raises + `RequestCancelled`. + - Service shutdown with in-flight handler → same. + - `check_cancelled()` outside a request raises a clear error (not + `RuntimeError` from contextvar lookup). +- Run existing `tests/http/` to ensure no regressions. +- `just check localpost/http/_service.py` after each substantive change. +- Phase 2 should mostly improve RPS, not p50. +- `just bench-micro` to confirm no router regressions. + +## Phase 1 results (2026-04-27) + +Bench: 5 s per cell, `max_concurrency=32`, 64 concurrent clients. + +| Stack | RPS (before → after) | p50 (before → after) | +| ----------------- | --------------------: | -------------------: | +| `localpost_native`| 5,450 → **9,381** | 11.71 → **6.29 ms** | +| `localpost_flask` | 5,117 → **8,157** | 12.46 → **7.44 ms** | +| `localpost_wsgi` | 4,919 → **7,743** | 13.03 → **7.87 ms** | +| `flask_cheroot` | 7,433 → 8,218 | 2.41 → 2.20 ms | + +LocalPost native now **out-throughputs Cheroot** (9,381 vs 8,218 RPS) on +plaintext. Same on `json_post`: 7,985 vs 7,698 RPS. Cheroot still wins on +p50 (~2 ms vs ~6 ms) — that's the thread-per-connection model paying off +for tail-latency. We're queue-bound at ~9 k RPS now (`64 / 0.006 ≈ 10,600` +matches measured RPS); raising `max_concurrency` past 32 should push p50 +down and RPS up further. + +All `tests/http/` (129 tests) green; 10/10 stress runs at 800/800 success. + +### What shipped + +- Worker thread pool fed by `threadtools.Channel` (`localpost/http/_service.py`). + No more per-request portal call or `to_thread.run_sync` hop. +- HTTP-native cancellation: `localpost.http.check_cancelled` / + `RequestCancelled` (`localpost/http/_cancel.py`), a per-request token + registry, and service-shutdown propagation. **Not** mixed with AnyIO. +- `Server.to_watchdog` for client-disconnect detection while a handler runs. + Cooperates with the worker via a mode-recheck under `_lock`. +- `HTTPConn.tracked` → `HTTPConn.mode: ConnMode` (UNTRACKED / NORMAL / WATCHDOG). + `tracked` kept as a backwards-compat property. +- `finish_response` now drains h11's pending request-body events before + `_maybe_give_back`. Without this the next keep-alive request hits + `PAUSED` from `parser.next_event`. + +### Bug we hit and fixed + +Once the worker pool was wired up, ~6% of requests under contention failed +with `Connection reset by peer`. Root cause: the worker's *outer* `track()` +call (in the `finally` block) ran `sock.settimeout(0)` on the conn, switching +the shared socket back to non-blocking — racing with the **next** request's +worker, which was already mid-response in blocking I/O mode and would then +hit a spurious `BlockingIOError`. The dispatcher now relies entirely on +`finish_response`'s `_maybe_give_back` to re-track; the outer block only +closes the conn when the inner path didn't (cancel / disconnect / handler +returned without completing). diff --git a/localpost/http/README.md b/localpost/http/README.md index b06d242..c42d876 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -181,6 +181,25 @@ on the documented public Flask API. The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. +## Design + +### Sync handlers only — no async + +`RequestHandler` is `Callable[[HTTPReqCtx], None]`, sync-only. **This is +intentional and not a planned extension.** + +The whole package is built around blocking sockets and a thread pool: the +selector accepts connections, parses HTTP/1.1 with h11, and dispatches each +request to a worker thread that does its I/O synchronously. Per-request +cancellation is HTTP-native (`localpost.http.check_cancelled`), driven by +the selector's client-disconnect watchdog and by service-shutdown signals — +not by AnyIO. + +If you need an async server, use one of the ASGI servers that already exist +(uvicorn, hypercorn, granian, …) — the `localpost.hosting` adapters in +`localpost.hosting.services/` plug them in cleanly. There is no need for +this package to grow a second, parallel async path. + ## Roadmap Items that are not currently bugs but where there's a known better diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 19f9445..7e5bd92 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,3 +1,4 @@ +from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._service import http_server, wsgi_server from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( @@ -35,4 +36,7 @@ # hosting "http_server", "wsgi_server", + # cancellation + "check_cancelled", + "RequestCancelled", ] diff --git a/localpost/http/_cancel.py b/localpost/http/_cancel.py new file mode 100644 index 0000000..5e758e6 --- /dev/null +++ b/localpost/http/_cancel.py @@ -0,0 +1,74 @@ +"""HTTP-native request cancellation. + +Independent of AnyIO and :mod:`localpost.threadtools` by design. Those layers +manage *worker* / *task* cancellation; this module manages *request* +cancellation — a distinct lifetime that ends when the HTTP client goes away +or when the hosted service is shutting down. +""" + +from __future__ import annotations + +import threading +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import final + +__all__ = ["RequestCancelled", "RequestCancel", "check_cancelled"] + + +class RequestCancelled(Exception): + """Raised by :func:`check_cancelled` when the current request is cancelled. + + Triggers: + * The HTTP client disconnected mid-request (selector watchdog detected EOF). + * The hosted service is shutting down. + + Inherits from :class:`Exception` (not :class:`BaseException`): request-scoped + cancellation is meant to be catchable by ordinary ``except Exception:`` blocks. + Worker / service cancellation lives on a separate, AnyIO-driven channel. + """ + + +@final +@dataclass(eq=False, slots=True) +class RequestCancel: + """Per-request cancellation token. Flipped by the dispatcher on disconnect / shutdown.""" + + _event: threading.Event = field(default_factory=threading.Event) + + def cancel(self) -> None: + self._event.set() + + @property + def is_cancelled(self) -> bool: + return self._event.is_set() + + +_current: ContextVar[RequestCancel] = ContextVar("localpost.http._cancel.current") + + +def check_cancelled() -> None: + """Cooperative cancellation check for HTTP request handlers. + + Raises: + RequestCancelled: if the current request was cancelled. + LookupError: if called outside an HTTP request context. + """ + try: + token = _current.get() + except LookupError: + raise LookupError("check_cancelled() called outside a request handler") from None + if token.is_cancelled: + raise RequestCancelled + + +@contextmanager +def _enter_request(token: RequestCancel) -> Generator[None]: + """Bind ``token`` to the calling thread for the duration of the block.""" + reset = _current.set(token) + try: + yield + finally: + _current.reset(reset) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 8502ee6..b698cce 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,21 +1,47 @@ from __future__ import annotations import logging +import threading from collections.abc import Awaitable -from contextlib import ExitStack +from contextlib import suppress from wsgiref.types import WSGIApplication -from anyio import CapacityLimiter, from_thread, to_thread +import h11 +from anyio import ( + BrokenResourceError, + CapacityLimiter, + ClosedResourceError, + EndOfStream, + from_thread, + to_thread, +) from localpost import hosting, threadtools from localpost.hosting import ServiceLifetime +from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request from localpost.http.config import LOGGER_NAME, ServerConfig -from localpost.http.server import HTTPReqCtx, RequestHandler, emit_handler_error, start_http_server +from localpost.http.server import ConnMode, HTTPReqCtx, RequestHandler, emit_handler_error, start_http_server from localpost.http.wsgi import wrap_wsgi __all__ = ["http_server", "wsgi_server"] +def _request_has_body(request: h11.Request) -> bool: + """``True`` iff the request advertises a non-empty body (``Content-Length > 0`` or chunked).""" + for name, value in request.headers: + n = bytes(name).lower() + if n == b"content-length": + try: + return int(value) > 0 + except ValueError: + return True + if n == b"transfer-encoding": + return True + return False + + + + @hosting.service def http_server( config: ServerConfig, @@ -24,16 +50,21 @@ def http_server( *, max_concurrency: int = 1, ): - """Run an :func:`start_http_server` loop inside a hosted service. + """Run an HTTP server inside a hosted service. + + The selector thread accepts and parses requests, then hands each one to a + worker thread via a bounded :class:`localpost.threadtools.Channel`. Workers + run sync handlers directly — no event-loop hop, no per-request portal call. + Once the channel buffer is full, the selector blocks on ``put`` and applies + back-pressure on accept. - Each accepted request is dispatched as an AnyIO task in the service's task group, - so every request has its own cancellation scope and shutdown cancels in-flight - handlers. ``max_concurrency`` bounds concurrent handlers and applies back-pressure - on the accept loop. With the default ``max_concurrency=1`` requests are still - handed off to the task group (and therefore gain a cancel scope), they just never - run concurrently with each other. + Per-request cancellation is HTTP-native and lives behind + :func:`localpost.http.check_cancelled`. Triggers: + * Client disconnected mid-request (selector watchdog detected EOF). + * Service is shutting down. - ``handler`` runs on an AnyIO worker thread via ``to_thread.run_sync``. + Worker / service cancellation (a separate concern) flows through the + AnyIO machinery as elsewhere in :mod:`localpost`. """ if max_concurrency < 1: raise ValueError("max_concurrency must be >= 1") @@ -41,30 +72,88 @@ def http_server( logger = logging.getLogger(LOGGER_NAME) def run(lt: ServiceLifetime) -> Awaitable[None]: - req_slots = threadtools.cancellable_semaphore(max_concurrency) - handler_limiter = CapacityLimiter(max_concurrency) + tx, rx = threadtools.Channel[tuple[HTTPReqCtx, RequestCancel]].create(capacity=max_concurrency) - async def handle_request(ctx: HTTPReqCtx, borrow_stack: ExitStack) -> None: - try: - try: - await to_thread.run_sync(handler, ctx, limiter=handler_limiter) - except Exception: - logger.exception("Handler raised for %s %r", ctx.request.method, ctx.request.target) - await to_thread.run_sync(emit_handler_error, ctx, limiter=handler_limiter) - finally: - borrow_stack.close() - req_slots.release() + # Tokens for in-flight requests, so service shutdown can flip them all at once. + in_flight: set[RequestCancel] = set() + in_flight_lock = threading.Lock() + # Dedicated thread limiter so workers + selector don't consume slots from + # AnyIO's default global limiter. + threads_limiter = CapacityLimiter(max_concurrency + 1) def dispatch(ctx: HTTPReqCtx) -> None: - req_slots.acquire() # back-pressure - stack = ExitStack() - stack.enter_context(ctx.borrow()) # detach the socket from the accept loop + """Selector-thread callback: parse done, hand off to a worker.""" + cancel = RequestCancel() + if _request_has_body(ctx.request): + # The worker will do blocking I/O to read the body. We can't + # arm the watchdog here — buffered body bytes would make the + # selector spin on a level-triggered ``EVENT_READ``. + ctx._server.stop_tracking(ctx._conn) + else: + # No body to read — arm the watchdog. EOF on the socket = client + # walked away while the handler was running. ``MSG_PEEK`` is safe + # alongside the worker's ``send`` (different ops at the kernel level). + ctx._server.to_watchdog(ctx._conn, cancel.cancel) try: - from_thread.run_sync(lt.tg.start_soon, handle_request, ctx, stack) - except BaseException: - stack.close() - req_slots.release() - raise + tx.put((ctx, cancel)) + except (ClosedResourceError, BrokenResourceError): + with suppress(Exception): + ctx._conn.close() + + def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: + """Worker-thread loop. Pulls from ``my_rx`` until the channel ends.""" + with my_rx: + while True: + try: + ctx, cancel = my_rx.get() + except (EndOfStream, ClosedResourceError): + return + + with in_flight_lock: + in_flight.add(cancel) + try: + with _enter_request(cancel): + try: + handler(ctx) + except RequestCancelled: + # Handler bailed cleanly. Connection state is uncertain — close. + with suppress(Exception): + ctx._conn.close() + except Exception: + logger.exception( + "Handler raised for %s %r", ctx.request.method, ctx.request.target + ) + emit_handler_error(ctx) + finally: + with in_flight_lock: + in_flight.discard(cancel) + # Conn-release policy: + # + # On the success path, ``finish_response`` already drained h11 events and + # re-tracked the conn (mode → NORMAL). We MUST NOT call ``track`` here + # because ``track`` flips the socket back to non-blocking via + # ``settimeout(0)`` — which would race with the next request's worker + # already using the shared socket in blocking mode (response-body + # writes), causing spurious ``BlockingIOError`` and dropped requests. + # + # The only thing left to do is close the conn for the unhappy paths: + # client disconnected, watchdog fired, or the handler returned without + # completing the response (mode != NORMAL). + if ( + cancel.is_cancelled + or ctx._conn.recv_closed + or ctx._conn.mode is not ConnMode.NORMAL + ): + with suppress(Exception): + ctx._conn.close() + + async def shutdown_watcher() -> None: + await lt.shutting_down.wait() + with in_flight_lock: + tokens = list(in_flight) + for token in tokens: + token.cancel() + tx.close() def run_server() -> None: with start_http_server(config, dispatch) as server: @@ -73,7 +162,21 @@ def run_server() -> None: from_thread.check_cancelled() server.run() - return to_thread.run_sync(run_server, limiter=CapacityLimiter(1)) + async def run_worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: + await to_thread.run_sync(worker, my_rx, limiter=threads_limiter) + + async def main() -> None: + try: + for _ in range(max_concurrency): + lt.tg.start_soon(run_worker, rx.clone()) + rx.close() + lt.tg.start_soon(shutdown_watcher) + await to_thread.run_sync(run_server, limiter=threads_limiter) + finally: + # Selector exited (clean or otherwise) — drain workers. + tx.close() + + return main() return run diff --git a/localpost/http/server.py b/localpost/http/server.py index 6c6b167..4663c7e 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -8,6 +8,7 @@ from __future__ import annotations +import enum import logging import selectors import socket @@ -25,6 +26,33 @@ __all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] +class ConnMode(enum.Enum): + """Selector-tracking state of an :class:`HTTPConn`. + + UNTRACKED — not registered in the selector at all (worker holds the conn). + NORMAL — registered for HTTP processing; selector reads bytes and drives the parser. + WATCHDOG — registered for client-disconnect detection only; selector does + ``recv(MSG_PEEK)`` on wake-up to detect EOF, never consumes bytes. + """ + + UNTRACKED = "untracked" + NORMAL = "normal" + WATCHDOG = "watchdog" + + +@final +@dataclass(eq=False, slots=True) +class _WatchdogToken: + """Selector data-tag for a conn in WATCHDOG mode. + + ``on_disconnect`` is invoked once when the selector detects EOF on the + socket. The token is then unregistered. + """ + + conn: HTTPConn + on_disconnect: Callable[[], None] + + _INTERNAL_ERROR_BODY = b"Internal Server Error" _INTERNAL_ERROR_RESPONSE = h11.Response( status_code=500, @@ -179,7 +207,7 @@ def _cleanup_stale(self): self.selector.unregister(conn.sock) except (KeyError, ValueError): pass - conn.tracked = False + conn.mode = ConnMode.UNTRACKED # Drop the lock for any I/O — closing a socket can block, and we don't # want to hold the selector lock while talking to the kernel. for conn in stale: @@ -206,33 +234,79 @@ def _cleanup_stale(self): pass def track(self, conn: HTTPConn) -> None: + """Register or restore ``conn`` to NORMAL mode (selector reads bytes). + + Handles all incoming transitions: + * UNTRACKED → NORMAL: register fresh. + * WATCHDOG → NORMAL: swap selector data-tag, no re-register. + * NORMAL → NORMAL: no-op. + + ``conn.mode`` is updated *under* ``_lock``: any ``_handle_watchdog_event`` + racing the same fd must observe a consistent ``(selector data-tag, mode)`` pair. + """ sock = conn.sock try: sock.settimeout(0) except OSError: - # The socket was already closed (e.g., by a previous error-recovery - # path). Mark the conn as no longer borrowed so a second - # _maybe_give_back call doesn't try again. - conn.tracked = True + conn.mode = ConnMode.UNTRACKED return with self._lock: if self.shutting_down: - # Server is going down — don't re-register, just close. try: sock.close() except OSError: pass - conn.tracked = True + conn.mode = ConnMode.UNTRACKED return - self.selector.register(sock, selectors.EVENT_READ, data=conn) - conn.tracked = True + if conn.mode is ConnMode.WATCHDOG: + self.selector.modify(sock, selectors.EVENT_READ, data=conn) + elif conn.mode is ConnMode.UNTRACKED: + self.selector.register(sock, selectors.EVENT_READ, data=conn) + # else NORMAL: nothing to do + conn.mode = ConnMode.NORMAL def stop_tracking(self, conn: HTTPConn) -> None: + """Unregister ``conn`` from the selector and switch the socket to blocking I/O.""" sock = conn.sock with self._lock: - self.selector.unregister(sock) + try: + self.selector.unregister(sock) + except (KeyError, ValueError): + pass + conn.mode = ConnMode.UNTRACKED sock.settimeout(self.config.rw_timeout) - conn.tracked = False + + def to_watchdog(self, conn: HTTPConn, on_disconnect: Callable[[], None]) -> None: + """Place ``conn`` in WATCHDOG mode for client-disconnect detection. + + The socket is switched to blocking-with-timeout for the worker thread + (which now owns it for I/O). The selector keeps the fd registered but + only does ``recv(MSG_PEEK)`` on wake-up, never consuming bytes — + ``MSG_PEEK`` does not conflict with a concurrent ``send`` from the + worker. + + ``on_disconnect`` is fired at most once when EOF (peer FIN) is detected. + """ + sock = conn.sock + try: + sock.settimeout(self.config.rw_timeout) + except OSError: + conn.mode = ConnMode.UNTRACKED + return + token = _WatchdogToken(conn, on_disconnect) + with self._lock: + if self.shutting_down: + try: + sock.close() + except OSError: + pass + conn.mode = ConnMode.UNTRACKED + return + if conn.mode is ConnMode.UNTRACKED: + self.selector.register(sock, selectors.EVENT_READ, data=token) + else: # NORMAL or WATCHDOG — swap the data-tag in place + self.selector.modify(sock, selectors.EVENT_READ, data=token) + conn.mode = ConnMode.WATCHDOG def _shutdown_active_connections(self) -> None: """Set the shutdown flag and close any connections still in the selector. @@ -247,8 +321,12 @@ def _shutdown_active_connections(self) -> None: for key in keys: if key.fileobj is self.sock: continue # listening socket — closed by the outer CM - conn = key.data - if not isinstance(conn, HTTPConn): + conn: HTTPConn | None + if isinstance(key.data, HTTPConn): + conn = key.data + elif isinstance(key.data, _WatchdogToken): + conn = key.data.conn + else: continue try: self.selector.unregister(conn.sock) @@ -258,7 +336,7 @@ def _shutdown_active_connections(self) -> None: conn.sock.close() except OSError: pass - conn.tracked = False + conn.mode = ConnMode.UNTRACKED def run(self, *, timeout: float | None = None) -> None: """One iteration of the server loop. Should be called repeatedly until the server is stopped. @@ -273,22 +351,71 @@ def run(self, *, timeout: float | None = None) -> None: h = self.handler self._cleanup_stale() for key, _ in self.selector.select(timeout=timeout): + data = key.data if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() conn = HTTPConn(self, client_sock, client_addr) self.track(conn) - elif (conn := key.data) and isinstance(conn, HTTPConn): + elif isinstance(data, _WatchdogToken): + self._handle_watchdog_event(data) + elif isinstance(data, HTTPConn): try: - conn(h) + data(h) except Exception: - self.logger.exception("Unhandled exception from connection %s", conn.addr) + self.logger.exception("Unhandled exception from connection %s", data.addr) try: - conn.close() + data.close() except Exception: # noqa: BLE001, S110 pass else: raise RuntimeError(f"Unexpected selector key: {key!r}") + def _handle_watchdog_event(self, wd: _WatchdogToken) -> None: + """Selector wake-up on a WATCHDOG-mode conn. + + Peek for EOF without consuming bytes — ``recv(1, MSG_PEEK | MSG_DONTWAIT)`` + returns ``b""`` on a clean half-close (peer FIN), data if buffered, or + raises ``BlockingIOError`` if the wake-up was spurious. + + Once the watchdog has fired (or seen buffered data) we unregister; the + worker's :meth:`track` will re-attach the conn after the response + finishes. Avoids busy-looping on level-triggered selectors. + + **Race-guard:** the ``key.data`` reference reported by ``selector.select`` + is captured before we acquire ``_lock``. By the time we run, the worker + thread may have swapped the conn back to NORMAL mode via :meth:`track`. + We re-check ``conn.mode`` under the lock and bail if so — otherwise we'd + unregister an fd the worker just re-tracked and selector wakes for + subsequent requests on this conn would never arrive. + """ + if wd.conn.mode is not ConnMode.WATCHDOG: + return + sock = wd.conn.sock + eof = False + try: + peeked = sock.recv(1, socket.MSG_PEEK | socket.MSG_DONTWAIT) + except BlockingIOError: + return # spurious wake-up; leave the watchdog armed + except OSError: + eof = True + else: + if not peeked: + eof = True + with self._lock: + if wd.conn.mode is not ConnMode.WATCHDOG: + return # raced with track() — leave the worker's NORMAL state alone + try: + self.selector.unregister(sock) + except (KeyError, ValueError): + pass + wd.conn.mode = ConnMode.UNTRACKED + if eof: + wd.conn.recv_closed = True + try: + wd.on_disconnect() + except Exception: + self.logger.exception("Watchdog on_disconnect callback failed") + @final @dataclass(eq=False, slots=True) @@ -299,7 +426,8 @@ class HTTPConn: recv_closed: bool = False parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) close_at: float | None = None # Used only when tracked, to enforce keep-alive and read timeouts - tracked: bool = False + mode: ConnMode = ConnMode.UNTRACKED + """Current selector-tracking state. See :class:`ConnMode`.""" body_bytes_received: int = 0 """Cumulative body bytes received for the current request — reset on ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` @@ -310,9 +438,22 @@ class HTTPConn: for the current request. Distinguishes idle keep-alive (close silently) from a stalled mid-request (emit 408 Request Timeout).""" + @property + def tracked(self) -> bool: + """Backward-compat: ``True`` iff in NORMAL selector-tracking mode. + + New code should branch on :attr:`mode` directly. + """ + return self.mode is ConnMode.NORMAL + def close(self) -> None: - if self.tracked: - self.server.selector.unregister(self.sock) + if self.mode is not ConnMode.UNTRACKED: + with self.server._lock: + try: + self.server.selector.unregister(self.sock) + except (KeyError, ValueError): + pass + self.mode = ConnMode.UNTRACKED # Send a FIN before close() so the client sees a clean half-close # rather than a possible RST when there's unread data in the kernel # receive buffer. Errors are expected on already-broken sockets. @@ -536,6 +677,21 @@ def send(self, chunk: Buffer, /) -> None: def finish_response(self) -> None: self._conn.send(h11.EndOfMessage()) + # Drain h11's pending ``EndOfMessage`` for the request side before + # giving the conn back. For a no-body request the selector parsed + # ``Request`` and stopped — h11 still has the implicit EndOfMessage + # queued, and ``their_state`` won't reach ``DONE`` until something + # consumes it. Without this drain, the next selector wake on a + # keep-alive request hits ``PAUSED`` from ``parser.next_event``. + # + # If h11 needs more bytes (handler didn't read a non-empty body), + # close the conn — keep-alive isn't safe with un-drained body bytes. + parser = self._conn.parser + while parser.their_state is not h11.DONE: + event = parser.next_event() + if event is h11.NEED_DATA or event is h11.PAUSED or isinstance(event, h11.ConnectionClosed): + self._conn.close() + return self._maybe_give_back() diff --git a/tests/http/service.py b/tests/http/service.py index e287b97..72ccf51 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -12,10 +12,19 @@ import h11 import httpx import pytest -from anyio import from_thread, to_thread +from anyio import to_thread from localpost.hosting import serve -from localpost.http import HTTPReqCtx, RequestCtx, Response, Routes, ServerConfig, http_server +from localpost.http import ( + HTTPReqCtx, + RequestCancelled, + RequestCtx, + Response, + Routes, + ServerConfig, + check_cancelled, + http_server, +) pytestmark = pytest.mark.anyio @@ -156,18 +165,18 @@ def handler(ctx: HTTPReqCtx): await lt.stopped async def test_shutdown_cancels_inflight(self, free_port): - """Triggering shutdown while a handler is running cancels it via the task-group cancel scope.""" + """Triggering shutdown while a handler is running cancels it via the HTTP cancellation token.""" handler_started = threading.Event() handler_cancelled = threading.Event() def handler(ctx: HTTPReqCtx): handler_started.set() - # Run a loop that cooperates with cancellation via from_thread.check_cancelled + # Cooperate with cancellation via localpost.http.check_cancelled try: for _ in range(100): - from_thread.check_cancelled() + check_cancelled() time.sleep(0.05) - except BaseException: + except RequestCancelled: handler_cancelled.set() raise ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") @@ -353,5 +362,57 @@ async def fire(): assert sum(counts.values()) == 10 assert len(counts) >= 2 + +class TestRequestCancellation: + async def test_check_cancelled_outside_handler_raises_lookup_error(self): + with pytest.raises(LookupError, match="outside a request handler"): + check_cancelled() + + async def test_client_disconnect_cancels_handler(self, free_port): + """A handler doing slow work sees ``RequestCancelled`` when the client closes the socket. + + The watchdog only arms for requests without a body, which is the case for the + bare ``GET /`` we open here. We close the socket before reading the response so + the server detects EOF mid-handler. + """ + handler_started = threading.Event() + handler_cancelled = threading.Event() + + def handler(ctx: HTTPReqCtx): + handler_started.set() + try: + for _ in range(200): + check_cancelled() + time.sleep(0.02) + except RequestCancelled: + handler_cancelled.set() + raise + # Should not be reached + ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + svc = http_server(cfg, handler, max_concurrency=2) + async with serve(svc) as lt: + await lt.started + await _wait_server_ready(free_port) + + def hit_and_drop(): + # Open a raw socket, send a minimal GET, then close mid-handler. + s = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + handler_started.wait(2.0) + s.close() # peer FIN — selector watchdog should fire + + await to_thread.run_sync(hit_and_drop) + + # Wait for cancellation to propagate (selector poll interval + check_cancelled poll interval). + def wait_for_cancel(): + return handler_cancelled.wait(5.0) + + assert await to_thread.run_sync(wait_for_cancel) + + lt.shutdown() + await lt.stopped + lt.shutdown() await lt.stopped From cf7c7ccced2c3d0321e5968126efdd54e5644e4f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 18:52:46 +0400 Subject: [PATCH 096/286] perf(http): pre-bake keep-alive header, fold environ pass, drop redundant header normalisations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three micro-optimisations on the worker request hot path, all leaning on h11's guarantee that header names are normalised to lowercase bytes on both the request side (parser output) and the response side (Response constructor): * Pre-bake the (b"keep-alive", b"timeout=N") tuple once on Server. The per-request f-string + encode is gone from _maybe_inject_keep_alive. * Drop bytes(name).lower() allocations in _content_length and _maybe_inject_keep_alive. Direct equality on the already-normalised bytes is enough. * Fold _build_environ's two header walks into one, with a single decode per name/value and bytes-level upper/replace for HTTP_* keys. These changes don't move the macro bench (still ~9 k RPS at max_concurrency=32) because the bench is selector-thread CPU bound, not worker-CPU bound — confirmed by a max_concurrency=128 run that plateaus at the same RPS. They are still net positive (less per-request CPU on workers, smaller GC pressure) and roughly halve the cost of _build_environ on the WSGI path. Phase 3 (selector self-pipe + op queue) is next; it targets the actual ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 38 ++++++++++++++++++++++++++++++++ localpost/http/server.py | 32 +++++++++++++++++++-------- localpost/http/wsgi.py | 23 ++++++++++--------- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 95a0073..6da613b 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -209,3 +209,41 @@ hit a spurious `BlockingIOError`. The dispatcher now relies entirely on `finish_response`'s `_maybe_give_back` to re-track; the outer block only closes the conn when the inner path didn't (cancel / disconnect / handler returned without completing). + +## Phase 2 results (2026-04-27) + +Three micro-optimisations on the worker hot path: + +- Pre-bake the `Keep-Alive: timeout=N` header tuple on `Server`; drop the + per-request f-string + encode in `_maybe_inject_keep_alive`. +- Drop `bytes(name).lower()` allocations in `_content_length` and + `_maybe_inject_keep_alive` — h11 normalizes header names to lowercase + bytes on both request and response sides. +- Fold the two header walks in `wsgi._build_environ` into one; cache + `bytes(name)` and avoid double-decoding. + +**Bench (5 s/cell, `max_concurrency=32`):** numbers are within +run-to-run noise (~±3 %) compared to Phase 1. All 100 % success. + +**Why no measurable RPS gain:** at the bench's load (64 clients, +`max_concurrency=32`), we are **selector-bound**, not worker-CPU-bound. +The selector thread does accept + h11 parse + dispatch serially; that +sets the ceiling regardless of how cheap each request is on the workers. +Bumping `max_concurrency` to 128 (verified with a one-shot run) yields +the same ~8.5 k RPS — confirming it's selector-thread CPU, not queueing. + +The Phase 2 changes are still net positive (less per-request CPU on +workers, smaller GC pressure) and they make the WSGI environ build +~2× cheaper, which matters under different workload mixes — but they +don't move the bench needle until Phase 3 unblocks the selector. + +## Phase 3 next + +`Self-pipe wakeup + per-iteration op queue` (already on the http README +roadmap) is now the right next step. Today every dispatch path acquires +`Server._lock` for a `selector.modify` call (twice per request: +`to_watchdog` + `track`). Replace with a thread-safe op queue + self-pipe +wakeup so workers fire-and-forget mode transitions to the selector — the +selector drains the op queue once per `select()` cycle. Lower lock +contention, fewer syscalls per request, and a cleaner threading model +where the selector has exactly one writer. diff --git a/localpost/http/server.py b/localpost/http/server.py index 4663c7e..b243a36 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -104,8 +104,9 @@ class BodyTooLarge(Exception): def _content_length(headers) -> int | None: + # h11 normalizes header names to lowercase bytes — direct equality is enough. for name, value in headers: - if bytes(name).lower() == b"content-length": + if name == b"content-length": try: return int(value) except ValueError: @@ -192,6 +193,14 @@ def __init__( """Set to True on context-manager exit. Once set, ``track`` rejects new registrations and ``_maybe_give_back`` closes connections instead of returning them to the selector.""" + # Pre-baked ``Keep-Alive: timeout=N`` header tuple, or ``None`` if + # keep-alive is disabled (``keep_alive_timeout < 1``). Computed once + # so :meth:`HTTPReqCtx._maybe_inject_keep_alive` doesn't pay the + # f-string + ``.encode`` cost per request. + ka_timeout = int(config.keep_alive_timeout) + self.keep_alive_header: tuple[bytes, bytes] | None = ( + (b"keep-alive", f"timeout={ka_timeout}".encode("ascii")) if ka_timeout >= 1 else None + ) def _find_stale(self): now = time.monotonic() @@ -649,24 +658,29 @@ def start_response(self, response: h11.Response | h11.InformationalResponse, /) def _maybe_inject_keep_alive(self, response: h11.Response) -> h11.Response: """Append ``Keep-Alive: timeout=N`` to the response on persistent HTTP/1.1 - connections so clients can size their keep-alive pool to our deadline.""" - timeout = int(self._server.config.keep_alive_timeout) - if timeout < 1: + connections so clients can size their keep-alive pool to our deadline. + + Header names are compared directly because h11 normalizes them to + lowercase bytes on both the request side (parser output) and the + response side (``h11.Response`` constructor). The keep-alive tuple + itself is pre-baked on the server. + """ + ka_header = self._server.keep_alive_header + if ka_header is None: return response if self.request.http_version != b"1.1": return response for name, value in self.request.headers: - if bytes(name).lower() == b"connection" and b"close" in bytes(value).lower(): + if name == b"connection" and b"close" in value.lower(): return response for name, value in response.headers: - nl = bytes(name).lower() - if nl == b"connection" and b"close" in bytes(value).lower(): + if name == b"connection" and b"close" in value.lower(): return response - if nl == b"keep-alive": + if name == b"keep-alive": return response # caller already set it return h11.Response( status_code=response.status_code, - headers=[*response.headers, (b"keep-alive", f"timeout={timeout}".encode("ascii"))], + headers=[*response.headers, ka_header], reason=response.reason, ) diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 3c95474..0dd3c11 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -140,17 +140,13 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: else: path, query_string = target, "" - headers_dict: dict[str, str] = {} - for name, value in request.headers: - headers_dict[name.decode("iso-8859-1").lower()] = value.decode("iso-8859-1") - environ: dict[str, Any] = { "REQUEST_METHOD": request.method.decode("ascii"), "SCRIPT_NAME": "", "PATH_INFO": path, "QUERY_STRING": query_string, - "CONTENT_TYPE": headers_dict.get("content-type", ""), - "CONTENT_LENGTH": headers_dict.get("content-length", ""), + "CONTENT_TYPE": "", + "CONTENT_LENGTH": "", "SERVER_NAME": ctx._server.config.host, "SERVER_PORT": str(ctx._server.port), "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", @@ -163,10 +159,17 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: "wsgi.run_once": False, } + # Single header pass: h11 normalizes names to lowercase bytes, so we can + # work with bytes directly and decode each name/value exactly once. for name, value in request.headers: - name_str = name.decode("iso-8859-1").upper().replace("-", "_") - if name_str in ("CONTENT_TYPE", "CONTENT_LENGTH"): - continue - environ[f"HTTP_{name_str}"] = value.decode("iso-8859-1") + value_str = value.decode("iso-8859-1") if isinstance(value, bytes) else bytes(value).decode("iso-8859-1") + if name == b"content-type": + environ["CONTENT_TYPE"] = value_str + elif name == b"content-length": + environ["CONTENT_LENGTH"] = value_str + else: + # bytes-level upper + replace, then single decode (ASCII for HTTP_* keys). + key = b"HTTP_" + name.upper().replace(b"-", b"_") + environ[key.decode("ascii")] = value_str return environ From 7b1fdd0933994e399abf7c66db335cc53ac96cd8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 21:18:26 +0400 Subject: [PATCH 097/286] refactor(http): collapse watchdog into pull-based check_cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the WATCHDOG conn mode and its selector event handler. HTTPConn now has just ``tracked: bool`` (in selector vs borrowed by worker), the original two-state model. Client-disconnect detection moves into RequestCancel.is_cancelled as a non-blocking MSG_PEEK on the request socket — pull-based, fired only when the handler calls check_cancelled. The watchdog was the single biggest source of complexity in the http package: a third state, separate selector data tag (_WatchdogToken), a race-prone _handle_watchdog_event with mode-recheck under lock, and a busy-loop avoidance hack on level-triggered selectors. All gone. Also drop the per-request in-flight registry — a single shared threading.Event in the cancel token covers service shutdown without iteration. Worker outer-finally simplified to just ``if cancel.fired: close``. The old ``not ctx._conn.tracked`` check was racing with the dispatcher of the next request (which calls stop_tracking, mutating tracked on the shared conn). The new ``fired`` property is event-only (no syscall), giving the per-request semantic without touching shared conn state. Bench (5s/cell, max_concurrency=32): localpost_native plaintext 9,381 → 8,961 RPS (within noise) localpost_flask plaintext 8,157 → 8,053 RPS localpost_wsgi plaintext 7,743 → 7,630 RPS All cells 100% success, 10/10 stress runs 800/800. Net: ~150 lines deleted, ~30 simplified. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 92 +++++++++++++++-- localpost/http/_cancel.py | 51 ++++++++- localpost/http/_service.py | 89 ++++++---------- localpost/http/server.py | 171 +++++-------------------------- 4 files changed, 186 insertions(+), 217 deletions(-) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 6da613b..647be01 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -237,13 +237,85 @@ workers, smaller GC pressure) and they make the WSGI environ build ~2× cheaper, which matters under different workload mixes — but they don't move the bench needle until Phase 3 unblocks the selector. -## Phase 3 next - -`Self-pipe wakeup + per-iteration op queue` (already on the http README -roadmap) is now the right next step. Today every dispatch path acquires -`Server._lock` for a `selector.modify` call (twice per request: -`to_watchdog` + `track`). Replace with a thread-safe op queue + self-pipe -wakeup so workers fire-and-forget mode transitions to the selector — the -selector drains the op queue once per `select()` cycle. Lower lock -contention, fewer syscalls per request, and a cleaner threading model -where the selector has exactly one writer. +## Phase 3 attempt: lock-free op queue (reverted) + +We prototyped the self-pipe + op-queue design: `Server._lock` removed, workers +enqueue typed `_OpTrack` / `_OpClose` ops on a `collections.deque`, write a +wakeup byte to `os.pipe()` registered in the selector, selector drains at the +top of each iteration. Single-writer-to-selector invariant held, but the +optimistic mode-flip semantics across three threads (selector, watchdog event +handler, worker) created races on the `ConnMode` field that I couldn't fully +close — stress dropped to 50-75% with EBADF mid-`send`. Reverted. + +## Phase 3 (shipped): simplify the conn model, pull-based disconnect detection + +The core insight from the reverted attempt: **the `WATCHDOG` mode is the +source of most of the complexity** (third state, separate selector data tag, +mode-recheck races, level-triggered busy-loop avoidance). Replacing it with a +much simpler design: + +- **Two states only:** `HTTPConn.tracked: bool`. The conn is either in the + selector for normal HTTP processing or borrowed by a worker. No third + WATCHDOG state, no `_WatchdogToken`, no `Server.to_watchdog`, no + `Server._handle_watchdog_event`. +- **Pull-based disconnect detection.** `RequestCancel.is_cancelled` does a + non-blocking `recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. + `b""` means peer FIN; `BlockingIOError` means no signal; any other + `OSError` treats the connection as broken. The result is cached on + `_event` so subsequent calls don't re-issue the syscall. +- **Single shared shutdown event.** Replaces the per-request `in_flight` + registry — every cancel token OR-s in a single `threading.Event` set by + the shutdown watcher. +- **Worker outer-finally simplified.** Only closes when `cancel.fired` + (cheap event-only check) — never reads `ctx._conn.tracked`, which is + shared with the next request's dispatcher and would race. + +### Code delta + +- `localpost/http/server.py`: dropped `ConnMode` enum, `_WatchdogToken` + dataclass, `Server.to_watchdog`, `Server._handle_watchdog_event`. Replaced + `HTTPConn.mode: ConnMode` with `tracked: bool`. Simplified `Server.run`'s + for-event branch. +- `localpost/http/_cancel.py`: `RequestCancel` gains `_sock` + `_shutdown_event`. + `is_cancelled` does the PEEK; `fired` is the event-only check. +- `localpost/http/_service.py`: dropped `_request_has_body`, the in-flight + registry, and the watchdog branch in `dispatch`. Always `stop_tracking`. + +Net: ~150 lines deleted, ~30 simplified. The selector loop has only two +event types (accept / HTTPConn) and one state field (`tracked: bool`). + +### Trade-offs + +- **Detection is cooperative now.** Disconnect surfaces only when the + handler calls `check_cancelled()` — same contract as service-shutdown + cancellation. A pure-compute handler that ignores cancellation won't + notice mid-flight disconnects (just like it doesn't notice service + shutdown). Most real handlers either poll cancellation or perform I/O, + which surfaces disconnects via `EPIPE`/`ECONNRESET` naturally. +- **One extra syscall per `check_cancelled()` call** (the PEEK). Negligible + vs the rest of the request path. + +### Bench (5 s/cell, `max_concurrency=32`, 64 clients) + +| Stack | Phase 2 RPS / p50 | Phase 3-simplified RPS / p50 | +| ----------------- | ----------------: | ---------------------------: | +| `localpost_native`| 9,381 / 6.29 | 8,961 / 7.09 ms | +| `localpost_flask` | 8,157 / 7.44 | 8,053 / 7.88 ms | +| `localpost_wsgi` | 7,743 / 7.87 | 7,630 / 8.29 ms | + +Within run-to-run noise. We're still selector-thread CPU bound at ~9 k RPS +— this refactor was about **maintainability and correctness**, not raising +the ceiling. 10/10 stress runs at 800/800. + +### What's left for future perf work + +The selector ceiling is real. Options to revisit: + +- **Move accept + parse + dispatch to multiple selector threads.** Multiple + selectors each owning a slice of conns. More CPU, more lock-free. +- **Lock-free op queue (the reverted Phase 3).** Now feasible on the + simpler base — only two ops to consider, no 3-way mode races. The whole + argument for op-queue was contention on `Server._lock`; the simpler + model would let it work. +- **Replace pure-Python h11 parsing with a C parser** (e.g. `httptools`). + Likely the easiest win at this point. diff --git a/localpost/http/_cancel.py b/localpost/http/_cancel.py index 5e758e6..a9715a2 100644 --- a/localpost/http/_cancel.py +++ b/localpost/http/_cancel.py @@ -8,6 +8,7 @@ from __future__ import annotations +import socket import threading from collections.abc import Generator from contextlib import contextmanager @@ -22,7 +23,8 @@ class RequestCancelled(Exception): """Raised by :func:`check_cancelled` when the current request is cancelled. Triggers: - * The HTTP client disconnected mid-request (selector watchdog detected EOF). + * The HTTP client disconnected mid-request (detected by a non-blocking + ``MSG_PEEK`` on the request socket). * The hosted service is shutting down. Inherits from :class:`Exception` (not :class:`BaseException`): request-scoped @@ -34,16 +36,59 @@ class RequestCancelled(Exception): @final @dataclass(eq=False, slots=True) class RequestCancel: - """Per-request cancellation token. Flipped by the dispatcher on disconnect / shutdown.""" + """Per-request cancellation token. + + Three signals collapsed into one: + + * Explicit :meth:`cancel` (sets ``_event``) — used by tests and any future + per-request timeout. + * Service shutdown — a single shared :class:`threading.Event` is OR-ed in. + Set once when the hosted service is winding down; every in-flight token + sees it without a registry. + * Client disconnect — :meth:`is_cancelled` does a non-blocking + ``recv(1, MSG_PEEK | MSG_DONTWAIT)`` on the request socket. ``b""`` means + EOF (peer FIN); ``BlockingIOError`` means no signal; any other ``OSError`` + treats the connection as broken. Once detected, the result is cached on + ``_event`` so subsequent reads don't re-issue the syscall. + + ``MSG_PEEK`` is safe alongside the worker's send/recv on the same socket: + peek doesn't consume bytes, and the read and write paths are independent at + the kernel level. + """ + _sock: socket.socket + _shutdown_event: threading.Event _event: threading.Event = field(default_factory=threading.Event) def cancel(self) -> None: self._event.set() + @property + def fired(self) -> bool: + """``True`` iff cancellation was already signalled — explicit cancel, + service shutdown, or a previous PEEK detected disconnect (cached on + ``_event``). + + Cheap (no syscall). For the cooperative ``check_cancelled`` path that + actively probes the socket for client disconnect, use :attr:`is_cancelled`. + """ + return self._event.is_set() or self._shutdown_event.is_set() + @property def is_cancelled(self) -> bool: - return self._event.is_set() + if self.fired: + return True + try: + peeked = self._sock.recv(1, socket.MSG_PEEK | socket.MSG_DONTWAIT) + except BlockingIOError: + return False + except OSError: + self._event.set() + return True + if not peeked: + self._event.set() + return True + return False _current: ContextVar[RequestCancel] = ContextVar("localpost.http._cancel.current") diff --git a/localpost/http/_service.py b/localpost/http/_service.py index b698cce..edd9ebe 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -6,7 +6,6 @@ from contextlib import suppress from wsgiref.types import WSGIApplication -import h11 from anyio import ( BrokenResourceError, CapacityLimiter, @@ -20,28 +19,12 @@ from localpost.hosting import ServiceLifetime from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request from localpost.http.config import LOGGER_NAME, ServerConfig -from localpost.http.server import ConnMode, HTTPReqCtx, RequestHandler, emit_handler_error, start_http_server +from localpost.http.server import HTTPReqCtx, RequestHandler, emit_handler_error, start_http_server from localpost.http.wsgi import wrap_wsgi __all__ = ["http_server", "wsgi_server"] -def _request_has_body(request: h11.Request) -> bool: - """``True`` iff the request advertises a non-empty body (``Content-Length > 0`` or chunked).""" - for name, value in request.headers: - n = bytes(name).lower() - if n == b"content-length": - try: - return int(value) > 0 - except ValueError: - return True - if n == b"transfer-encoding": - return True - return False - - - - @hosting.service def http_server( config: ServerConfig, @@ -60,8 +43,10 @@ def http_server( Per-request cancellation is HTTP-native and lives behind :func:`localpost.http.check_cancelled`. Triggers: - * Client disconnected mid-request (selector watchdog detected EOF). - * Service is shutting down. + * Client disconnected mid-request (pull-based ``MSG_PEEK`` on the + request socket from inside ``check_cancelled``). + * Service is shutting down (a single shared ``threading.Event`` is OR-ed + into every in-flight token's ``is_cancelled`` check). Worker / service cancellation (a separate concern) flows through the AnyIO machinery as elsewhere in :mod:`localpost`. @@ -74,26 +59,25 @@ def http_server( def run(lt: ServiceLifetime) -> Awaitable[None]: tx, rx = threadtools.Channel[tuple[HTTPReqCtx, RequestCancel]].create(capacity=max_concurrency) - # Tokens for in-flight requests, so service shutdown can flip them all at once. - in_flight: set[RequestCancel] = set() - in_flight_lock = threading.Lock() + # Single shutdown signal shared by every in-flight cancel token. No + # per-request registry needed — workers see shutdown via their token's + # ``is_cancelled`` check. + shutdown_event = threading.Event() # Dedicated thread limiter so workers + selector don't consume slots from # AnyIO's default global limiter. threads_limiter = CapacityLimiter(max_concurrency + 1) def dispatch(ctx: HTTPReqCtx) -> None: - """Selector-thread callback: parse done, hand off to a worker.""" - cancel = RequestCancel() - if _request_has_body(ctx.request): - # The worker will do blocking I/O to read the body. We can't - # arm the watchdog here — buffered body bytes would make the - # selector spin on a level-triggered ``EVENT_READ``. - ctx._server.stop_tracking(ctx._conn) - else: - # No body to read — arm the watchdog. EOF on the socket = client - # walked away while the handler was running. ``MSG_PEEK`` is safe - # alongside the worker's ``send`` (different ops at the kernel level). - ctx._server.to_watchdog(ctx._conn, cancel.cancel) + """Selector-thread callback: parse done, hand off to a worker. + + Always uses ``stop_tracking`` (the original borrow flow): the conn + leaves the selector entirely while the worker owns it. Client + disconnect detection while the worker is running happens inside + :func:`localpost.http.check_cancelled` via a non-blocking + ``MSG_PEEK``. + """ + ctx._server.stop_tracking(ctx._conn) + cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) try: tx.put((ctx, cancel)) except (ClosedResourceError, BrokenResourceError): @@ -109,8 +93,6 @@ def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) except (EndOfStream, ClosedResourceError): return - with in_flight_lock: - in_flight.add(cancel) try: with _enter_request(cancel): try: @@ -125,34 +107,27 @@ def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) ) emit_handler_error(ctx) finally: - with in_flight_lock: - in_flight.discard(cancel) # Conn-release policy: # - # On the success path, ``finish_response`` already drained h11 events and - # re-tracked the conn (mode → NORMAL). We MUST NOT call ``track`` here - # because ``track`` flips the socket back to non-blocking via - # ``settimeout(0)`` — which would race with the next request's worker - # already using the shared socket in blocking mode (response-body - # writes), causing spurious ``BlockingIOError`` and dropped requests. + # On the success path, ``finish_response`` already re-tracked the conn + # via ``_maybe_give_back`` — we MUST NOT touch it here. Specifically, + # we cannot read ``ctx._conn.tracked``: that field is shared with the + # next request's dispatcher, which clears it via ``stop_tracking`` + # before this finally runs. # - # The only thing left to do is close the conn for the unhappy paths: - # client disconnected, watchdog fired, or the handler returned without - # completing the response (mode != NORMAL). - if ( - cancel.is_cancelled - or ctx._conn.recv_closed - or ctx._conn.mode is not ConnMode.NORMAL - ): + # The only case left to handle is per-request cancellation: the + # handler caught the signal and returned, but the conn is in an + # uncertain state. ``cancel.fired`` is the cheap (no-syscall) + # check — ``is_cancelled`` would actively re-probe the socket + # which is wasteful here. + # ``emit_handler_error`` already closes on the exception path. + if cancel.fired: with suppress(Exception): ctx._conn.close() async def shutdown_watcher() -> None: await lt.shutting_down.wait() - with in_flight_lock: - tokens = list(in_flight) - for token in tokens: - token.cancel() + shutdown_event.set() tx.close() def run_server() -> None: diff --git a/localpost/http/server.py b/localpost/http/server.py index b243a36..6328d5c 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -8,7 +8,6 @@ from __future__ import annotations -import enum import logging import selectors import socket @@ -26,33 +25,6 @@ __all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] -class ConnMode(enum.Enum): - """Selector-tracking state of an :class:`HTTPConn`. - - UNTRACKED — not registered in the selector at all (worker holds the conn). - NORMAL — registered for HTTP processing; selector reads bytes and drives the parser. - WATCHDOG — registered for client-disconnect detection only; selector does - ``recv(MSG_PEEK)`` on wake-up to detect EOF, never consumes bytes. - """ - - UNTRACKED = "untracked" - NORMAL = "normal" - WATCHDOG = "watchdog" - - -@final -@dataclass(eq=False, slots=True) -class _WatchdogToken: - """Selector data-tag for a conn in WATCHDOG mode. - - ``on_disconnect`` is invoked once when the selector detects EOF on the - socket. The token is then unregistered. - """ - - conn: HTTPConn - on_disconnect: Callable[[], None] - - _INTERNAL_ERROR_BODY = b"Internal Server Error" _INTERNAL_ERROR_RESPONSE = h11.Response( status_code=500, @@ -216,7 +188,7 @@ def _cleanup_stale(self): self.selector.unregister(conn.sock) except (KeyError, ValueError): pass - conn.mode = ConnMode.UNTRACKED + conn.tracked = False # Drop the lock for any I/O — closing a socket can block, and we don't # want to hold the selector lock while talking to the kernel. for conn in stale: @@ -243,21 +215,15 @@ def _cleanup_stale(self): pass def track(self, conn: HTTPConn) -> None: - """Register or restore ``conn`` to NORMAL mode (selector reads bytes). - - Handles all incoming transitions: - * UNTRACKED → NORMAL: register fresh. - * WATCHDOG → NORMAL: swap selector data-tag, no re-register. - * NORMAL → NORMAL: no-op. + """Register ``conn`` in the selector for normal HTTP processing. - ``conn.mode`` is updated *under* ``_lock``: any ``_handle_watchdog_event`` - racing the same fd must observe a consistent ``(selector data-tag, mode)`` pair. + Idempotent — a no-op if ``conn`` is already tracked. """ sock = conn.sock try: sock.settimeout(0) except OSError: - conn.mode = ConnMode.UNTRACKED + conn.tracked = False return with self._lock: if self.shutting_down: @@ -265,58 +231,29 @@ def track(self, conn: HTTPConn) -> None: sock.close() except OSError: pass - conn.mode = ConnMode.UNTRACKED + conn.tracked = False return - if conn.mode is ConnMode.WATCHDOG: - self.selector.modify(sock, selectors.EVENT_READ, data=conn) - elif conn.mode is ConnMode.UNTRACKED: + if not conn.tracked: self.selector.register(sock, selectors.EVENT_READ, data=conn) - # else NORMAL: nothing to do - conn.mode = ConnMode.NORMAL + conn.tracked = True def stop_tracking(self, conn: HTTPConn) -> None: - """Unregister ``conn`` from the selector and switch the socket to blocking I/O.""" + """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. + + Switches the socket to blocking-with-timeout (``rw_timeout``) so the + worker can do synchronous send/recv. Client-disconnect detection + while the conn is borrowed lives in + :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). + """ sock = conn.sock with self._lock: try: self.selector.unregister(sock) except (KeyError, ValueError): pass - conn.mode = ConnMode.UNTRACKED + conn.tracked = False sock.settimeout(self.config.rw_timeout) - def to_watchdog(self, conn: HTTPConn, on_disconnect: Callable[[], None]) -> None: - """Place ``conn`` in WATCHDOG mode for client-disconnect detection. - - The socket is switched to blocking-with-timeout for the worker thread - (which now owns it for I/O). The selector keeps the fd registered but - only does ``recv(MSG_PEEK)`` on wake-up, never consuming bytes — - ``MSG_PEEK`` does not conflict with a concurrent ``send`` from the - worker. - - ``on_disconnect`` is fired at most once when EOF (peer FIN) is detected. - """ - sock = conn.sock - try: - sock.settimeout(self.config.rw_timeout) - except OSError: - conn.mode = ConnMode.UNTRACKED - return - token = _WatchdogToken(conn, on_disconnect) - with self._lock: - if self.shutting_down: - try: - sock.close() - except OSError: - pass - conn.mode = ConnMode.UNTRACKED - return - if conn.mode is ConnMode.UNTRACKED: - self.selector.register(sock, selectors.EVENT_READ, data=token) - else: # NORMAL or WATCHDOG — swap the data-tag in place - self.selector.modify(sock, selectors.EVENT_READ, data=token) - conn.mode = ConnMode.WATCHDOG - def _shutdown_active_connections(self) -> None: """Set the shutdown flag and close any connections still in the selector. @@ -330,13 +267,9 @@ def _shutdown_active_connections(self) -> None: for key in keys: if key.fileobj is self.sock: continue # listening socket — closed by the outer CM - conn: HTTPConn | None - if isinstance(key.data, HTTPConn): - conn = key.data - elif isinstance(key.data, _WatchdogToken): - conn = key.data.conn - else: + if not isinstance(key.data, HTTPConn): continue + conn = key.data try: self.selector.unregister(conn.sock) except (KeyError, ValueError): @@ -345,7 +278,7 @@ def _shutdown_active_connections(self) -> None: conn.sock.close() except OSError: pass - conn.mode = ConnMode.UNTRACKED + conn.tracked = False def run(self, *, timeout: float | None = None) -> None: """One iteration of the server loop. Should be called repeatedly until the server is stopped. @@ -360,14 +293,11 @@ def run(self, *, timeout: float | None = None) -> None: h = self.handler self._cleanup_stale() for key, _ in self.selector.select(timeout=timeout): - data = key.data if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() conn = HTTPConn(self, client_sock, client_addr) self.track(conn) - elif isinstance(data, _WatchdogToken): - self._handle_watchdog_event(data) - elif isinstance(data, HTTPConn): + elif (data := key.data) and isinstance(data, HTTPConn): try: data(h) except Exception: @@ -379,52 +309,6 @@ def run(self, *, timeout: float | None = None) -> None: else: raise RuntimeError(f"Unexpected selector key: {key!r}") - def _handle_watchdog_event(self, wd: _WatchdogToken) -> None: - """Selector wake-up on a WATCHDOG-mode conn. - - Peek for EOF without consuming bytes — ``recv(1, MSG_PEEK | MSG_DONTWAIT)`` - returns ``b""`` on a clean half-close (peer FIN), data if buffered, or - raises ``BlockingIOError`` if the wake-up was spurious. - - Once the watchdog has fired (or seen buffered data) we unregister; the - worker's :meth:`track` will re-attach the conn after the response - finishes. Avoids busy-looping on level-triggered selectors. - - **Race-guard:** the ``key.data`` reference reported by ``selector.select`` - is captured before we acquire ``_lock``. By the time we run, the worker - thread may have swapped the conn back to NORMAL mode via :meth:`track`. - We re-check ``conn.mode`` under the lock and bail if so — otherwise we'd - unregister an fd the worker just re-tracked and selector wakes for - subsequent requests on this conn would never arrive. - """ - if wd.conn.mode is not ConnMode.WATCHDOG: - return - sock = wd.conn.sock - eof = False - try: - peeked = sock.recv(1, socket.MSG_PEEK | socket.MSG_DONTWAIT) - except BlockingIOError: - return # spurious wake-up; leave the watchdog armed - except OSError: - eof = True - else: - if not peeked: - eof = True - with self._lock: - if wd.conn.mode is not ConnMode.WATCHDOG: - return # raced with track() — leave the worker's NORMAL state alone - try: - self.selector.unregister(sock) - except (KeyError, ValueError): - pass - wd.conn.mode = ConnMode.UNTRACKED - if eof: - wd.conn.recv_closed = True - try: - wd.on_disconnect() - except Exception: - self.logger.exception("Watchdog on_disconnect callback failed") - @final @dataclass(eq=False, slots=True) @@ -435,8 +319,9 @@ class HTTPConn: recv_closed: bool = False parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) close_at: float | None = None # Used only when tracked, to enforce keep-alive and read timeouts - mode: ConnMode = ConnMode.UNTRACKED - """Current selector-tracking state. See :class:`ConnMode`.""" + tracked: bool = False + """``True`` iff this conn is registered in the selector. ``False`` while a + worker has borrowed it (between ``stop_tracking`` and the next ``track``).""" body_bytes_received: int = 0 """Cumulative body bytes received for the current request — reset on ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` @@ -447,22 +332,14 @@ class HTTPConn: for the current request. Distinguishes idle keep-alive (close silently) from a stalled mid-request (emit 408 Request Timeout).""" - @property - def tracked(self) -> bool: - """Backward-compat: ``True`` iff in NORMAL selector-tracking mode. - - New code should branch on :attr:`mode` directly. - """ - return self.mode is ConnMode.NORMAL - def close(self) -> None: - if self.mode is not ConnMode.UNTRACKED: + if self.tracked: with self.server._lock: try: self.server.selector.unregister(self.sock) except (KeyError, ValueError): pass - self.mode = ConnMode.UNTRACKED + self.tracked = False # Send a FIN before close() so the client sees a clean half-close # rather than a possible RST when there's unread data in the kernel # receive buffer. Errors are expected on already-broken sockets. From 621f98f29394fc48702468c2860c562d6733ce47 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 21:25:02 +0400 Subject: [PATCH 098/286] docs(http): document check_cancelled + two-state conn model - Public API gains a Cancellation table covering check_cancelled() and RequestCancelled. - Design section drops the watchdog reference; adds the two-state (tracked / borrowed) model and pull-based disconnect detection notes. - Roadmap entry for the self-pipe + op queue is reframed: the model simplification has cleared the way (no more 3-state mode races), but the work is deferred since selector CPU is the actual ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/README.md | 94 +++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index c42d876..b8bc783 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -84,6 +84,13 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) | `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`) | | `RequestHandler` | `Callable[[HTTPReqCtx], None]` | +### Cancellation + +| Symbol | Notes | +| --------------------- | -------------------------------------------------------------- | +| `check_cancelled()` | Cooperative cancel check for sync handlers. Raises `RequestCancelled` if the client disconnected (detected via non-blocking ``MSG_PEEK``) or the hosted service is shutting down. Call periodically in long-running handlers. | +| `RequestCancelled` | Exception raised by `check_cancelled()`. Inherits from `Exception` (not `BaseException`) — catchable with `except Exception:`. | + ### `localpost.http.router` | Symbol | Notes | @@ -190,51 +197,70 @@ intentional and not a planned extension.** The whole package is built around blocking sockets and a thread pool: the selector accepts connections, parses HTTP/1.1 with h11, and dispatches each -request to a worker thread that does its I/O synchronously. Per-request -cancellation is HTTP-native (`localpost.http.check_cancelled`), driven by -the selector's client-disconnect watchdog and by service-shutdown signals — -not by AnyIO. +request to a worker thread that does its I/O synchronously. If you need an async server, use one of the ASGI servers that already exist (uvicorn, hypercorn, granian, …) — the `localpost.hosting` adapters in `localpost.hosting.services/` plug them in cleanly. There is no need for this package to grow a second, parallel async path. +### Two-state connection model + +A connection is either **tracked** (registered in the selector for normal +HTTP processing) or **borrowed** (a worker thread holds it for the duration +of one request). The dispatcher unregisters before handing off to a worker +(`stop_tracking`); `finish_response` re-registers via `_maybe_give_back`. +Two states, no third "watchdog" mode, no shared mode field for threads to +race on. + +### Pull-based client-disconnect detection + +While the worker holds a borrowed connection, client disconnects are +detected on demand: `check_cancelled()` does a non-blocking +`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. `b""` means peer +FIN — `RequestCancelled` is raised. Handlers that do regular I/O surface +disconnects via `EPIPE` / `ECONNRESET` naturally; handlers that compute +without I/O should call `check_cancelled()` periodically (same contract as +service-shutdown cancellation). + +This replaces an earlier push-based design where the selector kept the +socket registered in a third "watchdog" mode and fired EOF events. That +worked but introduced a 3-way state machine with cross-thread races. The +pull-based variant collapses to two states and one syscall per +`check_cancelled()` call. + ## Roadmap Items that are not currently bugs but where there's a known better implementation worth tackling later. -### Self-pipe wakeup + per-iteration op queue - -Today `Server.track` / `Server.stop_tracking` mutate the selector under -`Server._lock` from any thread, and the accept-loop thread reads it via -`selector.select(timeout=config.select_timeout)`. The shipped selectors on -macOS (`kqueue`) and Linux (`epoll`) observe set modifications during a wait, -so newly tracked fds are picked up immediately and correctness is fine. On -selectors that don't (`SelectSelector`), the loop only sees the new fd once -the current `select()` returns — up to `select_timeout` of latency on a -keep-alive return. - -The fix is the standard **self-pipe trick** plus a single-writer model: - -1. Allocate an internal `os.pipe()` registered for `EVENT_READ` alongside the - listening socket. -2. Worker threads stop touching the selector. `track` / `stop_tracking` - enqueue an op (e.g. `("track", conn)`) onto a thread-safe deque, then - write one byte to the pipe to wake the loop. -3. `Server.run`, at the start of each iteration, drains the pipe, drains the - op queue (under the lock), applies the ops, and only then calls - `select()`. - -Benefits: portable across all selectors, sub-millisecond wakeup on -track / untrack regardless of `select_timeout`, and a cleaner threading -model where the selector has exactly one writer. - -Cost: ~80–120 LoC of careful threading, an extra fd pair per server, and a -new stress test for rapid track / untrack churn. Deferred for now since the -default selector on supported platforms doesn't suffer from the latency in -practice. +### Lock-free op queue + self-pipe wakeup + +Today `Server.track` is the only cross-thread selector mutation: workers +call it from `_maybe_give_back` after a response completes. It acquires +`Server._lock` to serialise the `selector.modify` / `selector.register` +call against the selector thread. Cheap in absolute terms (~µs), but +under heavy keep-alive contention this is per-request lock traffic. + +Goal: make the selector the single writer to its own state. + +1. Allocate an internal `os.pipe()` registered for `EVENT_READ` alongside + the listening socket. +2. Workers stop touching the selector. `Server.track` from a worker thread + enqueues an op (e.g. `("track", conn)`) onto a thread-safe `deque` and + writes one byte to the pipe to wake the selector. +3. `Server.run`, at the start of each iteration, drains the pipe, drains + the op queue, applies the ops, and only then calls `select()`. + +Cost: ~80–120 LoC of careful threading, an extra fd pair per server, and +a new stress test for rapid track / untrack churn. + +Status: previously attempted with a 3-state conn model — the optimistic +mode-flip semantics raced. Now that the model has been simplified to two +states and one shared field, the op queue should fit cleanly. Deferred: +the selector thread is CPU-bound on h11 parsing well before the lock +becomes a bottleneck on this hardware (verified at `max_concurrency=128`). +See [`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md). ## How is it different from… From 66a7999a89ddcabcdb8bee0fded26a90c5c490f7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 21:34:11 +0400 Subject: [PATCH 099/286] perf(http): lock-free op queue + self-pipe wakeup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server._lock removed. Workers no longer touch the selector directly: they enqueue typed ops on a collections.deque and write a wakeup byte to an internal os.pipe() registered in the selector. The selector is now the single writer to its own state, drained at the top of every iteration and on wakeup-sentinel events. Two ops only: - _OpTrack(conn) — register/modify conn with data=conn. - _OpClose(fd) — clean up selector._fd_to_key after sock.close(). HTTPConn captures sock.fileno() into ``HTTPConn.fd`` at construction so the close path can use selector.unregister(fd_int) even after sock.close() (where sock.fileno() returns -1). Server.track and HTTPConn.close branch on threading.get_ident() against the cached selector thread id — inline if we're on the selector thread, otherwise enqueue. Same external API. Bench (5s/cell, max_concurrency=32): localpost_native plaintext 8,961 → 8,843 RPS (within noise) The selector is CPU-bound on h11 parsing well before the lock becomes a bottleneck — confirmed in Phase 2 (max_concurrency=128 plateau). The win isn't in the bench: the threading model is now strictly single-writer to the selector, and the lock is gone. 10/10 stress at 800/800. Roadmap entry for the op queue → done. Replaced with "faster HTTP/1.1 parsing" (C parser) as the next realistic perf step. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 40 ++++- localpost/http/README.md | 35 +--- localpost/http/server.py | 299 ++++++++++++++++++++++++------- 3 files changed, 281 insertions(+), 93 deletions(-) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 647be01..c0cd8be 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -313,9 +313,41 @@ The selector ceiling is real. Options to revisit: - **Move accept + parse + dispatch to multiple selector threads.** Multiple selectors each owning a slice of conns. More CPU, more lock-free. -- **Lock-free op queue (the reverted Phase 3).** Now feasible on the - simpler base — only two ops to consider, no 3-way mode races. The whole - argument for op-queue was contention on `Server._lock`; the simpler - model would let it work. - **Replace pure-Python h11 parsing with a C parser** (e.g. `httptools`). Likely the easiest win at this point. + +## Phase 3-B (shipped): lock-free op queue + self-pipe wakeup + +With the conn model down to two states, the original Phase 3 design fits +cleanly. Workers no longer touch the selector directly: + +- ``Server._lock`` deleted. +- ``Server._ops: collections.deque`` — atomic ``append`` / ``popleft``. +- ``os.pipe()`` registered in the selector for wakeup. +- ``Server.track`` from a worker thread enqueues ``_OpTrack(conn)`` and + writes a wakeup byte. ``HTTPConn.close`` from a worker enqueues + ``_OpClose(fd)`` after closing the kernel socket synchronously (the + ``_OpClose`` handler just cleans ``selector._fd_to_key``). +- Selector drains ``_ops`` at the top of every iteration and on + wakeup-sentinel events; ``stop_tracking`` and ``_cleanup_stale`` run + inline (already on selector thread). +- ``HTTPConn.fd`` captured at construction so cleanup can use the integer + fd via ``selector.unregister(fd_int)`` even after ``sock.close()`` (where + ``sock.fileno()`` returns -1). + +### Bench (5 s/cell, ``max_concurrency=32``, 64 clients) + +| Stack | Phase 3-A (lock) RPS / p50 | Phase 3-B (op queue) RPS / p50 | +| ----------------- | -------------------------: | -----------------------------: | +| `localpost_native`| 8,961 / 7.09 | 8,843 / 7.15 | +| `localpost_flask` | 8,053 / 7.88 | 7,884 / 7.92 | +| `localpost_wsgi` | 7,630 / 8.29 | 7,505 / 8.45 | + +Within run-to-run noise — confirming the prediction that we're selector +CPU-bound (h11 parsing), not lock-bound. 10/10 stress at 800/800. + +The win isn't in the bench: it's in **the threading model is now strictly +single-writer to the selector**, and the lock is gone. Future work that +needs to add cross-thread selector mutations (e.g. scheduled FD timers) +just enqueues an op — no lock to contend with, no consistency invariants +to re-prove. diff --git a/localpost/http/README.md b/localpost/http/README.md index b8bc783..efca43f 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -234,33 +234,14 @@ pull-based variant collapses to two states and one syscall per Items that are not currently bugs but where there's a known better implementation worth tackling later. -### Lock-free op queue + self-pipe wakeup - -Today `Server.track` is the only cross-thread selector mutation: workers -call it from `_maybe_give_back` after a response completes. It acquires -`Server._lock` to serialise the `selector.modify` / `selector.register` -call against the selector thread. Cheap in absolute terms (~µs), but -under heavy keep-alive contention this is per-request lock traffic. - -Goal: make the selector the single writer to its own state. - -1. Allocate an internal `os.pipe()` registered for `EVENT_READ` alongside - the listening socket. -2. Workers stop touching the selector. `Server.track` from a worker thread - enqueues an op (e.g. `("track", conn)`) onto a thread-safe `deque` and - writes one byte to the pipe to wake the selector. -3. `Server.run`, at the start of each iteration, drains the pipe, drains - the op queue, applies the ops, and only then calls `select()`. - -Cost: ~80–120 LoC of careful threading, an extra fd pair per server, and -a new stress test for rapid track / untrack churn. - -Status: previously attempted with a 3-state conn model — the optimistic -mode-flip semantics raced. Now that the model has been simplified to two -states and one shared field, the op queue should fit cleanly. Deferred: -the selector thread is CPU-bound on h11 parsing well before the lock -becomes a bottleneck on this hardware (verified at `max_concurrency=128`). -See [`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md). +### Faster HTTP/1.1 parsing + +The selector thread is CPU-bound on `h11` (pure Python). Switching to a C +parser (`httptools` or similar) is the largest realistic single-step +perf win — a 30-50% RPS uplift would be plausible. Trade-off: adds a C +dependency and weakens the "minimal, readable, in-Python" pitch. See +[`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md) +for the analysis behind the current bench numbers. ## How is it different from… diff --git a/localpost/http/server.py b/localpost/http/server.py index 6328d5c..8f62813 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -8,7 +8,10 @@ from __future__ import annotations +import collections +import fcntl import logging +import os import selectors import socket import threading @@ -25,6 +28,35 @@ __all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] +# Selector data-tag for ``Server._wakeup_r`` — distinguishable in the for-event +# loop so the selector thread knows to drain the wakeup pipe + op queue. +_WAKEUP_SENTINEL: object = object() + + +@final +@dataclass(eq=False, slots=True, frozen=True) +class _OpTrack: + """Worker-enqueued op: register ``conn`` in the selector with ``data=conn``.""" + + conn: HTTPConn + + +@final +@dataclass(eq=False, slots=True, frozen=True) +class _OpClose: + """Worker-enqueued op: clean up ``selector._fd_to_key[fd]`` after ``sock.close()``. + + The kernel auto-removes the fd from kqueue/epoll on ``close()``. The + Python-side ``_fd_to_key`` dict is what we actually need to clean up so + the conn instance can be GC'd. + """ + + fd: int + + +_Op = _OpTrack | _OpClose + + _INTERNAL_ERROR_BODY = b"Internal Server Error" _INTERNAL_ERROR_RESPONSE = h11.Response( status_code=500, @@ -160,11 +192,29 @@ def __init__( self.config = config self.handler = handler self.logger = logger - self._lock = threading.Lock() self.shutting_down: bool = False """Set to True on context-manager exit. Once set, ``track`` rejects new registrations and ``_maybe_give_back`` closes connections instead of returning them to the selector.""" + # Lock-free op queue + self-pipe wakeup. The selector thread is the + # single writer to ``self.selector``; cross-thread mutations from + # workers (``track`` from ``_maybe_give_back``, ``close`` from error + # paths) enqueue ops and write a wakeup byte. The selector drains at + # the top of every iteration and on wakeup-sentinel events. + self._ops: collections.deque[_Op] = collections.deque() + r, w = os.pipe() + os.set_blocking(r, False) + os.set_blocking(w, False) + for fd in (r, w): + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) + self._wakeup_r: int = r + self._wakeup_w: int = w + selector.register(r, selectors.EVENT_READ, data=_WAKEUP_SENTINEL) + self._selector_thread_id: int | None = None + """Cached on the first ``run()`` call. Used to route ``track`` / + ``close`` calls inline when invoked from the selector thread itself + (e.g. from the accept branch of ``run``).""" # Pre-baked ``Keep-Alive: timeout=N`` header tuple, or ``None`` if # keep-alive is disabled (``keep_alive_timeout < 1``). Computed once # so :meth:`HTTPReqCtx._maybe_inject_keep_alive` doesn't pay the @@ -181,16 +231,15 @@ def _find_stale(self): yield conn def _cleanup_stale(self): - with self._lock: - stale = list(self._find_stale()) - for conn in stale: - try: - self.selector.unregister(conn.sock) - except (KeyError, ValueError): - pass - conn.tracked = False - # Drop the lock for any I/O — closing a socket can block, and we don't - # want to hold the selector lock while talking to the kernel. + # Selector-thread only. Lock-free: ``self.selector`` and + # ``conn.tracked`` are owned by the selector thread. + stale = list(self._find_stale()) + for conn in stale: + try: + self.selector.unregister(conn.sock) + except (KeyError, ValueError): + pass + conn.tracked = False for conn in stale: # Stalled mid-request (some bytes received but no complete request # yet, and no response started) gets a 408; idle keep-alive gets @@ -217,7 +266,11 @@ def _cleanup_stale(self): def track(self, conn: HTTPConn) -> None: """Register ``conn`` in the selector for normal HTTP processing. - Idempotent — a no-op if ``conn`` is already tracked. + Safe from any thread. When called from a worker, the actual + ``selector.register`` happens on the selector thread (drained from + ``self._ops`` at the top of the next iteration). ``conn.tracked`` + is flipped optimistically so concurrent readers see the intended + state. """ sock = conn.sock try: @@ -225,60 +278,153 @@ def track(self, conn: HTTPConn) -> None: except OSError: conn.tracked = False return - with self._lock: - if self.shutting_down: - try: - sock.close() - except OSError: - pass - conn.tracked = False - return - if not conn.tracked: - self.selector.register(sock, selectors.EVENT_READ, data=conn) - conn.tracked = True + if self.shutting_down: + try: + sock.close() + except OSError: + pass + conn.tracked = False + return + if conn.tracked: + return # already tracked — no-op, nothing to enqueue + conn.tracked = True # optimistic + if threading.get_ident() == self._selector_thread_id: + self._apply_track(conn) + else: + self._ops.append(_OpTrack(conn)) + self._wake() def stop_tracking(self, conn: HTTPConn) -> None: """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. - Switches the socket to blocking-with-timeout (``rw_timeout``) so the - worker can do synchronous send/recv. Client-disconnect detection - while the conn is borrowed lives in + Selector-thread only (called from the dispatcher inside ``Server.run``'s + for-event loop). Switches the socket to blocking-with-timeout + (``rw_timeout``) so the worker can do synchronous send/recv. + Client-disconnect detection while the conn is borrowed lives in :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). """ sock = conn.sock - with self._lock: - try: - self.selector.unregister(sock) - except (KeyError, ValueError): + try: + self.selector.unregister(sock) + except (KeyError, ValueError): + pass + conn.tracked = False + sock.settimeout(self.config.rw_timeout) + + # ----- Op queue + self-pipe helpers ----- + + def _wake(self) -> None: + """Signal the selector that ``self._ops`` has work. + + ``BlockingIOError`` (pipe full) is benign: a wakeup is already pending, + the existing byte will fire ``select()`` and the queue is the source + of truth. Other ``OSError`` (pipe closed during shutdown) is also + swallowed. + """ + try: + os.write(self._wakeup_w, b"\x00") + except (BlockingIOError, OSError): + pass + + def _drain_wakeup(self) -> None: + try: + while os.read(self._wakeup_r, 4096): pass + except (BlockingIOError, OSError): + pass + + def _drain_ops(self) -> None: + """Apply all pending ops on the selector thread.""" + while True: + try: + op = self._ops.popleft() + except IndexError: + return + try: + if isinstance(op, _OpTrack): + self._apply_track(op.conn) + elif isinstance(op, _OpClose): + try: + self.selector.unregister(op.fd) + except (KeyError, ValueError): + pass + except Exception: + self.logger.exception("Op handler raised for %r", op) + + def _apply_track(self, conn: HTTPConn) -> None: + """Selector-thread handler for ``_OpTrack``. + + Probes the selector (modify-then-fall-back-to-register) instead of + relying on a captured prev-mode snapshot — between the worker's + ``track()`` and our drain, the conn could have been unregistered + elsewhere (or already registered by a previous op). + """ + sock = conn.sock + try: + try: + self.selector.modify(sock, selectors.EVENT_READ, data=conn) + except KeyError: + self.selector.register(sock, selectors.EVENT_READ, data=conn) + except Exception: # noqa: BLE001 conn.tracked = False - sock.settimeout(self.config.rw_timeout) + try: + sock.close() + except OSError: + pass def _shutdown_active_connections(self) -> None: """Set the shutdown flag and close any connections still in the selector. - Called from ``start_http_server.__exit__``. Borrowed connections (held - by handler threads) are not in the selector — they are closed by their - handlers on the next ``_maybe_give_back`` after we set the flag. + Called from ``start_http_server.__exit__`` *after* the selector loop + has stopped (verified in ``_service.py:run_server`` and + ``serve_in_thread`` test fixture). Workers calling ``track`` / + ``close`` post-shutdown self-clean via the ``shutting_down`` check. """ - with self._lock: - self.shutting_down = True - keys = list(self.selector.get_map().values()) - for key in keys: - if key.fileobj is self.sock: - continue # listening socket — closed by the outer CM - if not isinstance(key.data, HTTPConn): - continue - conn = key.data - try: - self.selector.unregister(conn.sock) - except (KeyError, ValueError): - pass + self.shutting_down = True + + # Drain residual ops — selector won't process them, so the conn + # references would otherwise leak. + while True: + try: + op = self._ops.popleft() + except IndexError: + break + if isinstance(op, _OpTrack): try: - conn.sock.close() + op.conn.sock.close() except OSError: pass - conn.tracked = False + op.conn.tracked = False + # _OpClose just cleans _fd_to_key — handled by the walk below. + + for key in list(self.selector.get_map().values()): + if key.fileobj is self.sock: + continue # listening socket — closed by the outer CM + if key.fileobj == self._wakeup_r: + continue # wakeup pipe — closed below + if not isinstance(key.data, HTTPConn): + continue + conn = key.data + try: + self.selector.unregister(conn.sock) + except (KeyError, ValueError): + pass + try: + conn.sock.close() + except OSError: + pass + conn.tracked = False + + # Close the wakeup pipe. + try: + self.selector.unregister(self._wakeup_r) + except (KeyError, ValueError): + pass + for fd in (self._wakeup_r, self._wakeup_w): + try: + os.close(fd) + except OSError: + pass def run(self, *, timeout: float | None = None) -> None: """One iteration of the server loop. Should be called repeatedly until the server is stopped. @@ -287,17 +433,26 @@ def run(self, *, timeout: float | None = None) -> None: method blocks before returning to the caller, giving the caller a chance to check for shutdown / cancellation. Defaults to ``config.select_timeout``. """ + if self._selector_thread_id is None: + self._selector_thread_id = threading.get_ident() if timeout is None: timeout = self.config.select_timeout server_sock = self.sock h = self.handler + self._drain_ops() self._cleanup_stale() for key, _ in self.selector.select(timeout=timeout): + data = key.data + if data is _WAKEUP_SENTINEL: + self._drain_wakeup() + self._drain_ops() + continue if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() conn = HTTPConn(self, client_sock, client_addr) self.track(conn) - elif (data := key.data) and isinstance(data, HTTPConn): + continue + if isinstance(data, HTTPConn): try: data(h) except Exception: @@ -307,7 +462,7 @@ def run(self, *, timeout: float | None = None) -> None: except Exception: # noqa: BLE001, S110 pass else: - raise RuntimeError(f"Unexpected selector key: {key!r}") + raise RuntimeError(f"Unexpected selector key: {key!r}") # noqa: TRY004 @final @@ -316,6 +471,10 @@ class HTTPConn: server: Server sock: socket.socket addr: tuple[str, int] + fd: int = field(init=False) + """The integer file descriptor captured at construction time. Used to + clean up ``selector._fd_to_key`` after ``sock.close()`` (where + ``sock.fileno()`` returns -1).""" recv_closed: bool = False parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) close_at: float | None = None # Used only when tracked, to enforce keep-alive and read timeouts @@ -332,22 +491,38 @@ class HTTPConn: for the current request. Distinguishes idle keep-alive (close silently) from a stalled mid-request (emit 408 Request Timeout).""" + def __post_init__(self) -> None: + self.fd = self.sock.fileno() + def close(self) -> None: - if self.tracked: - with self.server._lock: - try: - self.server.selector.unregister(self.sock) - except (KeyError, ValueError): - pass - self.tracked = False - # Send a FIN before close() so the client sees a clean half-close - # rather than a possible RST when there's unread data in the kernel - # receive buffer. Errors are expected on already-broken sockets. + """Tear down the connection. + + Synchronously sends FIN + closes the socket so the kernel fd is + freed immediately. The selector-side ``_fd_to_key`` cleanup happens + on the selector thread — inline if we are it, else enqueued via + ``_OpClose``. Safe to call from any thread. + """ + was_tracked = self.tracked + self.tracked = False try: self.sock.shutdown(socket.SHUT_WR) except OSError: pass - self.sock.close() + try: + self.sock.close() + except OSError: + pass + if not was_tracked: + return + # Selector still has _fd_to_key[fd]. Clean up. + if threading.get_ident() == self.server._selector_thread_id: + try: + self.server.selector.unregister(self.fd) + except (KeyError, ValueError): + pass + else: + self.server._ops.append(_OpClose(self.fd)) + self.server._wake() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: data = self.sock.recv(size) From 7d338c2517cc5486dfea65f6c3edf332c78490cf Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 00:31:58 +0400 Subject: [PATCH 100/286] perf(http): lazy _cleanup_stale to skip the O(N) walk per iter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server._cleanup_stale was walking selector.get_map() on every iteration to discover "nothing to clean" — the common case under load (defaults: rw_timeout=1s, keep_alive_timeout=15s, so most conns are nowhere near expiring). Now: cache _last_cleanup_at; skip the walk when not enough time has passed. _cleanup_interval defaults to min(rw_timeout, keep_alive_timeout) / 2 = 0.5s, floored at 100ms. Trade-off: stale-detection latency goes from ~1s to ~1.5s worst case. Detection of non-fatal conditions (slow clients, dead keep-alive) only; no impact on per-request latency. At the bench scale (64 conns) the O(N) savings are within run-to-run noise (~580k Python ops/sec, a few percent of selector CPU). Scales linearly with conn count — a real keep-alive deployment with hundreds or thousands of idle conns sees proportionally larger gains. 10/10 stress at 800/800. All 129 tests pass. The bench is now firmly stuck at the h11 parsing ceiling. Further RPS gains require a C parser or multi-selector — both meaningful refactors deferred from this iteration. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 37 ++++++++++++++++++++++++++++++++ localpost/http/server.py | 21 ++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index c0cd8be..10509ac 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -316,6 +316,43 @@ The selector ceiling is real. Options to revisit: - **Replace pure-Python h11 parsing with a C parser** (e.g. `httptools`). Likely the easiest win at this point. +## Phase 4 (shipped): lazy `_cleanup_stale` + +Targeted audit of the selector hot path identified one piece of clearly +wasted CPU: ``Server._cleanup_stale`` walking ``selector.get_map().values()`` +on every iteration to discover "nothing to clean" — the common case under +load (default ``rw_timeout=1.0 s``, ``keep_alive_timeout=15 s``). + +Fix: cache ``_last_cleanup_at``, skip the O(N) walk when ``now - +_last_cleanup_at < _cleanup_interval``. ``_cleanup_interval`` defaults +to ``min(rw_timeout, keep_alive_timeout) / 2`` (=0.5 s with defaults), +floored at 100 ms. + +Trade-off: stale-detection latency goes from ~``select_timeout`` (1 s) to +~``timeout + 0.5 s`` (1.5 s for ``rw_timeout``, ~15.5 s for +``keep_alive_timeout``). Both are detection latencies for non-fatal +conditions (slow clients, dead keep-alive connections), well outside +any user-visible budget. + +### Bench (5 s/cell, ``max_concurrency=32``, 64 clients) + +| Stack | Phase 3-B RPS / p50 | Phase 4 RPS / p50 | +| ----------------- | ------------------: | ----------------: | +| `localpost_native`| 8,843 / 7.15 | 8,824 / 7.16 | +| `localpost_flask` | 7,884 / 7.92 | 7,909 / 8.01 | +| `localpost_wsgi` | 7,505 / 8.45 | ~7,500 / ~8 ms | + +Within run-to-run noise — as predicted. At 64 keep-alive conns, the +O(N) walk savings are ~64 ops per iter × ~9k iter/sec = ~580 k ops/sec +saved. A few percent of selector CPU, swallowed by bench noise. The +optimization scales with conn count: real-world deployments with +hundreds–thousands of idle keep-alive conns would see proportionally +larger gains. 10/10 stress at 800/800. + +The bench needle is now firmly stuck at the h11 parsing ceiling. +Future RPS gains require either a C parser (`httptools`) or +multi-selector / SO_REUSEPORT — both meaningful architectural changes. + ## Phase 3-B (shipped): lock-free op queue + self-pipe wakeup With the conn model down to two states, the original Phase 3 design fits diff --git a/localpost/http/server.py b/localpost/http/server.py index 8f62813..fa95189 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -223,6 +223,17 @@ def __init__( self.keep_alive_header: tuple[bytes, bytes] | None = ( (b"keep-alive", f"timeout={ka_timeout}".encode("ascii")) if ka_timeout >= 1 else None ) + # Stale-connection cleanup runs lazily — at most once per + # ``_cleanup_interval``. Avoids walking ``selector.get_map()`` on + # every iteration when nothing's actually stale (which is the + # common case under load: keep_alive_timeout=15s, rw_timeout=1s). + # Floor at 100 ms so users with degenerate tiny timeouts don't + # pathologically over-cleanup. + self._cleanup_interval: float = max( + min(config.rw_timeout, config.keep_alive_timeout) / 2, + 0.1, + ) + self._last_cleanup_at: float = 0.0 def _find_stale(self): now = time.monotonic() @@ -233,6 +244,16 @@ def _find_stale(self): def _cleanup_stale(self): # Selector-thread only. Lock-free: ``self.selector`` and # ``conn.tracked`` are owned by the selector thread. + # + # Lazy: skip the O(N) walk over registered conns when not enough + # time has passed since the last sweep. Worst-case extra detection + # latency is ``_cleanup_interval`` (default 0.5 s) on top of the + # configured ``rw_timeout`` / ``keep_alive_timeout`` — both already + # measure non-fatal conditions. + now = time.monotonic() + if now - self._last_cleanup_at < self._cleanup_interval: + return + self._last_cleanup_at = now stale = list(self._find_stale()) for conn in stale: try: From 90036b1224d51fb5b2ded87269ff06980db0df50 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 01:03:43 +0400 Subject: [PATCH 101/286] perf(http): probe selector before modify; drop Keep-Alive timeout header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile-guided micro-opts. yappi profile of the bench load surfaced two avoidable wastes outside h11: 1. socket.__repr__ called once per request (~25 µs/call). Came from _apply_track's ``try selector.modify; except KeyError: register`` pattern: after the worker re-tracks a freshly-unregistered conn, modify always misses, and selectors.modify builds the KeyError message as f"{fileobj!r}" before throwing. Fix: probe ``selector.get_map()`` (O(1) dict-in) to choose modify vs register directly. Same probe applied to HTTPConn.close and _OpClose handling. 2. _maybe_inject_keep_alive constructed a new h11.Response per response to append Keep-Alive: timeout=N. h11.Response.__init__ runs normalize_and_validate over the full headers list — heavy h11 work, every response. Fix: drop the explicit Keep-Alive: timeout=N header. HTTP/1.1 defaults to keep-alive; the timeout header is informational and most clients (httpx, requests, browsers) maintain their own pool timeout. Removes _maybe_inject_keep_alive method, Server.keep_alive_header field, and three tests that asserted on the explicit header. Bench (5s/cell, max_concurrency=32): localpost_native plaintext 8,824 → 9,617 RPS (+9.0%) localpost_native path_param 8,805 → 9,461 RPS (+7.4%) localpost_native json_post 7,676 → 8,589 RPS (+11.9%) localpost_flask plaintext 7,909 → 8,385 RPS (+6.0%) 10/10 stress at 800/800. 126 tests pass (3 keep-alive header tests removed). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 49 +++++++++++++++++++++++ localpost/http/server.py | 67 ++++++++------------------------ tests/http/server.py | 54 +------------------------ 3 files changed, 67 insertions(+), 103 deletions(-) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 10509ac..f2d5f80 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -316,6 +316,55 @@ The selector ceiling is real. Options to revisit: - **Replace pure-Python h11 parsing with a C parser** (e.g. `httptools`). Likely the easiest win at this point. +## Phase 5 (shipped): profile-guided micro-opts + +Profiled the server under bench load with `yappi` (CPU clock, multi-threaded). +The profile confirmed h11 dominates the per-request hot path (`next_event`, +`send`, `normalize_and_validate`, `_extract_next_receive_event` together +account for ~60-70% of selector CPU). But it surfaced two avoidable wastes +*outside* h11: + +### Surprise #1: `socket.__repr__` per request + +`Server._apply_track` was using `try selector.modify; except KeyError: +selector.register`. After a worker re-tracks a freshly-unregistered conn, +`modify` always misses — and `selectors.modify` builds the `KeyError` +message as ``f"{fileobj!r}"`` *before* throwing. `socket.__repr__` is +~25 µs/call. At 9 k req/sec that's ~225 ms/sec of CPU spent on an error +message we immediately discard. + +Fix: probe ``selector.get_map()`` (an O(1) ``fd in dict`` check) and +choose ``modify`` vs ``register`` directly — never enter the exception +path. Same fix applied to ``HTTPConn.close`` and the ``_OpClose`` handler +in ``_drain_ops``. + +### Surprise #2: `_maybe_inject_keep_alive` rebuilds the response + +Every response went through ``_maybe_inject_keep_alive``, which +constructed a *new* ``h11.Response`` to append the ``Keep-Alive: timeout=N`` +header. ``h11.Response.__init__`` runs ``normalize_and_validate`` over the +full headers list — heavy h11 work, every response. + +Fix: drop the explicit ``Keep-Alive: timeout=N`` header. HTTP/1.1 +defaults to keep-alive; the timeout header is informational and most +clients (httpx, requests, browsers) maintain their own pool timeout. +The 3 tests that asserted on the explicit header are removed. + +### Bench + +| Stack | Phase 4 RPS / p50 | Phase 5 RPS / p50 | Δ | +| ----------------- | ----------------: | ----------------: | -----: | +| `localpost_native` plaintext | 8,824 / 7.16 | **9,617 / 6.59** | +9.0% | +| `localpost_native` path_param | 8,805 / 7.20 | **9,461 / 6.71** | +7.4% | +| `localpost_native` json_post | 7,676 / 3.93 | **8,589 / 3.63** | +11.9% | +| `localpost_flask` plaintext | 7,909 / 8.01 | **8,385 / 7.56** | +6.0% | + +10/10 stress at 800/800. All remaining 126 tests pass. + +This is the realistic ceiling for the current architecture without +replacing h11 or going multi-process. The remaining selector-thread +CPU is now overwhelmingly inside h11 — verified by the same profile. + ## Phase 4 (shipped): lazy `_cleanup_stale` Targeted audit of the selector hot path identified one piece of clearly diff --git a/localpost/http/server.py b/localpost/http/server.py index fa95189..4ad805e 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -215,14 +215,6 @@ def __init__( """Cached on the first ``run()`` call. Used to route ``track`` / ``close`` calls inline when invoked from the selector thread itself (e.g. from the accept branch of ``run``).""" - # Pre-baked ``Keep-Alive: timeout=N`` header tuple, or ``None`` if - # keep-alive is disabled (``keep_alive_timeout < 1``). Computed once - # so :meth:`HTTPReqCtx._maybe_inject_keep_alive` doesn't pay the - # f-string + ``.encode`` cost per request. - ka_timeout = int(config.keep_alive_timeout) - self.keep_alive_header: tuple[bytes, bytes] | None = ( - (b"keep-alive", f"timeout={ka_timeout}".encode("ascii")) if ka_timeout >= 1 else None - ) # Stale-connection cleanup runs lazily — at most once per # ``_cleanup_interval``. Avoids walking ``selector.get_map()`` on # every iteration when nothing's actually stale (which is the @@ -364,7 +356,7 @@ def _drain_ops(self) -> None: try: if isinstance(op, _OpTrack): self._apply_track(op.conn) - elif isinstance(op, _OpClose): + elif isinstance(op, _OpClose) and op.fd in self.selector.get_map(): try: self.selector.unregister(op.fd) except (KeyError, ValueError): @@ -375,17 +367,19 @@ def _drain_ops(self) -> None: def _apply_track(self, conn: HTTPConn) -> None: """Selector-thread handler for ``_OpTrack``. - Probes the selector (modify-then-fall-back-to-register) instead of - relying on a captured prev-mode snapshot — between the worker's - ``track()`` and our drain, the conn could have been unregistered - elsewhere (or already registered by a previous op). + Probes ``selector.get_map()`` (an O(1) dict-``in`` check) to choose + ``modify`` vs ``register``, instead of catching ``KeyError`` from + ``modify``. The error path inside ``selectors.modify`` builds the + exception message as ``f"{fileobj!r}"`` — and ``socket.__repr__`` + is surprisingly expensive (~25 µs/call). Per-request overhead. """ sock = conn.sock + sel = self.selector try: - try: - self.selector.modify(sock, selectors.EVENT_READ, data=conn) - except KeyError: - self.selector.register(sock, selectors.EVENT_READ, data=conn) + if conn.fd in sel.get_map(): + sel.modify(sock, selectors.EVENT_READ, data=conn) + else: + sel.register(sock, selectors.EVENT_READ, data=conn) except Exception: # noqa: BLE001 conn.tracked = False try: @@ -537,10 +531,12 @@ def close(self) -> None: return # Selector still has _fd_to_key[fd]. Clean up. if threading.get_ident() == self.server._selector_thread_id: - try: - self.server.selector.unregister(self.fd) - except (KeyError, ValueError): - pass + sel = self.server.selector + if self.fd in sel.get_map(): + try: + sel.unregister(self.fd) + except (KeyError, ValueError): + pass else: self.server._ops.append(_OpClose(self.fd)) self.server._wake() @@ -726,37 +722,8 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: def start_response(self, response: h11.Response | h11.InformationalResponse, /) -> None: if isinstance(response, h11.Response): object.__setattr__(self, "response_status", response.status_code) - response = self._maybe_inject_keep_alive(response) self._conn.send(response) - def _maybe_inject_keep_alive(self, response: h11.Response) -> h11.Response: - """Append ``Keep-Alive: timeout=N`` to the response on persistent HTTP/1.1 - connections so clients can size their keep-alive pool to our deadline. - - Header names are compared directly because h11 normalizes them to - lowercase bytes on both the request side (parser output) and the - response side (``h11.Response`` constructor). The keep-alive tuple - itself is pre-baked on the server. - """ - ka_header = self._server.keep_alive_header - if ka_header is None: - return response - if self.request.http_version != b"1.1": - return response - for name, value in self.request.headers: - if name == b"connection" and b"close" in value.lower(): - return response - for name, value in response.headers: - if name == b"connection" and b"close" in value.lower(): - return response - if name == b"keep-alive": - return response # caller already set it - return h11.Response( - status_code=response.status_code, - headers=[*response.headers, ka_header], - reason=response.reason, - ) - def send(self, chunk: Buffer, /) -> None: # h11 wants bytes; widen the public API to any Buffer (memoryview, # bytearray, …) so callers can avoid an explicit copy. diff --git a/tests/http/server.py b/tests/http/server.py index 2b7de42..6b108c9 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -453,7 +453,7 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"resp-for-/b" in data -# --- Buffer-protocol body / Keep-Alive header -------------------------------- +# --- Buffer-protocol body --------------------------------------------------- class TestSendBuffer: @@ -475,58 +475,6 @@ def handler(ctx: HTTPReqCtx) -> None: assert resp.content == b"hello" -class TestKeepAliveHeader: - def test_keep_alive_header_added_on_http11(self, serve_in_thread): - """HTTP/1.1 responses get a Keep-Alive: timeout=N header by default.""" - - def handler(ctx: HTTPReqCtx) -> None: - ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), - b"ok", - ) - - with serve_in_thread(handler) as port: - resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) - - ka = resp.headers.get("keep-alive") - assert ka is not None and ka.startswith("timeout="), f"unexpected Keep-Alive: {ka!r}" - - def test_keep_alive_header_skipped_when_request_says_close(self, serve_in_thread): - """Don't advertise keep-alive when the client opted out via Connection: close.""" - - def handler(ctx: HTTPReqCtx) -> None: - ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), - b"ok", - ) - - with serve_in_thread(handler) as port: - resp = httpx.get( - f"http://127.0.0.1:{port}/", - headers={"Connection": "close"}, - timeout=5, - ) - - assert "keep-alive" not in resp.headers - - def test_keep_alive_header_skipped_when_response_says_close(self, serve_in_thread): - """Don't advertise keep-alive when the handler set Connection: close itself.""" - - def handler(ctx: HTTPReqCtx) -> None: - ctx.complete( - h11.Response( - status_code=200, - headers=[(b"content-length", b"2"), (b"connection", b"close")], - ), - b"ok", - ) - - with serve_in_thread(handler) as port: - resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) - - assert "keep-alive" not in resp.headers - - # --- Stale-connection cleanup ------------------------------------------------ From e8af3d45c84152e3cd62d72dd05a19f92aae7079 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 27 Apr 2026 21:42:21 +0000 Subject: [PATCH 102/286] chore(perf): new benchmark scenario --- benchmarks/README.md | 19 +- benchmarks/http/apps/_flask_app.py | 13 +- benchmarks/http/apps/_starlette_app.py | 14 +- benchmarks/http/apps/localpost_native.py | 11 +- benchmarks/http/scenarios.py | 46 +- pyproject.toml | 1 + uv.lock | 1234 +++++++++++----------- 7 files changed, 725 insertions(+), 613 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 777e110..8548dce 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -14,7 +14,7 @@ Both are kept out of the default test run (`just tests`). ## Quick start ```bash -# macro (HTTP load) — 8 stacks × 3 scenarios at 20s each = ~8 minutes +# macro (HTTP load) — 8 stacks × 4 scenarios at 20s each = ~11 minutes brew install oha # one-time prereq just bench-http @@ -27,7 +27,7 @@ just bench-micro ## What's compared (macro) -Three "app types", each implementing the same three scenarios: +Three "app types", each implementing the same four scenarios: | App type | Stacks | |------------|-------------------------------------------------------------------------------------------| @@ -37,11 +37,16 @@ Three "app types", each implementing the same three scenarios: Scenarios — defined in [`http/scenarios.py`](http/scenarios.py): -| Scenario | Method | Path | Concurrency | -|--------------|--------|------------------|-------------| -| `plaintext` | GET | `/ping` | 64 | -| `path_param` | GET | `/hello/{name}` | 64 | -| `json_post` | POST | `/echo` | 32 | +| Scenario | Method | Path | Concurrency | +|------------------|--------|-----------------------------|-------------| +| `plaintext` | GET | `/ping` | 64 | +| `path_param` | GET | `/hello/{name}` | 64 | +| `json_post` | POST | `/echo` | 32 | +| `profile_update` | POST | `/users/{user_id}/profile` | 32 | + +`profile_update` is the more application-shaped case: route parameter, JSON +request parsing, deterministic profile normalization, three short simulated I/O +waits, and JSON response serialization. All servers are configured to be roughly comparable — single process, sized worker pool (`max_concurrency=32` for LocalPost, `--threads 32` for diff --git a/benchmarks/http/apps/_flask_app.py b/benchmarks/http/apps/_flask_app.py index 2e1208e..5ff935c 100644 --- a/benchmarks/http/apps/_flask_app.py +++ b/benchmarks/http/apps/_flask_app.py @@ -2,10 +2,12 @@ from __future__ import annotations -from flask import Flask, Response +import time + +from flask import Flask, Response, jsonify from flask import request as flask_request -from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY +from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update def build_app() -> Flask: @@ -23,4 +25,11 @@ def hello(name: str) -> Response: def echo() -> Response: return Response(flask_request.get_data(cache=False), mimetype="application/json") + @app.post("/users//profile") + def profile_update(user_id: str) -> Response: + response = build_profile_update(user_id, flask_request.get_json()) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return jsonify(response) + return app diff --git a/benchmarks/http/apps/_starlette_app.py b/benchmarks/http/apps/_starlette_app.py index a5c55ef..0db95dc 100644 --- a/benchmarks/http/apps/_starlette_app.py +++ b/benchmarks/http/apps/_starlette_app.py @@ -2,12 +2,14 @@ from __future__ import annotations +import asyncio + from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import JSONResponse, Response from starlette.routing import Route -from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY +from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update async def _ping(_: Request) -> Response: @@ -24,11 +26,19 @@ async def _echo(req: Request) -> Response: return Response(body, media_type="application/json") +async def _profile_update(req: Request) -> Response: + response = build_profile_update(req.path_params["user_id"], await req.json()) + for delay_s in PROFILE_WORK_DELAYS_S: + await asyncio.sleep(delay_s) + return JSONResponse(response) + + def build_app() -> Starlette: return Starlette( routes=[ Route("/ping", _ping, methods=["GET"]), Route("/hello/{name}", _hello, methods=["GET"]), Route("/echo", _echo, methods=["POST"]), + Route("/users/{user_id}/profile", _profile_update, methods=["POST"]), ] ) diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index 7c6c764..a5e50d4 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -3,9 +3,10 @@ from __future__ import annotations import sys +import time from benchmarks.http.apps._cli import parse_port -from benchmarks.http.scenarios import PING_BODY, hello_body +from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server @@ -22,12 +23,20 @@ def _echo(ctx: RequestCtx) -> Response: return Response(200, {"content-type": "application/json"}, [ctx.body()]) +def _profile_update(ctx: RequestCtx) -> Response: + body = profile_update_body(ctx.path_args["user_id"], ctx.body()) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response(200, {"content-type": "application/json"}, [body]) + + def main() -> int: port = parse_port() routes = Routes() routes.get("/ping")(_ping) routes.get("/hello/{name}")(_hello) routes.post("/echo")(_echo) + routes.post("/users/{user_id}/profile")(_profile_update) handler = routes.build().as_handler() return run_app(http_server(ServerConfig(host="127.0.0.1", port=port), handler, max_concurrency=32)) diff --git a/benchmarks/http/scenarios.py b/benchmarks/http/scenarios.py index ca410e5..c6b4ab2 100644 --- a/benchmarks/http/scenarios.py +++ b/benchmarks/http/scenarios.py @@ -1,20 +1,23 @@ """Shared scenario definitions: identical contracts every stack must implement. -Three scenarios for v1: +Four scenarios for v1: * ``plaintext`` — ``GET /ping`` → ``b"pong"``, text/plain. * ``path_param`` — ``GET /hello/{name}`` → ``f"hi {name}".encode()``. * ``json_post`` — ``POST /echo`` with JSON body → echo body verbatim, application/json. +* ``profile_update`` — ``POST /users/{user_id}/profile`` with JSON body + → normalized user profile JSON. -Every app module under ``benchmarks/http/apps/`` implements these three. The +Every app module under ``benchmarks/http/apps/`` implements these four. The runner uses ``SCENARIOS`` to know what to fire at each stack and how to verify the response shape. """ from __future__ import annotations +import json from dataclasses import dataclass -from typing import Final +from typing import Any, Final @dataclass(frozen=True, slots=True) @@ -33,6 +36,11 @@ def url(self, base: str) -> str: _JSON_BODY: Final = b'{"name":"world","numbers":[1,2,3,4,5,6,7,8,9,10]}' +_PROFILE_UPDATE_BODY: Final = ( + b'{"display_name":" Alex Example ","email":"ALEX@example.COM","version":7,' + b'"tags":["Python","localpost","Python"," benchmarks "],' + b'"settings":{"theme":"dark","newsletter":true}}' +) SCENARIOS: Final[tuple[Scenario, ...]] = ( Scenario( @@ -62,6 +70,15 @@ def url(self, base: str) -> str: expected_status=200, concurrency=32, ), + Scenario( + name="profile_update", + method="POST", + path="/users/42/profile", + body=_PROFILE_UPDATE_BODY, + content_type="application/json", + expected_status=200, + concurrency=32, + ), ) @@ -69,7 +86,30 @@ def url(self, base: str) -> str: PING_BODY: Final = b"pong" HELLO_PREFIX: Final = "hi " JSON_ECHO_BODY: Final = _JSON_BODY +PROFILE_WORK_DELAYS_S: Final = (0.001, 0.002, 0.001) def hello_body(name: str) -> bytes: return f"{HELLO_PREFIX}{name}".encode() + + +def profile_update_payload(user_id: str, body: bytes) -> dict[str, Any]: + payload = json.loads(body) + return build_profile_update(user_id, payload) + + +def build_profile_update(user_id: str, payload: dict[str, Any]) -> dict[str, Any]: + tags = {tag.strip().lower() for tag in (str(value) for value in payload.get("tags", ())) if tag.strip()} + return { + "user_id": user_id, + "display_name": str(payload.get("display_name", "")).strip(), + "email": str(payload.get("email", "")).strip().lower(), + "version": int(payload.get("version", 0)) + 1, + "tags": sorted(tags), + "settings": payload.get("settings", {}), + } + + +def profile_update_body(user_id: str, body: bytes) -> bytes: + payload = profile_update_payload(user_id, body) + return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() diff --git a/pyproject.toml b/pyproject.toml index 0331321..af4337b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,7 @@ dev-http = [ # "defspec ~=0.5", # Replaced by our own implementation "pydantic ~=2.11", "a2wsgi", + "httpx", ] dev-sentry = [ "sentry-sdk ~=2.51", diff --git a/uv.lock b/uv.lock index 3f3fd6e..b020e58 100644 --- a/uv.lock +++ b/uv.lock @@ -36,15 +36,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -58,54 +58,55 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "authlib" -version = "1.6.9" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, ] [[package]] name = "aws-lambda-powertools" -version = "3.25.0" +version = "3.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/ac/683ec02ceb4cc05bbb4bbef84f719508d02f9648de5d3055a25c133cfd14/aws_lambda_powertools-3.25.0.tar.gz", hash = "sha256:5d9c4bdfad1de7976e4ccf26410725aba17c47f081c84311eb2da16a00f75efb", size = 770414, upload-time = "2026-03-04T11:02:23.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/8f/006b11108d539ebcb0c2caa7cd85db0a83815abee54c22da907e3c14466a/aws_lambda_powertools-3.28.0.tar.gz", hash = "sha256:8849bc4fb01562aa13f1530a456626192256081ce2cfdf0cb0836ad6cba3c6b1", size = 782822, upload-time = "2026-04-14T10:34:17.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/89/e7ef654154454c4ce45a535308895336a5540972e1608808446b0ab0cbb7/aws_lambda_powertools-3.25.0-py3-none-any.whl", hash = "sha256:295467bfbc546b7b6a26d298cedcd06b04eb2cf96eb32e138126a47d761b7de1", size = 917936, upload-time = "2026-03-04T11:02:21.675Z" }, + { url = "https://files.pythonhosted.org/packages/77/ab/531e86d4a4306f9fedc78609259aa0ee7849d592da63168694ac5b96abb7/aws_lambda_powertools-3.28.0-py3-none-any.whl", hash = "sha256:6db5996784913b4b3a7ff04943cefaef4089440569ecbc2180ce07f08dd87659", size = 933148, upload-time = "2026-04-14T10:34:15.234Z" }, ] [[package]] name = "azure-core" -version = "1.38.2" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/fe/5c7710bc611a4070d06ba801de9a935cc87c3d4b689c644958047bdf2cba/azure_core-1.38.2.tar.gz", hash = "sha256:67562857cb979217e48dc60980243b61ea115b77326fa93d83b729e7ff0482e7", size = 363734, upload-time = "2026-02-18T19:33:05.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/23/6371a551800d3812d6019cd813acd985f9fac0fedc1290129211a73da4ae/azure_core-1.38.2-py3-none-any.whl", hash = "sha256:074806c75cf239ea284a33a66827695ef7aeddac0b4e19dda266a93e4665ead9", size = 217957, upload-time = "2026-02-18T19:33:07.696Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, ] [[package]] name = "azure-identity" -version = "1.25.2" +version = "1.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, @@ -114,9 +115,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, ] [[package]] @@ -159,30 +160,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.63" +version = "1.42.97" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/2a/33d5d4b16fd97dfd629421ebed2456392eae1553cc401d9f86010c18065e/boto3-1.42.63.tar.gz", hash = "sha256:cd008cfd0d7ea30f1c5e22daf0998c55b7c6c68cb68eea05110e33fe641173d5", size = 112778, upload-time = "2026-03-06T22:47:55.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/19/f1d8d2b24871d3d0ccb2cbd0b0cb64a3396d439384bd9643d2c25c641b84/boto3-1.42.63-py3-none-any.whl", hash = "sha256:d502a89a0acc701692ae020d15981f2a82e9eb3485acc651cfd0cf1a3afe79ee", size = 140554, upload-time = "2026-03-06T22:47:53.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/43/84c1888139aa1aaf1dc53f8f914e6ec629e5a571fbafdd42fb2d98ac361f/boto3-1.42.97-py3-none-any.whl", hash = "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd", size = 140556, upload-time = "2026-04-27T20:39:15.509Z" }, ] [[package]] name = "botocore" -version = "1.42.63" +version = "1.42.97" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/eb/a1c042f6638ada552399a9977335a6de2668a85bf80bece193c953531236/botocore-1.42.63.tar.gz", hash = "sha256:1fdfc33cff58d21e8622cf620ba2bba3cff324557932aaf935b5374e4610f059", size = 14965362, upload-time = "2026-03-06T22:47:44.158Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/60/17a2d3b94658bb999c6aee7bba6c76b271905debf0c8c8e6ac63ca8491bc/botocore-1.42.63-py3-none-any.whl", hash = "sha256:83f39d04f2b316bdfc59a3cac2d12238bde7126ac99d9a57d910dbd86d58c528", size = 14639889, upload-time = "2026-03-06T22:47:39.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc", size = 14950367, upload-time = "2026-04-27T20:39:01.261Z" }, ] [[package]] @@ -199,20 +200,20 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.3" +version = "7.0.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/5c/3b882b82e9af737906539a2eafb62f96a229f1fa80255bede0c7b554cbc4/cachetools-7.0.3.tar.gz", hash = "sha256:8c246313b95849964e54a909c03b327a87ab0428b068fac10da7b105ca275ef6", size = 37187, upload-time = "2026-03-05T21:00:57.918Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/7b/1755ed2c6bfabd1d98b37ae73152f8dcf94aa40fee119d163c19ed484704/cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24", size = 37526, upload-time = "2026-04-20T19:02:23.289Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/4a/573185481c50a8841331f54ddae44e4a3469c46aa0b397731c53a004369a/cachetools-7.0.3-py3-none-any.whl", hash = "sha256:c128ffca156eef344c25fcd08a96a5952803786fa33097f5f2d49edf76f79d53", size = 13907, upload-time = "2026-03-05T21:00:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/cf76242a5da1410917107ff14551764aa405a5fd10cd10cf9a5ca8fa77f4/cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", size = 13976, upload-time = "2026-04-20T19:02:21.187Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -274,59 +275,75 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, - { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, - { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, - { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, - { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, - { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, - { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, - { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, - { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, - { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, - { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, - { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, - { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, - { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, - { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, - { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, - { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, - { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, - { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, - { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, - { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, - { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, - { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, - { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, - { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, - { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -344,14 +361,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -365,25 +382,25 @@ wheels = [ [[package]] name = "confluent-kafka" -version = "2.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/38/f5855cae6d328fa66e689d068709f91cbbd4d72e7e03959998bd43ac6b26/confluent_kafka-2.13.2.tar.gz", hash = "sha256:619d10d1d77c9821ba913b3e42a33ade7f889f3573c7f3c17b57c3056e3310f5", size = 276068, upload-time = "2026-03-02T12:53:31.457Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/d3/a845c6993a728b8b6bdce9b500d15c3ec3663cd95d2bbf9c1b8cfd519b17/confluent_kafka-2.13.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e259c0d2b9a7e16211b45404f62869502246ac3d03e35a1f80720fd09d262457", size = 3635348, upload-time = "2026-03-02T12:52:50.927Z" }, - { url = "https://files.pythonhosted.org/packages/ab/22/1cb998f7b3ee613d5b29f4b98e4a7539776eb0819b89d7c3cdd19a685692/confluent_kafka-2.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:77ea4ceccdbb67498787b7c02cc329c32417bb730e9383f46c74eb9c5851763c", size = 3194667, upload-time = "2026-03-02T12:52:53.468Z" }, - { url = "https://files.pythonhosted.org/packages/11/38/8a1b12321068e8ae126e62600a55d7a1872f969e1de5ec7f602e0dba8394/confluent_kafka-2.13.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a64a8967734f865f54b766553d63a40f17081cd3d2c6cfe6d3217aa7494d88fb", size = 3724453, upload-time = "2026-03-02T12:52:55.187Z" }, - { url = "https://files.pythonhosted.org/packages/5c/06/3effa66c59a69e17cc48c69ae2533699f4321fac1b46741f2e4b1aefb1e7/confluent_kafka-2.13.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e4cb7d112463ec15a01a3f0e0d20392cda6e46156a6439fcaaad2267696f5cde", size = 3980919, upload-time = "2026-03-02T12:52:56.852Z" }, - { url = "https://files.pythonhosted.org/packages/98/22/f76a8b85fad652b4d5c0a0259c8f7bb66393d2d9f277631c754c9ebe5092/confluent_kafka-2.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:44496777ff0104421b8f4bb269728e8a5e772c09f34ae813bc47110e0172ebe0", size = 4097817, upload-time = "2026-03-02T12:52:58.831Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/ae9a7f21ba49e55b1be18362cefd7648e4aceb588e254f9ee5edb97fcf44/confluent_kafka-2.13.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:02702808dd3cfd91f117fbf17181da2a95392967e9f946b1cbdc5589b36e39d1", size = 3199459, upload-time = "2026-03-02T12:53:00.614Z" }, - { url = "https://files.pythonhosted.org/packages/12/94/ccd92f9a3bb685b265bc83ede699651aa526502e4988e906e710d3f24cd3/confluent_kafka-2.13.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:7dc3a2da92638c077bbabb07058f1938078b42a89f0bbfdcb852d4289c2de27e", size = 3638743, upload-time = "2026-03-02T12:53:01.951Z" }, - { url = "https://files.pythonhosted.org/packages/ba/66/048925a546a0f8e9134a89441aa4ae663892839004668d1039d5f9dd8d45/confluent_kafka-2.13.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f3e6d010ad38447a48e0f9fab81edd4d2fd0b5f5a79ab475c30347689e35c6e6", size = 3724788, upload-time = "2026-03-02T12:53:03.775Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a6/53faa22d52d8fc6f58424d4b6c2c32855198fcb776ea8b4404ee50b58c72/confluent_kafka-2.13.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9161865d8246eb77d1c30233a315bdad96145af783981877664532fa212f56be", size = 3981324, upload-time = "2026-03-02T12:53:05.339Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1578956d3721645b24c22b0e9ceeab794fffc197a32074a7572bfbc07ca7/confluent_kafka-2.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:931233798306b859f4870ec58e3951a2bd32d14ef29f944f56892851b0aafab0", size = 4157492, upload-time = "2026-03-02T12:53:06.977Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4c/46f09fcc1dedebb0a0884b072ddde74be8a8bcfb5e3fbc912bd2c8255e6f/confluent_kafka-2.13.2-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:9cb0d6820107deca1823d68b96831bd982d0a11c4e6bcf0a12e8040192c48a8f", size = 3199305, upload-time = "2026-03-02T12:53:08.351Z" }, - { url = "https://files.pythonhosted.org/packages/37/3c/56d052bdedb7d4bb56bf993dc017df4434e2eb5e73745f22d0beb3c32999/confluent_kafka-2.13.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:b31d94bca493d84927927d1bdd59e1b6d3d921019a657f99f0c8cc5da8c85311", size = 3638586, upload-time = "2026-03-02T12:53:10.01Z" }, - { url = "https://files.pythonhosted.org/packages/33/7a/2bfc9e9341d50813674d3db6425ac4cb963764bffdf589774f94c0cbf852/confluent_kafka-2.13.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f09adb42fb898a0b3a88b02e77bee472e93f758258945386c77864016b4e4efc", size = 3724554, upload-time = "2026-03-02T12:53:11.682Z" }, - { url = "https://files.pythonhosted.org/packages/cf/bb/0d0cdad1763044f3e06bea52c3332256b17f3e64c04a8214ee217fc68ab0/confluent_kafka-2.13.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:fa3be1fe231e06b2c7501fa3641b30ea90ea17be79ca89806eef22ff34ed106c", size = 3981002, upload-time = "2026-03-02T12:53:13.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/65/361ace93de20ab5d83dc0d108389b29f4549f478e0b8aa0f19baf597c0f0/confluent_kafka-2.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:a8d1e0721de378034ecc928b47238272b56bf20af5dd504233bcb93ce07a38a6", size = 4275836, upload-time = "2026-03-02T12:53:14.703Z" }, +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/52/2c71d8e0b2de51076f90cea05342dc9c20fa14ded11992827680db4bbdfa/confluent_kafka-2.14.0.tar.gz", hash = "sha256:34efddfd06766d1153d10a70c23a98f6035e253a906db8ed04cb0249fc3b0fd2", size = 287868, upload-time = "2026-04-02T11:28:57.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/05/f27091396c1e5fb98844e3e8b114ec7b896d1b54209e796e3946649de2cd/confluent_kafka-2.14.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:737b63f2389c9d63f3da0923681aa95abad1cb2f96b10f38192ef19ab727c883", size = 3650743, upload-time = "2026-04-02T11:28:07.697Z" }, + { url = "https://files.pythonhosted.org/packages/9e/49/b9de672412c4290b4719f99ac17b31ff35c64b221e4961a3047f6c1f334f/confluent_kafka-2.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1610aa31880c874bfa3351d898d6e6cdbfab2a0f9443598fd64425bbc815cb06", size = 3207894, upload-time = "2026-04-02T11:28:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b6/d892b50a48bbd95e8937d557baf89ffa07fc48bc27f792141476a004334d/confluent_kafka-2.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9cca8929bbc3d68a3299b21239c48def860f04e4661c7a59efe3104ecaea0e08", size = 3739440, upload-time = "2026-04-02T11:28:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/04d0f106820219e2621cf9e9a3ab49e910b7a19e55a72a21768b82031a85/confluent_kafka-2.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4d2e4718371c06579f649835239d1acf6ab5386a88f70e9cb9b839855c83c4a9", size = 3995763, upload-time = "2026-04-02T11:28:14.46Z" }, + { url = "https://files.pythonhosted.org/packages/64/d9/46258cefee841d65dda31d20ce61d12f7573e07ef8d26f49169edfd0b0fa/confluent_kafka-2.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:c37aff51512e817316edd6eafa8a2e59745052a7d1e61e09931b1caa11803266", size = 4112399, upload-time = "2026-04-02T11:28:16.264Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/13ca4b42c580cb8e8d4bc0711467c7c501573f0133dcaf1ed6d7e34abb42/confluent_kafka-2.14.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:a6dc0e49e8ac99854bd89ec7ac16c54af4488c7617baa633e615320dfbe44b25", size = 3212698, upload-time = "2026-04-02T11:28:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/27/f6/3b4744a8d1b7714500e830a615671d27f76bf64c15966740cc6ee1c960f7/confluent_kafka-2.14.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:308c972b23f44e4d0eb3e76b987872c9a7d04148a5a4f29313bbbec3841d75b4", size = 3654148, upload-time = "2026-04-02T11:28:20.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/9b/928775785983a2840c1944a689308e346badb2475765030f8e2a0db21f7a/confluent_kafka-2.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9b0acf2fffa19a6ffc2d6f0b82f3b7f1771f5d3943312438f3532ae69b6f2e83", size = 3739774, upload-time = "2026-04-02T11:28:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/37/c2d7a24f0c12673c763b25c2b32defe3b47b8458ad54befd842b6a3a0cde/confluent_kafka-2.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0023a941dbd8a2325e9e0d13ed1b2236c7d4ff3279b3d99cf06cf1409ab26d22", size = 3996169, upload-time = "2026-04-02T11:28:24.639Z" }, + { url = "https://files.pythonhosted.org/packages/be/fe/4c2e517a404110adbb5b560dafb5d0b3ba36c2af47d52b5508c90f65d5b0/confluent_kafka-2.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:3da898df3ebb866f61312365e9108cbadcfe74fb73af8d03add856542e715cfe", size = 4172080, upload-time = "2026-04-02T11:28:26.801Z" }, + { url = "https://files.pythonhosted.org/packages/f8/07/e217beea9a543c53484144164db337b33ec7f95912cc76f09f03fbc6ee7f/confluent_kafka-2.14.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:05bbf9745cadb1a6fd3b03508572d2cd5455d8d9960a437537ddac9d3f89ee49", size = 3212541, upload-time = "2026-04-02T11:28:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/cbb44df7afa3ac8746e0ebc37be5f457d0e91e32648c144226da26c5f682/confluent_kafka-2.14.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:32a72ff85d7b4428532aa477b8dfa4223a5c69f4e90fecaa64e1924cc99a06b6", size = 3653993, upload-time = "2026-04-02T11:28:31.042Z" }, + { url = "https://files.pythonhosted.org/packages/ae/49/49d9e62ff70a06e68c96dd65d8e621583e6b51682ccc08051ec585bfdf96/confluent_kafka-2.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:4fd75d53e0e36f7ff9c5454f7a3cf4a54790db3bfda169c3b582ddc97111f6f6", size = 3739535, upload-time = "2026-04-02T11:28:32.844Z" }, + { url = "https://files.pythonhosted.org/packages/33/6a/df467787418c24e063ed0c19e96aedf05c26eabc32d8adc75235d45d830b/confluent_kafka-2.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:eb17528ec7b177ec5e38214852f3dadb5d77172e0fb25c7c992c0cbc3dcfbaa2", size = 3995845, upload-time = "2026-04-02T11:28:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/c5ce2a48ece0ae2dd050ab28d4cd81b9efc610276a4e72f622582f5371d3/confluent_kafka-2.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:578afb532ded604cb98174a14a88847367191bcbe4f52a1661f5238dc5cf75dd", size = 4290326, upload-time = "2026-04-02T11:28:36.679Z" }, ] [package.optional-dependencies] @@ -406,86 +423,86 @@ schemaregistry = [ [[package]] name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] @@ -503,55 +520,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "47.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, ] [[package]] @@ -601,7 +618,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -610,9 +627,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] @@ -646,7 +663,7 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.30.0" +version = "2.30.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -655,9 +672,9 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, ] [package.optional-dependencies] @@ -668,34 +685,33 @@ grpc = [ [[package]] name = "google-auth" -version = "2.49.0" +version = "2.49.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/59/7371175bfd949abfb1170aa076352131d7281bd9449c0f978604fc4431c3/google_auth-2.49.0.tar.gz", hash = "sha256:9cc2d9259d3700d7a257681f81052db6737495a1a46b610597f4b8bafe5286ae", size = 333444, upload-time = "2026-03-06T21:53:06.07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", size = 240676, upload-time = "2026-03-06T21:52:38.304Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, ] [[package]] name = "google-cloud-core" -version = "2.5.0" +version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, + { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, ] [[package]] name = "google-cloud-datastore" -version = "2.23.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -705,41 +721,41 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/4b/0e27a94a9a2be1b2ba53cbe571314c84a2555e5d9295c5d63bf43ede5483/google_cloud_datastore-2.23.0.tar.gz", hash = "sha256:80049883a4ae928fdcc661ba6803ec267665dc0e6f3ce2da91441079a6bb6387", size = 264913, upload-time = "2025-12-16T22:09:33.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/69/1389655f6949d0da0c1c5a359e64aece0a199ba177b33b7a070aa86bf672/google_cloud_datastore-2.24.0.tar.gz", hash = "sha256:f087c02a6aa4ac68bbf17f0c048ae3ee355856bf09c51439bfba193741387792", size = 266157, upload-time = "2026-03-30T22:49:52.688Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/88/348c09570a03886356c02337f06d69532fa17a66ad2a9dff584f7b60eb04/google_cloud_datastore-2.23.0-py3-none-any.whl", hash = "sha256:24a1b1d29b902148fe41b109699f76fd3aa60591e9d547c0f8b87d7bf9ff213f", size = 206815, upload-time = "2025-12-16T22:09:31.608Z" }, + { url = "https://files.pythonhosted.org/packages/5b/88/357efc6b331fd29155dcb92a5dfb0030a8a6feddb7bbf8a6215defbed6c7/google_cloud_datastore-2.24.0-py3-none-any.whl", hash = "sha256:81f1d1c12c2906f59507f72742545ab04c38f62ed70b0542057e3cf04a53aa65", size = 207690, upload-time = "2026-03-30T22:47:49.48Z" }, ] [[package]] name = "google-cloud-pubsub" -version = "2.35.0" +version = "2.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, { name = "google-auth" }, { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, + { name = "grpcio", marker = "python_full_version < '3.14'" }, { name = "grpcio-status" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, { name = "proto-plus" }, { name = "protobuf" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/50/89/558c48382d6875335ea6cd7f6409acfbf256b9f7fbc2ad1c19976aabdb1f/google_cloud_pubsub-2.37.0.tar.gz", hash = "sha256:7c5ba9beb5236e2b83c091dd6171423dc7d6d0e989391bd09f60dbd242b29f10", size = 403391, upload-time = "2026-04-10T00:41:17.799Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/64/f1/bb7162ec50971b1d252e6837d05f64f185d5cfe4e08de8f706e363c305d9/google_cloud_pubsub-2.37.0-py3-none-any.whl", hash = "sha256:dd912422cf66e4ffb423b0d5391ca81bdfa408eb0f21f57adecdb6fb3b1e0bb1", size = 325136, upload-time = "2026-04-10T00:41:01.391Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.73.0" +version = "1.74.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, ] [package.optional-dependencies] @@ -810,114 +826,114 @@ wheels = [ [[package]] name = "grpc-google-iam-v1" -version = "0.14.3" +version = "0.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos", extra = ["grpc"] }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, ] [[package]] name = "grpcio" -version = "1.78.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -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/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" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] name = "grpcio-status" -version = "1.78.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, ] [[package]] name = "grpcio-tools" -version = "1.78.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/d1/cbefe328653f746fd319c4377836a25ba64226e41c6a1d7d5cdbc87a459f/grpcio_tools-1.78.0.tar.gz", hash = "sha256:4b0dd86560274316e155d925158276f8564508193088bc43e20d3f5dff956b2b", size = 5393026, upload-time = "2026-02-06T09:59:59.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ae/5b1fa5dd8d560a6925aa52de0de8731d319f121c276e35b9b2af7cc220a2/grpcio_tools-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:9eb122da57d4cad7d339fc75483116f0113af99e8d2c67f3ef9cae7501d806e4", size = 2546823, upload-time = "2026-02-06T09:58:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ed/d33ccf7fa701512efea7e7e23333b748848a123e9d3bbafde4e126784546/grpcio_tools-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d0c501b8249940b886420e6935045c44cb818fa6f265f4c2b97d5cff9cb5e796", size = 5706776, upload-time = "2026-02-06T09:58:20.944Z" }, - { url = "https://files.pythonhosted.org/packages/c6/69/4285583f40b37af28277fc6b867d636e3b10e1b6a7ebd29391a856e1279b/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:77e5aa2d2a7268d55b1b113f958264681ef1994c970f69d48db7d4683d040f57", size = 2593972, upload-time = "2026-02-06T09:58:23.29Z" }, - { url = "https://files.pythonhosted.org/packages/d7/eb/ecc1885bd6b3147f0a1b7dff5565cab72f01c8f8aa458f682a1c77a9fb08/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e3c0b0e6ba5275322ba29a97bf890565a55f129f99a21b121145e9e93a22525", size = 2905531, upload-time = "2026-02-06T09:58:25.406Z" }, - { url = "https://files.pythonhosted.org/packages/ae/a9/511d0040ced66960ca10ba0f082d6b2d2ee6dd61837b1709636fdd8e23b4/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975d4cb48694e20ebd78e1643e5f1cd94cdb6a3d38e677a8e84ae43665aa4790", size = 2656909, upload-time = "2026-02-06T09:58:28.022Z" }, - { url = "https://files.pythonhosted.org/packages/06/a3/3d2c707e7dee8df842c96fbb24feb2747e506e39f4a81b661def7fed107c/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:553ff18c5d52807dedecf25045ae70bad7a3dbba0b27a9a3cdd9bcf0a1b7baec", size = 3109778, upload-time = "2026-02-06T09:58:30.091Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4b/646811ba241bf05da1f0dc6f25764f1c837f78f75b4485a4210c84b79eae/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8c7f5e4af5a84d2e96c862b1a65e958a538237e268d5f8203a3a784340975b51", size = 3658763, upload-time = "2026-02-06T09:58:32.875Z" }, - { url = "https://files.pythonhosted.org/packages/45/de/0a5ef3b3e79d1011375f5580dfee3a9c1ccb96c5f5d1c74c8cee777a2483/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96183e2b44afc3f9a761e9d0f985c3b44e03e8bb98e626241a6cbfb3b6f7e88f", size = 3325116, upload-time = "2026-02-06T09:58:34.894Z" }, - { url = "https://files.pythonhosted.org/packages/95/d2/6391b241ad571bc3e71d63f957c0b1860f0c47932d03c7f300028880f9b8/grpcio_tools-1.78.0-cp312-cp312-win32.whl", hash = "sha256:2250e8424c565a88573f7dc10659a0b92802e68c2a1d57e41872c9b88ccea7a6", size = 993493, upload-time = "2026-02-06T09:58:37.242Z" }, - { url = "https://files.pythonhosted.org/packages/7c/8f/7d0d3a39ecad76ccc136be28274daa660569b244fa7d7d0bbb24d68e5ece/grpcio_tools-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:217d1fa29de14d9c567d616ead7cb0fef33cde36010edff5a9390b00d52e5094", size = 1158423, upload-time = "2026-02-06T09:58:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/53/ce/17311fb77530420e2f441e916b347515133e83d21cd6cc77be04ce093d5b/grpcio_tools-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2d6de1cc23bdc1baafc23e201b1e48c617b8c1418b4d8e34cebf72141676e5fb", size = 2546284, upload-time = "2026-02-06T09:58:43.073Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/79e101483115f0e78223397daef71751b75eba7e92a32060c10aae11ca64/grpcio_tools-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2afeaad88040894c76656202ff832cb151bceb05c0e6907e539d129188b1e456", size = 5705653, upload-time = "2026-02-06T09:58:45.533Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a7/52fa3ccb39ceeee6adc010056eadfbca8198651c113e418dafebbdf2b306/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33cc593735c93c03d63efe7a8ba25f3c66f16c52f0651910712490244facad72", size = 2592788, upload-time = "2026-02-06T09:58:48.918Z" }, - { url = "https://files.pythonhosted.org/packages/68/08/682ff6bb548225513d73dc9403742d8975439d7469c673bc534b9bbc83a7/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2921d7989c4d83b71f03130ab415fa4d66e6693b8b8a1fcbb7a1c67cff19b812", size = 2905157, upload-time = "2026-02-06T09:58:51.478Z" }, - { url = "https://files.pythonhosted.org/packages/b2/66/264f3836a96423b7018e5ada79d62576a6401f6da4e1f4975b18b2be1265/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6a0df438e82c804c7b95e3f311c97c2f876dcc36376488d5b736b7bcf5a9b45", size = 2656166, upload-time = "2026-02-06T09:58:54.117Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/f108276611522e03e98386b668cc7e575eff6952f2db9caa15b2a3b3e883/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9c6070a9500798225191ef25d0055a15d2c01c9c8f2ee7b681fffa99c98c822", size = 3109110, upload-time = "2026-02-06T09:58:56.891Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c7/cf048dbcd64b3396b3c860a2ffbcc67a8f8c87e736aaa74c2e505a7eee4c/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:394e8b57d85370a62e5b0a4d64c96fcf7568345c345d8590c821814d227ecf1d", size = 3657863, upload-time = "2026-02-06T09:58:59.176Z" }, - { url = "https://files.pythonhosted.org/packages/b6/37/e2736912c8fda57e2e57a66ea5e0bc8eb9a5fb7ded00e866ad22d50afb08/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3ef700293ab375e111a2909d87434ed0a0b086adf0ce67a8d9cf12ea7765e63", size = 3324748, upload-time = "2026-02-06T09:59:01.242Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/726abc75bb5bfc2841e88ea05896e42f51ca7c30cb56da5c5b63058b3867/grpcio_tools-1.78.0-cp313-cp313-win32.whl", hash = "sha256:6993b960fec43a8d840ee5dc20247ef206c1a19587ea49fe5e6cc3d2a09c1585", size = 993074, upload-time = "2026-02-06T09:59:03.085Z" }, - { url = "https://files.pythonhosted.org/packages/c5/68/91b400bb360faf9b177ffb5540ec1c4d06ca923691ddf0f79e2c9683f4da/grpcio_tools-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:275ce3c2978842a8cf9dd88dce954e836e590cf7029649ad5d1145b779039ed5", size = 1158185, upload-time = "2026-02-06T09:59:05.036Z" }, - { url = "https://files.pythonhosted.org/packages/cf/5e/278f3831c8d56bae02e3acc570465648eccf0a6bbedcb1733789ac966803/grpcio_tools-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b080d0d072e6032708a3a91731b808074d7ab02ca8fb9847b6a011fdce64cd9", size = 2546270, upload-time = "2026-02-06T09:59:07.426Z" }, - { url = "https://files.pythonhosted.org/packages/a3/d9/68582f2952b914b60dddc18a2e3f9c6f09af9372b6f6120d6cf3ec7f8b4e/grpcio_tools-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8c0ad8f8f133145cd7008b49cb611a5c6a9d89ab276c28afa17050516e801f79", size = 5705731, upload-time = "2026-02-06T09:59:09.856Z" }, - { url = "https://files.pythonhosted.org/packages/70/68/feb0f9a48818ee1df1e8b644069379a1e6ef5447b9b347c24e96fd258e5d/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f8ea092a7de74c6359335d36f0674d939a3c7e1a550f4c2c9e80e0226de8fe4", size = 2593896, upload-time = "2026-02-06T09:59:12.23Z" }, - { url = "https://files.pythonhosted.org/packages/1f/08/a430d8d06e1b8d33f3e48d3f0cc28236723af2f35e37bd5c8db05df6c3aa/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:da422985e0cac822b41822f43429c19ecb27c81ffe3126d0b74e77edec452608", size = 2905298, upload-time = "2026-02-06T09:59:14.458Z" }, - { url = "https://files.pythonhosted.org/packages/71/0a/348c36a3eae101ca0c090c9c3bc96f2179adf59ee0c9262d11cdc7bfe7db/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fab1faa3fbcb246263e68da7a8177d73772283f9db063fb8008517480888d26", size = 2656186, upload-time = "2026-02-06T09:59:16.949Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3f/18219f331536fad4af6207ade04142292faa77b5cb4f4463787988963df8/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dd9c094f73f734becae3f20f27d4944d3cd8fb68db7338ee6c58e62fc5c3d99f", size = 3109859, upload-time = "2026-02-06T09:59:19.202Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d9/341ea20a44c8e5a3a18acc820b65014c2e3ea5b4f32a53d14864bcd236bc/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2ed51ce6b833068f6c580b73193fc2ec16468e6bc18354bc2f83a58721195a58", size = 3657915, upload-time = "2026-02-06T09:59:21.839Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f4/5978b0f91611a64371424c109dd0027b247e5b39260abad2eaee66b6aa37/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05803a5cdafe77c8bdf36aa660ad7a6a1d9e49bc59ce45c1bade2a4698826599", size = 3324724, upload-time = "2026-02-06T09:59:24.402Z" }, - { url = "https://files.pythonhosted.org/packages/b2/80/96a324dba99cfbd20e291baf0b0ae719dbb62b76178c5ce6c788e7331cb1/grpcio_tools-1.78.0-cp314-cp314-win32.whl", hash = "sha256:f7c722e9ce6f11149ac5bddd5056e70aaccfd8168e74e9d34d8b8b588c3f5c7c", size = 1015505, upload-time = "2026-02-06T09:59:26.3Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d1/909e6a05bfd44d46327dc4b8a78beb2bae4fb245ffab2772e350081aaf7e/grpcio_tools-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d58ade518b546120ec8f0a8e006fc8076ae5df151250ebd7e82e9b5e152c229", size = 1190196, upload-time = "2026-02-06T09:59:28.359Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856, upload-time = "2026-03-30T08:50:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883, upload-time = "2026-03-30T08:51:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004, upload-time = "2026-03-30T08:51:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568, upload-time = "2026-03-30T08:51:04.159Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938, upload-time = "2026-03-30T08:51:06.126Z" }, + { url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814, upload-time = "2026-03-30T08:51:08.834Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793, upload-time = "2026-03-30T08:51:11.094Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149, upload-time = "2026-03-30T08:51:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519, upload-time = "2026-03-30T08:51:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407, upload-time = "2026-03-30T08:51:16.793Z" }, + { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" }, + { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" }, + { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" }, + { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" }, + { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" }, ] [[package]] @@ -1026,19 +1042,19 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.152.2" +version = "6.152.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/7f/67a06c5f19368e0fa04612bbc446c535bf45b0b51bc6aa56055b112f7604/hypothesis-6.152.2.tar.gz", hash = "sha256:11fd5725958fe75597d1b831f703fdf7e636b7cf1f249117f381ad5cee4d888f", size = 466358, upload-time = "2026-04-24T04:26:18.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/c7/3147bd903d6b18324a016d43a259cf5b4bb4545e1ead6773dc8a0374e70a/hypothesis-6.152.4.tar.gz", hash = "sha256:31c8f9ce619716f543e2710b489b1633c833586641d9e6c94cee03f109a5afc4", size = 466444, upload-time = "2026-04-27T20:18:37.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/ed/77eed094bceae845a994f936721293afae40a346c0005d85208407fd40e8/hypothesis-6.152.2-py3-none-any.whl", hash = "sha256:1ad5b87f0e6c0ab7a9a35b1378cc4963d23eaf0cb1e47e94f1d574b41155907a", size = 532034, upload-time = "2026-04-24T04:26:16.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" }, ] [[package]] name = "icecream" -version = "2.1.10" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -1046,18 +1062,18 @@ dependencies = [ { name = "executing" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/e2/3d064dfedbbc16687e0f56cd9b1d55e4e6dfd13d61b9435b61c250aaef3c/icecream-2.1.10.tar.gz", hash = "sha256:15900126ba7dbe1f83819583cbe5ff79a2943224600878d89307e4633b32e528", size = 13924, upload-time = "2026-01-21T07:34:17.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/84/6ebc95844feae8a6a29c7fd57e9e3a7ac4817ffab384dc4f0ed53b8e3c46/icecream-2.2.0.tar.gz", hash = "sha256:9d7f244187f00a13f4ac77d176990e187e9c279d6cac4f7548e338291ad97343", size = 14267, upload-time = "2026-04-03T17:42:51.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/4f/91c4ee1af60bb4b2519540fd46fb56dfc70ede0cf3d97f57aff62d61190b/icecream-2.1.10-py3-none-any.whl", hash = "sha256:6b0ae3e899de12954cd26d8611dcff86518ff19f40deef333427da2ccf4036b2", size = 16373, upload-time = "2026-01-21T07:34:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/7a/82/9707c7b0336bca53b75f52fc350956a93da66eb6be632b370bc933216fb4/icecream-2.2.0-py3-none-any.whl", hash = "sha256:f8df7343b3e787023eec22f42fbe4722df2f93099d394fd820b91e16b2e6cb56", size = 16707, upload-time = "2026-04-03T17:42:50.001Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -1132,6 +1148,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, +] + [[package]] name = "localpost" version = "0.6.0.dev0" @@ -1208,6 +1236,7 @@ dev-http = [ { name = "a2wsgi" }, { name = "cheroot" }, { name = "flask" }, + { name = "httpx" }, { name = "pydantic" }, ] dev-otel = [ @@ -1295,6 +1324,7 @@ dev-http = [ { name = "a2wsgi" }, { name = "cheroot", specifier = "~=11.1" }, { name = "flask", specifier = "~=3.1" }, + { name = "httpx" }, { name = "pydantic", specifier = "~=2.11" }, ] dev-otel = [ @@ -1397,25 +1427,25 @@ wheels = [ [[package]] name = "more-itertools" -version = "10.8.0" +version = "11.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] [[package]] name = "msal" -version = "1.35.1" +version = "1.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/aa/5a646093ac218e4a329391d5a31e5092a89db7d2ef1637a90b82cd0b6f94/msal-1.35.1.tar.gz", hash = "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656", size = 165658, upload-time = "2026-03-04T23:38:51.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/86/16815fddf056ca998853c6dc525397edf0b43559bb4073a80d2bc7fe8009/msal-1.35.1-py3-none-any.whl", hash = "sha256:8f4e82f34b10c19e326ec69f44dc6b30171f2f7098f3720ea8a9f0c11832caa3", size = 119909, upload-time = "2026-03-04T23:38:50.452Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, ] [[package]] @@ -1432,42 +1462,42 @@ wheels = [ [[package]] name = "msgspec" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" }, - { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d1/b902d38b6e5ba3bdddbec469bba388d647f960aeed7b5b3623a8debe8a76/msgspec-0.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c1ff8db03be7598b50dd4b4a478d6fe93faae3bd54f4f17aa004d0e46c14c46", size = 196463, upload-time = "2025-11-24T03:55:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/57/b6/eff0305961a1d9447ec2b02f8c73c8946f22564d302a504185b730c9a761/msgspec-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f6532369ece217fd37c5ebcfd7e981f2615628c21121b7b2df9d3adcf2fd69b8", size = 188650, upload-time = "2025-11-24T03:55:44.761Z" }, - { url = "https://files.pythonhosted.org/packages/99/93/f2ec1ae1de51d3fdee998a1ede6b2c089453a2ee82b5c1b361ed9095064a/msgspec-0.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a1697da2f85a751ac3cc6a97fceb8e937fc670947183fb2268edaf4016d1ee", size = 218834, upload-time = "2025-11-24T03:55:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" }, - { url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" }, - { url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" }, - { url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" }, - { url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" }, - { url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" }, - { url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" }, - { url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" }, - { url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" }, - { url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" }, - { url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" }, - { url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" }, - { url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" }, - { url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, ] [[package]] @@ -1481,45 +1511,45 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/37/b6708e0eff5c5fb9aba2e0ea09f7f3bcbfd12a592d2a780241b5f6014df7/opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a", size = 6152, upload-time = "2026-03-04T14:17:23.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/fc/aea77c28d9f3ffef2fdafdc3f4a235aee4091d262ddabd25882f47ce5c5f/opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d", size = 7023, upload-time = "2026-03-04T14:17:03.612Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1530,14 +1560,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1548,14 +1578,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.61b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1563,38 +1593,39 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, ] [[package]] name = "opentelemetry-instrumentation-botocore" -version = "0.61b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-propagator-aws-xray" }, { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/7f/2acb6d4e8cc70726cfbb24885a653ef5fdc3ac2d0a26ca3e6bff58416c2e/opentelemetry_instrumentation_botocore-0.61b0.tar.gz", hash = "sha256:49dee5f48d133b3bfadaa29bcbff28225899dc495ca14a9c1bb60b74fb4cd84d", size = 121236, upload-time = "2026-03-04T14:20:26.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/b8/47f6b6eb37f790f0de4339d6ca05b5222877c8327c1f0a967e6b443494d9/opentelemetry_instrumentation_botocore-0.62b1.tar.gz", hash = "sha256:2191ebfbb9cd6354ef1de6c0dd127c1cc847fabb7c9c25b8d000e766ce1b5671", size = 125970, upload-time = "2026-04-24T13:22:43.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/43/0ed1ac34b52a62b3694c22aee5c7a9c5b6120938d927d79de53ad9edb0cf/opentelemetry_instrumentation_botocore-0.61b0-py3-none-any.whl", hash = "sha256:b019d2f60562265319e64a75c1c9e7bad31c00b2a568def3cc5c57eaf9a06057", size = 38359, upload-time = "2026-03-04T14:19:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/a2d8655d4f2b2c135943ca99fde9698685ef33f4229daf1263873f34bd0e/opentelemetry_instrumentation_botocore-0.62b1-py3-none-any.whl", hash = "sha256:7768cc2650b979649fe3475238baa7779e4194e25997d164b7eca276db559d43", size = 40517, upload-time = "2026-04-24T13:21:44.429Z" }, ] [[package]] name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.61b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/0e/76db8f0f41678da1c50926367eaf7c3a984cb849235e83b699ad801341b0/opentelemetry_instrumentation_confluent_kafka-0.61b0.tar.gz", hash = "sha256:06c7a13b0ccfa77701d90df19e755e92f3ae3ca6f88c0b1cca64700b4ea1af78", size = 11900, upload-time = "2026-03-04T14:20:29.172Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/ed/4063731c1a44a5cce37e8ba88484857ca2aac59831bb4ba07dc49b23011f/opentelemetry_instrumentation_confluent_kafka-0.62b1.tar.gz", hash = "sha256:057f42de82470e59a1f4b406cb5b38caf9c277d5b9ef8e0fdcee1f255c22831c", size = 12107, upload-time = "2026-04-24T13:22:46.873Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/a2/0a79dcd319a7a8de6aaf91ba1ca1d995a51e63ca0fbf545ae0eeec993a81/opentelemetry_instrumentation_confluent_kafka-0.61b0-py3-none-any.whl", hash = "sha256:2ce36aa3287b870c30b62584090360d591d882f179da07d729f009d442799f16", size = 12810, upload-time = "2026-03-04T14:19:23.468Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a3d1cb30951cf9a0cf0eaf8d3e1f11bcf2f4ced5211d690f4eda016589c8/opentelemetry_instrumentation_confluent_kafka-0.62b1-py3-none-any.whl", hash = "sha256:9f3c518706797161e7ebd0bc036b906c5779d574e01c41ef942a30a4da2df732", size = 12801, upload-time = "2026-04-24T13:21:48.966Z" }, ] [[package]] @@ -1611,41 +1642,41 @@ wheels = [ [[package]] name = "opentelemetry-proto" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.40.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.61b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] [[package]] @@ -1662,11 +1693,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1689,29 +1720,29 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.27.1" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, ] [[package]] name = "protobuf" -version = "6.33.5" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, - { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -1798,11 +1829,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -1828,7 +1859,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1836,98 +1867,102 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -1937,7 +1972,7 @@ crypto = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1946,9 +1981,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1966,16 +2001,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -2072,7 +2107,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2080,46 +2115,34 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] name = "s3transfer" -version = "0.16.0" +version = "0.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" }, ] [[package]] name = "sentry-sdk" -version = "2.54.0" +version = "2.58.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, + { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" }, ] [[package]] @@ -2182,11 +2205,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] @@ -2240,7 +2263,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.14.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, @@ -2249,9 +2272,9 @@ dependencies = [ { name = "urllib3" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, ] [package.optional-dependencies] @@ -2294,15 +2317,15 @@ wheels = [ [[package]] name = "types-boto3-lite" -version = "1.42.63" +version = "1.42.97" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/b0/78bcd5d86a10758d3b33c91b6e29fcdd8a5e2a28145fd330e71d51c142e9/types_boto3_lite-1.42.63.tar.gz", hash = "sha256:ca94617a4a876373ac3c5d8d403dac75ecfcb782f927a94ece57ad2964d04a79", size = 73248, upload-time = "2026-03-06T22:54:49.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/ca/29bbdab1e137bf4576c1b4c9fc08f2b50acc0785c199e550f3952dadbb1f/types_boto3_lite-1.42.97.tar.gz", hash = "sha256:c541b203ff83a54a158d0d12b1d82704ec572daf937ed9566aa0767df5415775", size = 74153, upload-time = "2026-04-27T21:17:53.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/b5/48d12ffb4673ce03724a09f7b9e78c26989fffbdb097c6b6247afdc4238c/types_boto3_lite-1.42.63-py3-none-any.whl", hash = "sha256:bf8910de2037ec93f02d50ccc018ba4c8fc3f28c79ebbe50685bd939e6968138", size = 42751, upload-time = "2026-03-06T22:54:41.578Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/e0886fe2934c7a13b199e70ea034a6f3035180a43dc0d4380d83de3a5352/types_boto3_lite-1.42.97-py3-none-any.whl", hash = "sha256:8be13a98226dd0117a4df1f38116ed1df9666f4920790d22e996306fe69d9642", size = 43010, upload-time = "2026-04-27T21:17:50.888Z" }, ] [package.optional-dependencies] @@ -2321,20 +2344,20 @@ wheels = [ [[package]] name = "types-croniter" -version = "6.0.0.20250809" +version = "6.2.2.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/ac/7b26a9b19cc2b137293b14af71402ba83d13674c208b141474b6887465ae/types_croniter-6.0.0.20250809.tar.gz", hash = "sha256:c829295d4d65eaddcfafec905b0fbab59e72c3c91ee934a4d504dcafad79ff95", size = 11745, upload-time = "2025-08-09T03:14:10.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/e4/89a0101471d6fe4e912dad24c54ae7afd90a9eaa5c74adef2c81f383f8da/types_croniter-6.2.2.20260408.tar.gz", hash = "sha256:a28a18908db371654990d30a3fd99856adc5137e475a23dbda4b10dce85525da", size = 12040, upload-time = "2026-04-08T04:27:20.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/99/e8e40592fbecb6671b32e4dab4cca2a1d4fa7d5b2f54aece134ccb42e839/types_croniter-6.0.0.20250809-py3-none-any.whl", hash = "sha256:d9f53f3e837eb6af509e2090fd2f5bb29b38425dd78f77d7b3bf37ccd2b2bf93", size = 9712, upload-time = "2025-08-09T03:14:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/c3/05/b32e67944ff33e83c181cadf5835858d63f4292a2f2ff5bf6a1edb7f6fed/types_croniter-6.2.2.20260408-py3-none-any.whl", hash = "sha256:242087a5b6e201b7004e55f71ed34f466951b74551c64ef1c6a8a08c47d3cc0d", size = 9732, upload-time = "2026-04-08T04:27:19.229Z" }, ] [[package]] name = "types-grpcio" -version = "1.0.0.20251009" +version = "1.0.0.20260408" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/93/78aa083216853c667c9412df4ef8284b2a68c6bcd2aef833f970b311f3c1/types_grpcio-1.0.0.20251009.tar.gz", hash = "sha256:a8f615ea7a47b31f10da028ab5258d4f1611fbd70719ca450fc0ab3fb9c62b63", size = 14479, upload-time = "2025-10-09T02:54:14.539Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/e8af15d909e1f7bf46bac6f21e3faa0f16a92b4d5bd685d52e2c25ee26ca/types_grpcio-1.0.0.20260408.tar.gz", hash = "sha256:c8ebe07f91492a32e0f3a3810669d236828d5a2a1e540ab16cca8b5a46f8ee5d", size = 14551, upload-time = "2026-04-08T04:27:51.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/93/66d28f41b16bb4e6b611bd608ef28dffc740facec93250b30cf83138da21/types_grpcio-1.0.0.20251009-py3-none-any.whl", hash = "sha256:112ac4312a5b0a273a4c414f7f2c7668f342990d9c6ab0f647391c36331f95ed", size = 15208, upload-time = "2025-10-09T02:54:13.588Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/965d99a23d94e4697a76c88c17afcaf8013332583087551697a391b972bd/types_grpcio-1.0.0.20260408-py3-none-any.whl", hash = "sha256:256f2738a4a3eb2ebabd6d027f4fc7eace024822afb3b629e82811e0b661fc35", size = 15209, upload-time = "2026-04-08T04:27:50.02Z" }, ] [[package]] @@ -2378,11 +2401,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +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/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, + { 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]] @@ -2396,76 +2419,91 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.41.0" +version = "0.46.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] [[package]] name = "werkzeug" -version = "3.1.6" +version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] [[package]] name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +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/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]] @@ -2482,9 +2520,9 @@ wheels = [ [[package]] name = "zipp" -version = "3.23.0" +version = "3.23.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] From c94a202da94eb3cc20175c3010b186cf0e746650 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 13:37:38 +0400 Subject: [PATCH 103/286] refactor(http): split handler / Router / hosting; selector-thread 404/405 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted ``http_server`` used to bundle three concerns: hosting integration, worker-pool dispatch, and the implicit policy of always borrowing the conn before the handler runs. As a result every request — including a Router's 404/405 — round-tripped through a worker thread for no reason, and an all-immediate app paid for a pool it didn't need. Split into three orthogonal pieces: * ``thread_pool_handler(inner, *, max_concurrency)`` — async CM that owns workers, channel, shutdown event, and per-request cancel plumbing. Wraps any RequestHandler. * ``Router.as_handler()`` — runs ``_match`` on the calling thread; 404/405 are sent inline via ``_send_plain``. When passed directly to ``http_server`` the Router's miss-paths cost a regex match plus a static response, no thread hop. * ``http_server(config, handler)`` — hosting only. No ``max_concurrency``, no workers, no cancel-token wiring. Drops from ~150 to ~30 lines. Stale-connection cleanup stays on the selector thread. The lazy ``_cleanup_interval`` gate already keeps the O(N) walk near-free in steady state; a separate thread would lose the lock-free property over ``selector.get_map()`` or duplicate bookkeeping for negligible gain. Documented inline. Bundled cleanups: * ``_MatchResult.kind: str`` → tagged union of ``_MatchOk`` / ``_MatchNotFound`` / ``_MatchMethodNotAllowed`` frozen dataclasses; removes three ``assert ... is not None`` lines in the hot path. * ``router_sentry`` updated for the new match shape. Breaking changes (``localpost.http`` is stable, but this revision is the single deliberate break): * ``http_server`` no longer accepts ``max_concurrency``. * ``flask_server`` and ``wsgi_server`` drop ``max_concurrency`` for the same reason. WSGI / Flask apps are blocking — wrap with ``thread_pool_handler`` to opt back into worker dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 ++ benchmarks/http/apps/localpost_flask.py | 16 ++- benchmarks/http/apps/localpost_native.py | 21 ++- benchmarks/http/apps/localpost_wsgi.py | 14 +- examples/http/flask_app_server.py | 27 ++-- examples/http/multithread_server.py | 23 +++- examples/http/sentry_router_server.py | 25 +++- examples/http/wsgi_app_server.py | 19 ++- localpost/http/README.md | 88 ++++++++++-- localpost/http/__init__.py | 2 + localpost/http/_pool.py | 154 +++++++++++++++++++++ localpost/http/_service.py | 167 ++++------------------- localpost/http/flask.py | 12 +- localpost/http/flask_sentry.py | 4 +- localpost/http/router.py | 73 +++++----- localpost/http/router_sentry.py | 8 +- localpost/http/server.py | 23 +++- tests/http/_integration_app.py | 20 +-- tests/http/service.py | 141 ++++++++++++++----- 19 files changed, 574 insertions(+), 276 deletions(-) create mode 100644 localpost/http/_pool.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b74e95..82e97f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `localpost.http.thread_pool_handler` — async context manager that wraps + any `RequestHandler` so it runs on a worker thread. Compose explicitly + with `http_server` when you need a worker pool; immediate handlers + (including a `Router`'s 404/405 path) stay on the selector thread. + ### Changed +- `localpost.http._service.http_server` no longer accepts `max_concurrency` + and no longer owns a worker pool. Wrap your handler with + `thread_pool_handler` to opt back into worker dispatch. +- `localpost.http.flask.flask_server` and `localpost.http.wsgi_server` + drop their `max_concurrency` kwarg for the same reason — wrap with + `thread_pool_handler` if you need a pool (typical for blocking WSGI + / Flask apps). + ### Removed ## [0.6.0] - 2026-02-22 diff --git a/benchmarks/http/apps/localpost_flask.py b/benchmarks/http/apps/localpost_flask.py index a2c2308..fc79efc 100644 --- a/benchmarks/http/apps/localpost_flask.py +++ b/benchmarks/http/apps/localpost_flask.py @@ -6,14 +6,22 @@ from benchmarks.http.apps._cli import parse_port from benchmarks.http.apps._flask_app import build_app -from localpost.hosting import run_app -from localpost.http import ServerConfig -from localpost.http.flask import flask_server +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler +from localpost.http.flask import flask_handler def main() -> int: port = parse_port() - return run_app(flask_server(ServerConfig(host="127.0.0.1", port=port), build_app(), max_concurrency=32)) + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + async with thread_pool_handler(flask_handler(build_app()), max_concurrency=32) as wrapped: + async with http_server(cfg, wrapped): + yield + + return run_app(app()) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index a5e50d4..2826f24 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -7,8 +7,15 @@ from benchmarks.http.apps._cli import parse_port from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body -from localpost.hosting import run_app -from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server +from localpost.hosting import run_app, service +from localpost.http import ( + RequestCtx, + Response, + Routes, + ServerConfig, + http_server, + thread_pool_handler, +) def _ping(_: RequestCtx) -> Response: @@ -38,7 +45,15 @@ def main() -> int: routes.post("/echo")(_echo) routes.post("/users/{user_id}/profile")(_profile_update) handler = routes.build().as_handler() - return run_app(http_server(ServerConfig(host="127.0.0.1", port=port), handler, max_concurrency=32)) + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + async with thread_pool_handler(handler, max_concurrency=32) as wrapped: + async with http_server(cfg, wrapped): + yield + + return run_app(app()) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_wsgi.py b/benchmarks/http/apps/localpost_wsgi.py index 33c6650..9da25f5 100644 --- a/benchmarks/http/apps/localpost_wsgi.py +++ b/benchmarks/http/apps/localpost_wsgi.py @@ -6,13 +6,21 @@ from benchmarks.http.apps._cli import parse_port from benchmarks.http.apps._flask_app import build_app -from localpost.hosting import run_app -from localpost.http import ServerConfig, wsgi_server +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi def main() -> int: port = parse_port() - return run_app(wsgi_server(ServerConfig(host="127.0.0.1", port=port), build_app(), max_concurrency=32)) + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + async with thread_pool_handler(wrap_wsgi(build_app()), max_concurrency=32) as wrapped: + async with http_server(cfg, wrapped): + yield + + return run_app(app()) if __name__ == "__main__": diff --git a/examples/http/flask_app_server.py b/examples/http/flask_app_server.py index f0422c7..8259859 100644 --- a/examples/http/flask_app_server.py +++ b/examples/http/flask_app_server.py @@ -1,4 +1,4 @@ -"""Flask app served via ``flask_server`` — streaming without ``@stream_with_context``. +"""Flask app served via the native Flask adapter — streaming without ``@stream_with_context``. Run:: @@ -9,9 +9,9 @@ Key difference from the WSGI example: the ``/stream/...`` view returns a generator that reads ``flask.request.headers`` while yielding — and we did -**not** wrap it in ``@stream_with_context``. It works because -:func:`localpost.http.flask.flask_server` keeps Flask's request context -active through response-body iteration. +**not** wrap it in ``@stream_with_context``. It works because the native +Flask handler (:func:`localpost.http.flask.flask_handler`) keeps Flask's +request context active through response-body iteration. """ from __future__ import annotations @@ -22,9 +22,9 @@ from flask import Flask, Response from flask import request as flask_request -from localpost.hosting import run_app -from localpost.http import ServerConfig -from localpost.http.flask import flask_server +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler +from localpost.http.flask import flask_handler def build_app() -> Flask: @@ -46,16 +46,23 @@ def generate(): @app.teardown_request def _log_teardown(exc): - # Under flask_server this runs AFTER the body is fully sent. + # Under the native Flask handler this runs AFTER the body is fully sent. app.logger.info("teardown_request (exc=%r)", exc) return app +@service +async def app_svc(): + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(flask_handler(build_app()), max_concurrency=8) as wrapped: + async with http_server(config, wrapped): + yield + + def main() -> int: logging.basicConfig(level=logging.INFO) - config = ServerConfig(host="127.0.0.1", port=8000) - return run_app(flask_server(config, build_app(), max_concurrency=8)) + return run_app(app_svc()) if __name__ == "__main__": diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index f3cb4cd..4009263 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -8,9 +8,11 @@ curl http://localhost:8000/hello/world curl http://localhost:8000/slow # sleeps; spam a few of these in parallel -Handlers run in AnyIO worker threads (``to_thread.run_sync``), bounded by -``max_concurrency``. Each request becomes its own async task in the service's task -group, so SIGINT / SIGTERM cancels in-flight work cleanly. +The whole router is wrapped with :func:`localpost.http.thread_pool_handler` so +every matched route runs on a worker thread (bounded by ``max_concurrency``). +SIGINT / SIGTERM signals shutdown; in-flight handlers see ``RequestCancelled`` +on the next ``check_cancelled`` call and the pool drains before the process +exits. """ from __future__ import annotations @@ -20,7 +22,7 @@ import threading import time -from localpost.hosting import run_app +from localpost.hosting import run_app, service from localpost.http import ( RequestCtx, Response, @@ -28,6 +30,7 @@ Routes, ServerConfig, http_server, + thread_pool_handler, ) @@ -55,11 +58,17 @@ def build_router() -> Router: return routes.build() +@service +async def app(): + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(build_router().as_handler(), max_concurrency=16) as wrapped: + async with http_server(config, wrapped): + yield + + def main() -> int: logging.basicConfig(level=logging.INFO) - router = build_router() - config = ServerConfig(host="127.0.0.1", port=8000) - return run_app(http_server(config, router.as_handler(), max_concurrency=16)) + return run_app(app()) if __name__ == "__main__": diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index a5044db..8e1e072 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -21,8 +21,15 @@ import sentry_sdk -from localpost.hosting import run_app -from localpost.http import RequestCtx, Response, Routes, ServerConfig, http_server +from localpost.hosting import run_app, service +from localpost.http import ( + RequestCtx, + Response, + Routes, + ServerConfig, + http_server, + thread_pool_handler, +) from localpost.http.router_sentry import sentry_router_handler @@ -45,16 +52,22 @@ def build_router(): return routes.build() +@service +async def app(): + handler = sentry_router_handler(build_router()) + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(handler, max_concurrency=8) as wrapped: + async with http_server(config, wrapped): + yield + + def main() -> int: logging.basicConfig(level=logging.INFO) sentry_sdk.init( dsn=os.environ.get("SENTRY_DSN"), # None → Sentry runs in disabled mode traces_sample_rate=1.0, ) - - handler = sentry_router_handler(build_router()) - config = ServerConfig(host="127.0.0.1", port=8000) - return run_app(http_server(config, handler, max_concurrency=8)) + return run_app(app()) if __name__ == "__main__": diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py index 629dfe6..fdaa44f 100644 --- a/examples/http/wsgi_app_server.py +++ b/examples/http/wsgi_app_server.py @@ -1,4 +1,4 @@ -"""Serve a Flask (WSGI) app via ``localpost.http.wsgi_server``. +"""Serve a Flask (WSGI) app under ``localpost.http`` via the WSGI bridge. Run:: @@ -17,8 +17,8 @@ from flask import request as flask_request from flask.helpers import stream_with_context # type: ignore[import-untyped] -from localpost.hosting import run_app -from localpost.http import ServerConfig, wsgi_server +from localpost.hosting import run_app, service +from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi def build_app() -> Flask: @@ -42,10 +42,19 @@ def generate(): return app +@service +async def wsgi_app_service(): + config = ServerConfig(host="127.0.0.1", port=8000) + # WSGI views block on response-body iteration, so wrap with a thread pool + # to serve more than one request at a time. + async with thread_pool_handler(wrap_wsgi(build_app()), max_concurrency=8) as wrapped: + async with http_server(config, wrapped): + yield + + def main() -> int: logging.basicConfig(level=logging.INFO) - config = ServerConfig(host="127.0.0.1", port=8000) - return run_app(wsgi_server(config, build_app(), max_concurrency=8)) + return run_app(wsgi_app_service()) if __name__ == "__main__": diff --git a/localpost/http/README.md b/localpost/http/README.md index efca43f..5091889 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -117,7 +117,7 @@ straight to h11. See [`flask.py`](flask.py). | Symbol | Notes | | ----------------------------------------- | ------------------------------------------------------------------- | | `flask_handler(app)` | Flask → `RequestHandler` | -| `flask_server(config, app, *, max_concurrency=1)` | Hosted service serving a Flask app | +| `flask_server(config, app)` | Hosted service serving a Flask app (selector-thread, no pool) | ### `localpost.http.router_sentry` @@ -152,7 +152,7 @@ keeps everything on the same transaction. Transaction is named after Flask's `url_rule.rule` (e.g. `"GET /hello/"`) once routing has matched. -**Behavior differences from `wsgi_server`** (with a Flask app): +**Behavior differences from `wsgi_server`** (Flask served via `wrap_wsgi`): - Flask's **request context is active during response-body iteration**. A generator returned from a view can use `flask.request`, `session`, `g` @@ -177,16 +177,58 @@ on the documented public Flask API. ### Hosting integration -`@hosting.service` wrappers for running the server inside a hosted app: - -| Service | Module | Notes | -| -------------------------------------------- | ---------------------------- | ------------------------------------------- | -| `http_server(config, handler)` | `localpost.http._service` | Runs the server loop with your handler | -| `wsgi_server(config, app)` | `localpost.http._service` | Same, for a generic WSGI app | -| `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask` | +| Symbol | Module | Notes | +| ------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | +| `http_server(config, handler)` | `localpost.http._service` | `@hosting.service` — runs the server loop with `handler`. No thread pool. | +| `wsgi_server(config, app)` | `localpost.http._service` | Same, for a generic WSGI app. | +| `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask`. | +| `thread_pool_handler(inner, *, max_concurrency)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a worker thread. | The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown -is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. +is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. The +server hosts a single handler; whether that handler runs synchronously on the +selector thread or hops to a worker is the handler's choice. + +#### Composition pattern + +`http_server`, the `Router`, and `thread_pool_handler` are three orthogonal +concepts that you compose explicitly: + +```python +from localpost.hosting import run_app, service +from localpost.http import ( + Routes, ServerConfig, http_server, thread_pool_handler, +) + + +@service +async def app(): + routes = Routes() + + @routes.get("/hello/{name}") + def hello(ctx): ... # plain RequestCtx → Response handler + + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(routes.build().as_handler(), max_concurrency=8) as h: + async with http_server(config, h): + yield + + +run_app(app()) +``` + +What this gives you: + +- **404 / 405 stay on the selector thread.** When the `Router` is the handler + (wrapped or not), unmatched paths and method mismatches go through + `_send_plain` inline — no worker hop. +- **Matched routes run wherever you want.** Wrap the entire router with + `thread_pool_handler` (above) to run all matched handlers on workers; pass + the router directly to `http_server` to keep them all on the selector; + more granular per-route control is the user's composition problem (today + there is no per-route pool API). +- **No max\_concurrency on `http_server`.** The pool is the wrapper's + concern; `http_server` has one job and one job only. ## Design @@ -195,15 +237,35 @@ is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. `RequestHandler` is `Callable[[HTTPReqCtx], None]`, sync-only. **This is intentional and not a planned extension.** -The whole package is built around blocking sockets and a thread pool: the -selector accepts connections, parses HTTP/1.1 with h11, and dispatches each -request to a worker thread that does its I/O synchronously. +The whole package is built around blocking sockets: the selector accepts +connections, parses HTTP/1.1 with h11, and either dispatches the request +synchronously on the selector thread (e.g. for a 404 from `Router`) or +hands it off to a worker thread (when the user composes +`thread_pool_handler` into the handler chain). If you need an async server, use one of the ASGI servers that already exist (uvicorn, hypercorn, granian, …) — the `localpost.hosting` adapters in `localpost.hosting.services/` plug them in cleanly. There is no need for this package to grow a second, parallel async path. +### Three orthogonal concerns + +- **Handler** — `Callable[[HTTPReqCtx], None]`. Immediate by default + (runs on whichever thread invokes it). The thread-pool variant is just + a wrapper that borrows the connection, queues it, and runs `inner` on + a worker. +- **Router** — itself an immediate handler. Runs `_match()` inline on the + calling thread; 404/405 are sent via `_send_plain` and the conn re-tracks + via the existing connection loop. Matched routes call the registered + per-route handler — which the user can choose to pool or not. +- **`http_server`** — hosting integration only. Owns the AnyIO bridge, + drives `server.run()` in a thread, watches `lt.shutting_down`. Doesn't + know about pools, doesn't know about routing. + +This split means the simple case (all-immediate handlers) doesn't pay for a +worker pool, and the Router's 404/405 path doesn't either even when the +matched routes run on workers. + ### Two-state connection model A connection is either **tracked** (registered in the selector for normal diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 7e5bd92..b85fd0a 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,4 +1,5 @@ from localpost.http._cancel import RequestCancelled, check_cancelled +from localpost.http._pool import thread_pool_handler from localpost.http._service import http_server, wsgi_server from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( @@ -36,6 +37,7 @@ # hosting "http_server", "wsgi_server", + "thread_pool_handler", # cancellation "check_cancelled", "RequestCancelled", diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py new file mode 100644 index 0000000..acc038e --- /dev/null +++ b/localpost/http/_pool.py @@ -0,0 +1,154 @@ +"""Thread-pool wrapper for HTTP request handlers. + +Turns any ``RequestHandler`` into one that borrows the connection on the +selector thread, queues it, and runs the original handler on a worker. The +selector stays free to keep accepting / parsing / dispatching the next +request. + +Compose explicitly with :func:`localpost.http.http_server` — handlers that +can answer synchronously (e.g. a :class:`Router`'s 404/405 path) stay on +the selector thread; only the handlers you wrap pay the worker hop. +""" + +from __future__ import annotations + +import logging +import threading +from collections.abc import AsyncGenerator +from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress + +from anyio import ( + BrokenResourceError, + CapacityLimiter, + ClosedResourceError, + EndOfStream, + create_task_group, + to_thread, +) + +from localpost import threadtools +from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request +from localpost.http.config import LOGGER_NAME +from localpost.http.server import HTTPReqCtx, RequestHandler, emit_handler_error + +__all__ = ["thread_pool_handler"] + + +def thread_pool_handler( + inner: RequestHandler, + /, + *, + max_concurrency: int, +) -> AbstractAsyncContextManager[RequestHandler]: + """Async context manager: yields a ``RequestHandler`` that runs ``inner`` on a worker thread. + + On call, the wrapped handler borrows the connection from the selector + (``stop_tracking``) and queues ``(ctx, cancel)`` on a bounded channel + sized to ``max_concurrency``. ``max_concurrency`` worker threads pull + from the channel, run ``inner(ctx)`` under a per-request cancel scope, + and release the connection back to the selector via ``finish_response`` + (or close it on error / cancellation). + + Per-request cancellation surfaces through + :func:`localpost.http.check_cancelled`. Two triggers feed it: client + disconnect (via non-blocking ``MSG_PEEK`` on the socket) and pool + shutdown (a single :class:`threading.Event` ORed into every in-flight + token). + + Lifecycle: + * Enter — spawn ``max_concurrency`` workers in a private task group. + * Exit — set the shutdown event so in-flight handlers see cancellation + on their next ``check_cancelled`` call, close the channel, and wait + for workers to drain. + + Example:: + + async with thread_pool_handler(router.as_handler(), max_concurrency=8) as h: + async with http_server(config, h): + await current_service().shutting_down.wait() + """ + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + + return _thread_pool_handler(inner, max_concurrency) + + +@asynccontextmanager +async def _thread_pool_handler( + inner: RequestHandler, + max_concurrency: int, +) -> AsyncGenerator[RequestHandler]: + logger = logging.getLogger(LOGGER_NAME) + + tx, rx = threadtools.Channel[tuple[HTTPReqCtx, RequestCancel]].create(capacity=max_concurrency) + shutdown_event = threading.Event() + # Dedicated limiter so workers don't consume slots from AnyIO's global + # default thread pool. Each worker holds a slot for its full lifetime, + # so we need exactly ``max_concurrency`` slots. + workers_limiter = CapacityLimiter(max_concurrency) + + def submit(ctx: HTTPReqCtx) -> None: + """Selector-thread callback: borrow the conn and queue it for a worker.""" + ctx._server.stop_tracking(ctx._conn) + cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) + try: + tx.put((ctx, cancel)) + except (ClosedResourceError, BrokenResourceError): + with suppress(Exception): + ctx._conn.close() + + def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: + with my_rx: + while True: + try: + ctx, cancel = my_rx.get() + except (EndOfStream, ClosedResourceError): + return + + try: + with _enter_request(cancel): + try: + inner(ctx) + except RequestCancelled: + # Handler bailed cleanly. Connection state is uncertain — close. + with suppress(Exception): + ctx._conn.close() + except Exception: + logger.exception( + "Handler raised for %s %r", ctx.request.method, ctx.request.target + ) + emit_handler_error(ctx) + finally: + # Conn-release policy: + # + # On success ``finish_response`` already re-tracked the conn via + # ``_maybe_give_back`` — we MUST NOT touch it here. We can't + # read ``ctx._conn.tracked`` either: that field is shared with + # the next request's dispatcher, which clears it via + # ``stop_tracking`` before this finally runs. + # + # The case left to handle is per-request cancellation: the + # handler caught the signal and returned, but the conn is in + # an uncertain state. ``cancel.fired`` is the cheap (no-syscall) + # check — ``is_cancelled`` would actively re-probe the socket + # which is wasteful here. ``emit_handler_error`` already + # closes on the exception path. + if cancel.fired: + with suppress(Exception): + ctx._conn.close() + + async def run_worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: + await to_thread.run_sync(worker, my_rx, limiter=workers_limiter) + + async with create_task_group() as tg: + for _ in range(max_concurrency): + tg.start_soon(run_worker, rx.clone()) + rx.close() + try: + yield submit + finally: + # Signal in-flight handlers (next ``check_cancelled`` raises) and + # let workers drain via ``EndOfStream`` from the closed channel. + # The task group's exit waits for all workers to return. + shutdown_event.set() + tx.close() diff --git a/localpost/http/_service.py b/localpost/http/_service.py index edd9ebe..b080c16 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,167 +1,58 @@ from __future__ import annotations -import logging -import threading from collections.abc import Awaitable -from contextlib import suppress from wsgiref.types import WSGIApplication -from anyio import ( - BrokenResourceError, - CapacityLimiter, - ClosedResourceError, - EndOfStream, - from_thread, - to_thread, -) +from anyio import from_thread, to_thread -from localpost import hosting, threadtools +from localpost import hosting from localpost.hosting import ServiceLifetime -from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request -from localpost.http.config import LOGGER_NAME, ServerConfig -from localpost.http.server import HTTPReqCtx, RequestHandler, emit_handler_error, start_http_server +from localpost.http.config import ServerConfig +from localpost.http.server import RequestHandler, start_http_server from localpost.http.wsgi import wrap_wsgi __all__ = ["http_server", "wsgi_server"] @hosting.service -def http_server( - config: ServerConfig, - handler: RequestHandler, - /, - *, - max_concurrency: int = 1, -): +def http_server(config: ServerConfig, handler: RequestHandler, /): """Run an HTTP server inside a hosted service. - The selector thread accepts and parses requests, then hands each one to a - worker thread via a bounded :class:`localpost.threadtools.Channel`. Workers - run sync handlers directly — no event-loop hop, no per-request portal call. - Once the channel buffer is full, the selector blocks on ``put`` and applies - back-pressure on accept. - - Per-request cancellation is HTTP-native and lives behind - :func:`localpost.http.check_cancelled`. Triggers: - * Client disconnected mid-request (pull-based ``MSG_PEEK`` on the - request socket from inside ``check_cancelled``). - * Service is shutting down (a single shared ``threading.Event`` is OR-ed - into every in-flight token's ``is_cancelled`` check). - - Worker / service cancellation (a separate concern) flows through the - AnyIO machinery as elsewhere in :mod:`localpost`. + ``handler`` is invoked on the **selector thread** for every accepted + request — synchronous handlers (e.g. a :class:`Router` answering 404 / + 405 inline) complete without leaving that thread. Handlers that need + a worker pool should be wrapped with + :func:`localpost.http.thread_pool_handler` *before* being passed in. + + The service has no request-cancellation machinery of its own. + Per-request cancellation lives in :func:`localpost.http.check_cancelled` + and is wired up by :func:`thread_pool_handler` (the only place that + needs it — selector-thread handlers run to completion synchronously + and have no thread to signal). """ - if max_concurrency < 1: - raise ValueError("max_concurrency must be >= 1") - - logger = logging.getLogger(LOGGER_NAME) def run(lt: ServiceLifetime) -> Awaitable[None]: - tx, rx = threadtools.Channel[tuple[HTTPReqCtx, RequestCancel]].create(capacity=max_concurrency) - - # Single shutdown signal shared by every in-flight cancel token. No - # per-request registry needed — workers see shutdown via their token's - # ``is_cancelled`` check. - shutdown_event = threading.Event() - # Dedicated thread limiter so workers + selector don't consume slots from - # AnyIO's default global limiter. - threads_limiter = CapacityLimiter(max_concurrency + 1) - - def dispatch(ctx: HTTPReqCtx) -> None: - """Selector-thread callback: parse done, hand off to a worker. - - Always uses ``stop_tracking`` (the original borrow flow): the conn - leaves the selector entirely while the worker owns it. Client - disconnect detection while the worker is running happens inside - :func:`localpost.http.check_cancelled` via a non-blocking - ``MSG_PEEK``. - """ - ctx._server.stop_tracking(ctx._conn) - cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) - try: - tx.put((ctx, cancel)) - except (ClosedResourceError, BrokenResourceError): - with suppress(Exception): - ctx._conn.close() - - def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: - """Worker-thread loop. Pulls from ``my_rx`` until the channel ends.""" - with my_rx: - while True: - try: - ctx, cancel = my_rx.get() - except (EndOfStream, ClosedResourceError): - return - - try: - with _enter_request(cancel): - try: - handler(ctx) - except RequestCancelled: - # Handler bailed cleanly. Connection state is uncertain — close. - with suppress(Exception): - ctx._conn.close() - except Exception: - logger.exception( - "Handler raised for %s %r", ctx.request.method, ctx.request.target - ) - emit_handler_error(ctx) - finally: - # Conn-release policy: - # - # On the success path, ``finish_response`` already re-tracked the conn - # via ``_maybe_give_back`` — we MUST NOT touch it here. Specifically, - # we cannot read ``ctx._conn.tracked``: that field is shared with the - # next request's dispatcher, which clears it via ``stop_tracking`` - # before this finally runs. - # - # The only case left to handle is per-request cancellation: the - # handler caught the signal and returned, but the conn is in an - # uncertain state. ``cancel.fired`` is the cheap (no-syscall) - # check — ``is_cancelled`` would actively re-probe the socket - # which is wasteful here. - # ``emit_handler_error`` already closes on the exception path. - if cancel.fired: - with suppress(Exception): - ctx._conn.close() - - async def shutdown_watcher() -> None: - await lt.shutting_down.wait() - shutdown_event.set() - tx.close() - def run_server() -> None: - with start_http_server(config, dispatch) as server: + with start_http_server(config, handler) as server: lt.set_started() while not lt.shutting_down.is_set(): from_thread.check_cancelled() server.run() - async def run_worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: - await to_thread.run_sync(worker, my_rx, limiter=threads_limiter) + return to_thread.run_sync(run_server) - async def main() -> None: - try: - for _ in range(max_concurrency): - lt.tg.start_soon(run_worker, rx.clone()) - rx.close() - lt.tg.start_soon(shutdown_watcher) - await to_thread.run_sync(run_server, limiter=threads_limiter) - finally: - # Selector exited (clean or otherwise) — drain workers. - tx.close() + return run - return main() - return run +def wsgi_server(config: ServerConfig, app: WSGIApplication, /): + """Same as :func:`http_server`, but for a WSGI application. + A WSGI app blocks during request handling (response body iteration is + synchronous). Wrap with :func:`thread_pool_handler` if you want to + serve more than one request at a time:: -def wsgi_server( - config: ServerConfig, - app: WSGIApplication, - /, - *, - max_concurrency: int = 1, -): - """Same as :func:`http_server`, but serves a WSGI application.""" - return http_server(config, wrap_wsgi(app), max_concurrency=max_concurrency) + async with thread_pool_handler(wrap_wsgi(my_app), max_concurrency=8) as h: + async with http_server(config, h): + ... + """ + return http_server(config, wrap_wsgi(app)) diff --git a/localpost/http/flask.py b/localpost/http/flask.py index a3bab42..ae287c2 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -71,9 +71,15 @@ def _write_response(http_ctx: HTTPReqCtx, response) -> None: http_ctx.finish_response() -def flask_server(config: ServerConfig, app: Flask, /, *, max_concurrency: int = 1): +def flask_server(config: ServerConfig, app: Flask, /): """Hosted service serving a Flask app via :func:`flask_handler`. - See :func:`localpost.http.http_server` for the concurrency model. + Flask views block during request handling, so to serve more than one + request at a time wrap the handler with + :func:`localpost.http.thread_pool_handler`:: + + async with thread_pool_handler(flask_handler(app), max_concurrency=8) as h: + async with http_server(config, h): + ... """ - return http_server(config, flask_handler(app), max_concurrency=max_concurrency) + return http_server(config, flask_handler(app)) diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index c93b600..de64f04 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -18,7 +18,9 @@ sentry_sdk.init(dsn=..., traces_sample_rate=1.0) handler = sentry_flask_handler(my_flask_app) - run_app(http_server(config, handler, max_concurrency=8)) + async with thread_pool_handler(handler, max_concurrency=8) as wrapped: + async with http_server(config, wrapped): + ... """ from __future__ import annotations diff --git a/localpost/http/router.py b/localpost/http/router.py index 1f0907d..4745307 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -231,7 +231,7 @@ def from_routes(cls, routes: Routes) -> Self: def _match(self, path: str, method_str: str) -> _MatchResult: m = self._regex.match(path) if m is None: - return _MatchResult(kind="not_found") + return _MATCH_NOT_FOUND for route in self.routes: if m.group(route._group_prefix) is None: @@ -243,28 +243,17 @@ def _match(self, path: str, method_str: str) -> _MatchResult: try: method = HTTPMethod(method_str) except ValueError: - return _MatchResult( - kind="method_not_allowed", - allowed=tuple(method_map), - allow_header=route.allow_header, - ) + return _MatchMethodNotAllowed(allow_header=route.allow_header) handler = method_map.get(method) if handler is None: - return _MatchResult( - kind="method_not_allowed", - allowed=tuple(method_map), - allow_header=route.allow_header, - ) + return _MatchMethodNotAllowed(allow_header=route.allow_header) - return _MatchResult( - kind="ok", + return _MatchOk( handler=handler, method=method, matched_template=tmpl, path_args=path_args, - allowed=tuple(method_map), - allow_header=route.allow_header, ) raise AssertionError("unreachable: regex matched but no outer group set") @@ -275,20 +264,16 @@ def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: request_method_str = environ.get("REQUEST_METHOD", "GET").upper() match = self._match(request_path, request_method_str) - if match.kind == "not_found": + if isinstance(match, _MatchNotFound): start_response("404 Not Found", [("Content-Type", "text/plain")]) return [b"Not Found"] - if match.kind == "method_not_allowed": + if isinstance(match, _MatchMethodNotAllowed): start_response( "405 Method Not Allowed", [("Content-Type", "text/plain"), ("Allow", match.allow_header)], ) return [b"Method Not Allowed"] - assert match.handler is not None - assert match.matched_template is not None - assert match.method is not None - query_string = environ.get("QUERY_STRING", "") headers = _headers_from_environ(environ) wsgi_input = environ.get("wsgi.input") @@ -317,7 +302,14 @@ def receive(size: int) -> bytes: return response.body def as_handler(self) -> NativeRequestHandler: - """Return a :class:`localpost.http.RequestHandler` that dispatches via this router.""" + """Return a :class:`localpost.http.RequestHandler` that dispatches via this router. + + 404 (no template match) and 405 (template match, method mismatch) are + answered inline on the calling thread — no thread hop, no allocation + beyond the static response payload. A matched route delegates to its + registered handler, which may itself be a synchronous handler or a + :func:`thread_pool_handler`-wrapped one. + """ def handle(http_ctx: HTTPReqCtx) -> None: req = http_ctx.request @@ -330,10 +322,10 @@ def handle(http_ctx: HTTPReqCtx) -> None: match = self._match(path, method_str) - if match.kind == "not_found": + if isinstance(match, _MatchNotFound): _send_plain(http_ctx, 404, b"Not Found") return - if match.kind == "method_not_allowed": + if isinstance(match, _MatchMethodNotAllowed): _send_plain( http_ctx, 405, @@ -342,10 +334,6 @@ def handle(http_ctx: HTTPReqCtx) -> None: ) return - assert match.handler is not None - assert match.matched_template is not None - assert match.method is not None - headers = {name.decode("iso-8859-1").lower(): value.decode("iso-8859-1") for name, value in req.headers} with ExitStack() as stack: @@ -380,14 +368,27 @@ def handle(http_ctx: HTTPReqCtx) -> None: @final @dataclass(frozen=True, slots=True) -class _MatchResult: - kind: str # "ok" | "not_found" | "method_not_allowed" - handler: RequestHandler | None = None - method: HTTPMethod | None = None - matched_template: URITemplate | None = None - path_args: Mapping[str, str] = field(default_factory=dict) - allowed: tuple[HTTPMethod, ...] = () - allow_header: str = "" +class _MatchOk: + handler: RequestHandler + method: HTTPMethod + matched_template: URITemplate + path_args: Mapping[str, str] + + +@final +@dataclass(frozen=True, slots=True) +class _MatchNotFound: + pass + + +@final +@dataclass(frozen=True, slots=True) +class _MatchMethodNotAllowed: + allow_header: str + + +_MatchResult = _MatchOk | _MatchNotFound | _MatchMethodNotAllowed +_MATCH_NOT_FOUND = _MatchNotFound() def _literal_prefix_len(t: URITemplate) -> int: diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 879deea..9eefc30 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -19,7 +19,9 @@ def get_book(ctx): ... sentry_sdk.init(dsn=..., traces_sample_rate=1.0) handler = sentry_router_handler(router) - run_app(http_server(config, handler, max_concurrency=16)) + async with thread_pool_handler(handler, max_concurrency=16) as wrapped: + async with http_server(config, wrapped): + ... Pure Router instrumentation — no Flask import. Use :mod:`localpost.http.flask_sentry` for Flask apps. @@ -29,7 +31,7 @@ def get_book(ctx): ... import sentry_sdk -from localpost.http.router import Router +from localpost.http.router import Router, _MatchOk from localpost.http.server import HTTPReqCtx, RequestHandler __all__ = ["sentry_router_handler"] @@ -49,7 +51,7 @@ def handle(ctx: HTTPReqCtx) -> None: # Router's dispatch will match again, but template matching is a single # combined regex run — cheap. match = router._match(path, method) - if match.kind == "ok" and match.matched_template is not None: + if isinstance(match, _MatchOk): tx_name = f"{method} {match.matched_template.template}" source = "route" else: diff --git a/localpost/http/server.py b/localpost/http/server.py index 4ad805e..e01a16c 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -237,6 +237,15 @@ def _cleanup_stale(self): # Selector-thread only. Lock-free: ``self.selector`` and # ``conn.tracked`` are owned by the selector thread. # + # We deliberately keep this on the selector thread rather than a + # dedicated cleanup thread. With the lazy gate below the common + # case is an O(1) timestamp compare (no walk); the actual O(N) + # sweep runs at most every ``_cleanup_interval``, ~twice a second + # by default. A separate thread would either need a lock around + # ``selector.get_map()`` (losing the lock-free property of the + # current op-queue model) or maintain a parallel data structure + # — both add machinery without buying anything visible. + # # Lazy: skip the O(N) walk over registered conns when not enough # time has passed since the last sweep. Worst-case extra detection # latency is ``_cleanup_interval`` (default 0.5 s) on top of the @@ -664,14 +673,24 @@ def borrowed(self) -> bool: @contextmanager def borrow(self): + """Switch the conn out of selector tracking for the duration of the block. + + Useful for selector-thread handlers that need to do blocking I/O + (e.g. reading a request body after sending ``100 Continue``). + ``finish_response`` re-tracks the conn via ``_maybe_give_back`` on + the success path; on early exit we re-track here. + + The :func:`thread_pool_handler` dispatch path doesn't go through this + CM — it calls ``stop_tracking`` directly before queueing the conn to + a worker, and the response-completion logic re-tracks via + ``finish_response``. + """ assert not self.borrowed self._server.stop_tracking(self._conn) try: yield self finally: self._maybe_give_back() - # if not self._conn.recv_closed: - # self._server.track(self._conn) def _maybe_give_back(self) -> None: if self.borrowed: diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index 887d932..9a2cf70 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -1,8 +1,8 @@ """Subprocess entry point for integration tests. Runs a hosted localpost HTTP server (Router-based by default; switchable -to wsgi_server / flask_server via env vars) on the port given by -``LP_TEST_PORT`` and the anyio backend given by ``LP_TEST_BACKEND`` +to a WSGI / Flask app via the ``LP_TEST_MODE`` env var) on the port given +by ``LP_TEST_PORT`` and the anyio backend given by ``LP_TEST_BACKEND`` (``asyncio`` or ``trio``). """ @@ -78,19 +78,23 @@ def hello(name: str): def _main() -> int: logging.basicConfig(level=logging.INFO) - from localpost.hosting import run + from localpost.hosting import run, service + from localpost.hosting.middleware import shutdown_on_signal + from localpost.http import ServerConfig, http_server, thread_pool_handler # Honor LP_TEST_BACKEND so tests can pin asyncio vs trio. backend = os.environ.get("LP_TEST_BACKEND", "asyncio") port = int(os.environ["LP_TEST_PORT"]) handler = _build_handler() - - from localpost.hosting.middleware import shutdown_on_signal - from localpost.http import ServerConfig, http_server - cfg = ServerConfig(host="127.0.0.1", port=port) - svc = shutdown_on_signal()(http_server(cfg, handler, max_concurrency=8)) + @service + async def app(): + async with thread_pool_handler(handler, max_concurrency=8) as wrapped: + async with http_server(cfg, wrapped): + yield + + svc = shutdown_on_signal()(app()) return anyio.run(run, svc, None, backend=backend) diff --git a/tests/http/service.py b/tests/http/service.py index 72ccf51..1eadc3c 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -1,4 +1,6 @@ -"""Tests for localpost.http._service (the hosted http_server service).""" +"""Tests for ``localpost.http._service`` (the hosted ``http_server`` service) +and ``localpost.http._pool`` (the ``thread_pool_handler`` async CM). +""" from __future__ import annotations @@ -7,6 +9,8 @@ import threading import time from collections import Counter +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager import anyio import h11 @@ -14,7 +18,7 @@ import pytest from anyio import to_thread -from localpost.hosting import serve +from localpost.hosting import ServiceLifetimeView, serve from localpost.http import ( HTTPReqCtx, RequestCancelled, @@ -24,11 +28,29 @@ ServerConfig, check_cancelled, http_server, + thread_pool_handler, ) +from localpost.http.server import RequestHandler pytestmark = pytest.mark.anyio +@asynccontextmanager +async def _serve_pooled( + cfg: ServerConfig, + handler: RequestHandler, + *, + max_concurrency: int, +) -> AsyncGenerator[ServiceLifetimeView]: + """Compose ``thread_pool_handler`` + ``http_server`` and yield the http_server lifetime view. + + Tests own shutdown via the yielded lifetime; the pool drains on exit. + """ + async with thread_pool_handler(handler, max_concurrency=max_concurrency) as wrapped: + async with serve(http_server(cfg, wrapped)) as lt: + yield lt + + def _handler_200(body: bytes = b"ok"): def handler(ctx: HTTPReqCtx): ctx.complete( @@ -64,10 +86,23 @@ def probe(): class TestHttpServerService: - async def test_serves_single_request(self, free_port): + async def test_serves_single_request_immediate(self, free_port): + """Immediate handler: no thread pool, runs on the selector thread.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"hi"))) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_single_request_pooled(self, free_port): + """Pool-wrapped handler: same observable behaviour, but on a worker thread.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, _handler_200(b"hi")) - async with serve(svc) as lt: + async with _serve_pooled(cfg, _handler_200(b"hi"), max_concurrency=1) as lt: await lt.started await _wait_server_ready(free_port) resp = await _get(f"http://127.0.0.1:{free_port}/") @@ -96,8 +131,7 @@ def handler(ctx: HTTPReqCtx): ) cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, handler, max_concurrency=4) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=4) as lt: await lt.started await _wait_server_ready(free_port) @@ -134,7 +168,7 @@ def wait_for_three(): await lt.stopped async def test_max_concurrency_one_serializes(self, free_port): - """With max_concurrency=1, requests are handled one at a time.""" + """With a 1-slot pool, requests are handled one at a time.""" in_flight = 0 peak = 0 lock = threading.Lock() @@ -150,8 +184,7 @@ def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, handler, max_concurrency=1) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: await lt.started await _wait_server_ready(free_port) @@ -165,13 +198,17 @@ def handler(ctx: HTTPReqCtx): await lt.stopped async def test_shutdown_cancels_inflight(self, free_port): - """Triggering shutdown while a handler is running cancels it via the HTTP cancellation token.""" + """Triggering shutdown while a handler is running cancels it via the HTTP cancellation token. + + ``thread_pool_handler``'s exit sets the shared shutdown event ORed into + every in-flight ``RequestCancel``, so the next ``check_cancelled`` call + in the handler raises ``RequestCancelled``. + """ handler_started = threading.Event() handler_cancelled = threading.Event() def handler(ctx: HTTPReqCtx): handler_started.set() - # Cooperate with cancellation via localpost.http.check_cancelled try: for _ in range(100): check_cancelled() @@ -182,8 +219,7 @@ def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, handler, max_concurrency=2) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: await lt.started await _wait_server_ready(free_port) @@ -202,7 +238,7 @@ async def fire_and_forget(): await lt.stopped - # The handler's own loop observes cancellation via from_thread.check_cancelled. + # The handler's own loop observes cancellation via ``check_cancelled``. assert handler_cancelled.is_set() async def test_router_dispatch_via_service(self, free_port): @@ -216,8 +252,7 @@ def get_book(ctx: RequestCtx) -> Response: router = routes.build() cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, router.as_handler(), max_concurrency=4) - async with serve(svc) as lt: + async with _serve_pooled(cfg, router.as_handler(), max_concurrency=4) as lt: await lt.started await _wait_server_ready(free_port) @@ -233,7 +268,53 @@ def get_book(ctx: RequestCtx) -> Response: async def test_invalid_max_concurrency(self): with pytest.raises(ValueError, match="max_concurrency"): - http_server(ServerConfig(), _handler_200(), max_concurrency=0) + # The CM-creation call is enough to trigger validation; we don't + # need to enter it. + thread_pool_handler(_handler_200(), max_concurrency=0) + + +class TestSelectorThreadFastPath: + """When the Router is passed directly to ``http_server`` (no ``thread_pool_handler`` + wrapping), every route — including the 404 / 405 paths — runs on the selector + thread. No thread pool is involved at all, so 404/405 cost is just a regex + match plus a static response payload.""" + + async def test_router_direct_runs_on_one_thread(self, free_port): + """Matched routes all share a single thread (the selector). 404 returns + normally even though there's no worker pool to dispatch it through.""" + threads_seen: set[int] = set() + lock = threading.Lock() + + routes = Routes() + + @routes.get("/hit") + def hit(_: RequestCtx) -> Response: + with lock: + threads_seen.add(threading.get_ident()) + return Response(200, {"content-type": "text/plain"}, [b"ok"]) + + assert hit is not None + router = routes.build() + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, router.as_handler())) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(3): + r = await _get(f"http://127.0.0.1:{free_port}/hit") + assert r.status_code == 200 + + r = await _get(f"http://127.0.0.1:{free_port}/missing") + assert r.status_code == 404 + + # Every matched request was served from the same thread — the + # selector. With no ``thread_pool_handler`` in the composition, + # there are no worker threads to fan out to. + assert len(threads_seen) == 1, threads_seen + + lt.shutdown() + await lt.stopped class TestServiceRobustness: @@ -255,8 +336,7 @@ def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, handler, max_concurrency=3) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=3) as lt: await lt.started await _wait_server_ready(free_port) @@ -292,8 +372,7 @@ def boom(_: HTTPReqCtx) -> None: raise RuntimeError("handler crashed") cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, boom, max_concurrency=2) - async with serve(svc) as lt: + async with _serve_pooled(cfg, boom, max_concurrency=2) as lt: await lt.started await _wait_server_ready(free_port) @@ -308,18 +387,17 @@ def boom(_: HTTPReqCtx) -> None: assert lt.exit_code == 0 async def test_slot_released_after_normal_request(self, free_port): - """Repeatedly hitting a max_concurrency=1 service must keep working. + """Repeatedly hitting a 1-slot pool must keep working. - Indirectly confirms ``req_slots.release()`` runs on the success path — - if it didn't, the second request would block forever on the semaphore. + Indirectly confirms the worker channel slot is released on the success + path — if it weren't, the second request would block forever. """ def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, handler, max_concurrency=1) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: await lt.started await _wait_server_ready(free_port) @@ -342,8 +420,7 @@ def handler(ctx: HTTPReqCtx): tid, ) - svc = http_server(cfg, handler, max_concurrency=8) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=8) as lt: await lt.started await _wait_server_ready(free_port) @@ -391,8 +468,7 @@ def handler(ctx: HTTPReqCtx): ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) - svc = http_server(cfg, handler, max_concurrency=2) - async with serve(svc) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: await lt.started await _wait_server_ready(free_port) @@ -413,6 +489,3 @@ def wait_for_cancel(): lt.shutdown() await lt.stopped - - lt.shutdown() - await lt.stopped From 8ce186809ff9c12d041a42e892f6c8435b925221 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 13:47:33 +0400 Subject: [PATCH 104/286] fix(di): use reentrant lock so factories can resolve their own deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``DefaultServiceProvider._lock`` was ``threading.Lock`` (non-reentrant). The factory passed to ``descriptor.factory(self)`` runs while the lock is held, and any service with auto-wired constructor dependencies recurses into ``provider.resolve(dep)`` on the same thread — which then tried to re-acquire the same lock. Result: deadlock for any non-trivial DI graph. Switch to ``threading.RLock``; same-thread recursion is exactly what we need (different threads still serialize correctly). Also fix ``test_nested_scopes`` which constructed ``DefaultServiceProvider`` with three positional args — the dataclass now requires ``scope_type`` as the fourth. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/di/_services.py | 5 ++++- tests/di/services.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/localpost/di/_services.py b/localpost/di/_services.py index 2ca4f8e..6b90102 100644 --- a/localpost/di/_services.py +++ b/localpost/di/_services.py @@ -56,7 +56,10 @@ class DefaultServiceProvider(ServiceProvider): scope_type: type[ResolutionContext] services: Mapping[type, object] = field(default_factory=dict, init=False) """Resolved services.""" - _lock: threading.Lock = field(default_factory=threading.Lock, init=False) + _lock: threading.RLock = field(default_factory=threading.RLock, init=False) + """Reentrant: factories resolve their own dependencies via this same provider, + which re-enters the lock on the same thread. A non-reentrant ``Lock`` would + deadlock as soon as a service has any auto-wired dependency.""" def create[T](self, target_type: type[T], /, **kwargs: Any) -> T: """Create an instance of the given type, resolving constructor deps not provided in kwargs.""" diff --git a/tests/di/services.py b/tests/di/services.py index c4b5c75..50a6046 100644 --- a/tests/di/services.py +++ b/tests/di/services.py @@ -265,7 +265,7 @@ def test_nested_scopes(self): inner_registry.register_instance(inner_config) inner_ctx = AppContext() - inner_provider = DefaultServiceProvider(outer_provider, inner_registry, inner_ctx) + inner_provider = DefaultServiceProvider(outer_provider, inner_registry, inner_ctx, AppContext) with inner_ctx.ctx, set_cvar(current_provider, inner_provider): assert inner_provider.resolve(Config).host == "inner" assert current_provider.get().resolve(Config).host == "inner" From b249beff1b4801f78cce715eb523b4b3e5b2128f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 13:47:52 +0400 Subject: [PATCH 105/286] fix(scheduler): restore working state from 8a40d99 + adapt to current API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler is marked stable but the package was non-importable: ``_cond.py`` had a syntax error (incomplete ``After.__call__`` body), ``_scheduler.py`` was missing ``Scheduler``, ``ScheduledTaskTemplate``, ``Task``, ``Trigger`` and related symbols that ``__init__.py`` imports, and ``_trigger.py`` had been emptied. Three WIP commits since the last "fix: scheduler" left the rework half-done. Restore ``_cond.py``, ``_scheduler.py``, and ``_trigger.py`` to their state at 8a40d99 (the last known-good scheduler) and adapt to today's API: * ``ClosingContext`` was renamed to ``maybe_closing`` in ``localpost._utils``. * ``__init__.py`` no longer tries to export the never-shipped top-level ``run`` symbol. * ``after_all``'s param annotation was over-restrictive (``Result[T]`` nested inside ``Result``); accept ``T`` so the return type ``ScheduledTaskTemplate[Result[T]]`` matches. * Logger f-strings → lazy ``%`` formatting (G004). * Pinned ``# noqa`` on the few ``Generic[...]`` declarations that can't move to PEP 695 type-parameter syntax without losing the parameterised base-class semantics (``AbstractAsyncContextManager[Callable[[T], …]]`` on ``Task``; the ``After`` / ``AfterAll`` / ``after`` / ``after_all`` / ``trigger_factory_middleware`` declarations). All 12 scheduler tests (6 unit + 6 cron-related) now pass on the current codebase. The README added in ac414e2 already documents what this restoration brings back. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/scheduler/__init__.py | 7 +- localpost/scheduler/_cond.py | 178 ++++++++------------ localpost/scheduler/_scheduler.py | 261 +++++++++++++++++++++++------- localpost/scheduler/_trigger.py | 66 ++++++++ 4 files changed, 329 insertions(+), 183 deletions(-) diff --git a/localpost/scheduler/__init__.py b/localpost/scheduler/__init__.py index 94b6cd0..e28ba0a 100644 --- a/localpost/scheduler/__init__.py +++ b/localpost/scheduler/__init__.py @@ -1,16 +1,11 @@ from ._cond import after, after_all, every -from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Scheduler, Task, run, scheduled_task +from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Scheduler, Task, scheduled_task from ._trigger import delay, take_first, trigger_factory_middleware __all__ = [ - # "TaskHandler", "Task", "ScheduledTaskTemplate", "ScheduledTask", - # "Trigger", - # "TriggerFactory", - # "TriggerFactoryMiddleware", - # "TriggerFactoryDecorator", "Scheduler", "scheduled_task", "trigger_factory_middleware", diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index a107eeb..de7e6f4 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -3,32 +3,27 @@ import dataclasses as dc import itertools import logging -from collections.abc import AsyncGenerator, Iterable -from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from dataclasses import replace +from collections.abc import Iterable +from contextlib import asynccontextmanager from datetime import timedelta -from itertools import chain, cycle -from typing import Any, Generic, Self, TypeVar, cast, final +from typing import Any, Generic, TypeVar, final from anyio import ( BrokenResourceError, EndOfStream, WouldBlock, - create_memory_object_stream, create_task_group, get_cancelled_exc_class, ) -from anyio.abc import TaskGroup -from anyio.streams.memory import MemoryObjectSendStream from localpost._utils import ( TD_ZERO, - ClosingContext, EventView, MemoryStream, Result, cancellable_from, ensure_td, + maybe_closing, sleep, start_task_soon, td_str, @@ -42,108 +37,91 @@ logger = logging.getLogger("localpost.scheduler.cond") -import logging -from collections.abc import AsyncIterator, Callable -from functools import wraps -from typing import ParamSpec, TypeVar - -from localpost._utils import ( - DelayFactory, - ensure_delay_factory, - maybe_closing, -) - -# from ._scheduler import TriggerFactoryDecorator - -# P = ParamSpec("P") -# T = TypeVar("T") -# Trigger = AsyncIterator[T] -# TriggerDecorator = Callable[[Callable[P, AsyncIterator[T]]], Callable[P, AsyncIterator[T]]] -# TriggerMiddleware = Callable[[AsyncIterator[T]], AsyncIterator[T]] - -type TriggerMiddleware[T1, T2] = Callable[[AsyncIterator[T1]], AsyncIterator[T2]] - - -async def wait_trigger(time_spans: Iterable[timedelta], events: MemoryObjectSendStream[None]) -> None: - try: - for iter_n, iter_delay in enumerate(time_spans): - logger.debug("Sleeping for %s (iteration: %s)", td_str(iter_delay), iter_n) - await sleep(iter_delay) - try: - events.send_nowait(None) - except WouldBlock: - logger.warning("All executors are busy, skipping the event") - except BrokenResourceError: - logger.debug("All executors have been closed, stopping") - except get_cancelled_exc_class(): - logger.debug("Task is shutting down, stopping") - raise - finally: - events.close() - - @asynccontextmanager -async def apply_middlewares[T](source: T, middlewares: tuple[Callable[[T], T], ...]) -> AsyncGenerator[T]: - def as_acm(t) -> AbstractAsyncContextManager[T]: - return t if isinstance(t, AbstractAsyncContextManager) else maybe_closing(t) - - async with AsyncExitStack() as scope: - source = await scope.enter_async_context(as_acm(source)) - for middleware in middlewares: - source = await scope.enter_async_context(as_acm(middleware(source))) - yield source +async def wait_trigger(time_spans: Iterable[timedelta], shutting_down: EventView): + events, events_reader = MemoryStream[None].create(0) + + @cancellable_from(shutting_down) # DO NOT cancel the main task group + async def generate(): + try: + iter_n = 1 + logger.debug("Sleeping for %s (iteration: %s)", td_str(TD_ZERO), iter_n) + await events.send(None) # Execute the first iteration immediately + for iter_delay in time_spans: + iter_n += 1 + logger.debug("Sleeping for %s (iteration: %s)", td_str(iter_delay), iter_n) + await sleep(iter_delay) + try: + events.send_nowait(None) + except WouldBlock: + logger.warning("All executors are busy, skipping the event") + except BrokenResourceError: + logger.debug("All executors have been closed, stopping") + except get_cancelled_exc_class(): + logger.debug("Task is shutting down, stopping") + raise + finally: + events.close() + + # Order matters, the reader should be closed first (so the run loop can stop by itself) + async with create_task_group() as main_tg, events_reader: + start_task_soon(main_tg, generate) + try: + yield events_reader + except GeneratorExit: + # Can happen, if a trigger is wrapped in a middleware, backed by a generator + # (if we don't do that, it will be an unhandled exception in the task group) + pass @final @dc.dataclass(frozen=True, slots=True) -class Every[T]: +class Every: period: timedelta - middlewares: tuple[TriggerMiddleware[Any, Any], ...] = () def __repr__(self): return f"every({td_str(self.period)})" - def __rshift__[T2](self, other: TriggerMiddleware[T, T2]) -> Every[T2]: - return cast(Every[T2], replace(self, middlewares=self.middlewares + (other,))) - - def __call__(self, tg: TaskGroup, shutting_down: EventView) -> AbstractAsyncContextManager[AsyncIterator[T]]: - sender, receiver = create_memory_object_stream[None]() - tg.start_soon(wait_trigger, chain([0], cycle([self.period])), sender) - - return apply_middlewares(receiver, self.middlewares) + def __call__(self, task: ScheduledTask) -> Trigger[None]: + return wait_trigger(itertools.cycle([self.period]), task.shutting_down) def every(period: timedelta | str, /) -> ScheduledTaskTemplate[None]: - """Trigger an event every `period`.""" - return Every(ensure_td(period)) + """ + Trigger an event every `period`. + """ + # return ScheduledTaskTemplate(Every(ensure_td(period))) >> buffer(0, full_mode="drop") + return ScheduledTaskTemplate(Every(ensure_td(period))) @final @dc.dataclass(frozen=True, slots=True) -class After[ResT, T]: +class After(Generic[ResT]): # noqa: UP046 target: Task[Any, ResT] - middlewares: tuple[TriggerMiddleware[Any, Any], ...] = () def __repr__(self): return f"after({self.target!r})" - def __rshift__[T2](self, other: TriggerMiddleware[T, T2]) -> After[ResT, T2]: - return cast(After[ResT, T2], replace(self, middlewares=self.middlewares + (other,))) + def __call__(self, this_task: ScheduledTask) -> Trigger[ResT]: + task_exec_results = self.target.subscribe() - def __call__(self, _: TaskGroup, shutting_down: EventView) -> AbstractAsyncContextManager[AsyncIterator[T]]: - def generate(): - async with self.target.subscribe() as - async for task_exec_results.receive() + async def run(): + try: + while True: + res = await task_exec_results.receive() if res.error: logger.warning("Target task failed, skipping") # TODO extra else: yield res.value - - task_exec_results = self.target.subscribe() - return apply_middlewares(source, self.middlewares) + except EndOfStream: + logger.info("Target task completed, stopping") + finally: + task_exec_results.close() + + return maybe_closing(run()) -def after[T](target: ScheduledTask[Any, T], /) -> ScheduledTaskTemplate[T]: +def after(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[T]: # noqa: UP047 """ Trigger an event every time the target task completes successfully. """ @@ -152,7 +130,7 @@ def after[T](target: ScheduledTask[Any, T], /) -> ScheduledTaskTemplate[T]: @final @dc.dataclass(frozen=True, slots=True) -class AfterAll(Generic[ResT]): +class AfterAll(Generic[ResT]): # noqa: UP046 target: Task[Any, ResT] def __repr__(self): @@ -170,41 +148,11 @@ async def run(): finally: task_exec_results.close() - return ClosingContext(run()) + return maybe_closing(run()) -def after_all(target: ScheduledTask[Any, Result[T]] | Task[Any, Result[T]], /) -> ScheduledTaskTemplate[Result[T]]: +def after_all(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[Result[T]]: # noqa: UP047 """ Trigger an event every time the target task completes (successfully or not). """ return ScheduledTaskTemplate(AfterAll(target if isinstance(target, Task) else target.task)) - - -def take_first(n: int, /) -> TriggerMiddleware: - if n < 0: - raise ValueError("N must be a non-negative integer") - - async def middleware(events: AsyncIterator[T]) -> AsyncIterator[T]: - iter_n = 0 - async with maybe_closing(events): - async for event in events: - if iter_n >= n: - break - iter_n += 1 - yield event - - return middleware - - -def delay(value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: - delay_f = ensure_delay_factory(value) - - async def middleware(events: AsyncIterator[T]) -> AsyncIterator[T]: - async with maybe_closing(events): - async for event in events: - item_jitter = delay_f() - logger.debug("Sleeping for %s (delay)", td_str(item_jitter)) - await sleep(item_jitter) - yield event - - return middleware diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 42747d5..b6eb51c 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -1,56 +1,64 @@ from __future__ import annotations +import dataclasses as dc import inspect import logging import math -import threading -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager, contextmanager -from dataclasses import dataclass -from dataclasses import dataclass as define -from functools import partial -from typing import Any, TypeAlias, TypeVar, cast, final - -import anyio -from anyio import BrokenResourceError, WouldBlock, create_task_group, open_signal_receiver, to_thread -from anyio.abc import TaskGroup +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager +from typing import Any, Generic, Protocol, TypeVar, cast, final + +from anyio import BrokenResourceError, WouldBlock, create_task_group, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( - HANDLED_SIGNALS, Event, EventView, MemoryStream, Result, - choose_anyio_backend, def_full_name, is_async_callable, start_task_soon, - wait_any, ) -from localpost.hosting import ServiceLifetime -# T = TypeVar("T") -# T2 = TypeVar("T2") -# R = TypeVar("R") +T = TypeVar("T") +T2 = TypeVar("T2") +R = TypeVar("R") DecF = TypeVar("DecF", bound=Callable[..., Any]) -type AnyTaskHandler[T, R] = Callable[[T], Awaitable[R]] | Callable[[T], R] -type TaskHandler[T, R] = Callable[[T], Awaitable[R]] +type TaskHandler[T, R] = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] +type HandlerDecorator = Callable[[Any], Any] logger = logging.getLogger("localpost.scheduler") -def task_handler[T, R](func: AnyTaskHandler[T, R]) -> TaskHandler[T, R]: - return func if is_async_callable(func) else partial(to_thread.run_sync, func) +@final +@dc.dataclass() +class Task( + Generic[T, R], # noqa: UP046 — kept for compat with the parameterised AbstractAsyncContextManager base + AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], # AsyncHandlerManager[T] +): + name: str + event_aware: bool + def __init__(self, target: TaskHandler[T, R], /, *, name: str | None = None): + self.name = name or def_full_name(target) + self._target = target + e_aware = self.event_aware = len(inspect.signature(target).parameters) > 0 -@final -@define() -class ScheduledTaskRun[T, R]: - task: ScheduledTask[T, R] - sl: ServiceLifetime - handle: TaskHandler[T, R] + def e_handler(t) -> Callable[[T], Awaitable[R]]: + if is_async_callable(t): + return t if e_aware else lambda _: t() # type: ignore[misc] + return (lambda e: to_thread.run_sync(t, e)) if e_aware else (lambda _: to_thread.run_sync(t)) + + self._handle = e_handler(target) + + self._cm = ExitStack() + self._subscribers: list[MemoryObjectSendStream[Result[R]]] = [] + self._users = 0 + + def __repr__(self): + return f"<{self.__class__.__name__} {self.name!r}>" def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStream[Result[R]]: # By default, a stream is created with a buffer size of 0, which means that any write will be blocked until @@ -82,10 +90,91 @@ async def __call__(self, event: T) -> None: self._publish_result(result) raise + async def __aenter__(self): + self._users += 1 + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> bool | None: + self._users -= 1 + # A task can be scheduled multiple times, so we need to keep the results streams open until all the scheduled + # tasks are completed + if self._users == 0: + return self._cm.__exit__(exc_type, exc_value, traceback) + return False # Do not suppress exceptions + + +@final +class ScheduledTaskTemplate[T]: + @classmethod + def ensure(cls, tpl: TriggerFactory[T]) -> ScheduledTaskTemplate[T]: + if isinstance(tpl, cls): + return tpl + return cls(tpl) + + def __init__(self, tf: TriggerFactory[T]): + self._tf = tf + self._tf_queue: tuple[TriggerFactoryDecorator, ...] = () + self._handler_decorators: tuple[HandlerDecorator, ...] = () + + # TriggerFactory[T] + def __call__(self, *args, **kwargs) -> Trigger[T]: + return self.tf(*args, **kwargs) + + def __truediv__(self, middleware: TriggerFactoryMiddleware[T, T2]) -> ScheduledTaskTemplate[T2]: + from ._trigger import trigger_factory_middleware # noqa: PLC0415 + + return self // trigger_factory_middleware(middleware) + + def __floordiv__(self, decorator: TriggerFactoryDecorator[T, T2]) -> ScheduledTaskTemplate[T2]: + n = ScheduledTaskTemplate(self._tf) + n._tf_queue = self._tf_queue + (decorator,) + return cast(ScheduledTaskTemplate[T2], n) + + def __rshift__(self, decorator: HandlerDecorator) -> ScheduledTaskTemplate[T]: + n = ScheduledTaskTemplate[T](self._tf) + n._handler_decorators = self._handler_decorators + (decorator,) + return n + + def resolve_handler(self, task: Task[T, Any]) -> AbstractAsyncContextManager[Callable[[T], Awaitable[None]]]: + # TODO: Support handler decorators (flow module) + return task + + @property + def tf(self) -> TriggerFactory[T]: + tf = self._tf + for decorator in self._tf_queue: + tf = decorator(tf) + return tf + + +class ScheduledTask(Protocol[T, R]): + @property + def shutting_down(self) -> EventView: ... + + @property + def task(self) -> Task[T, R]: ... + + +@final +class _ScheduledTask[T, R]: + def __init__(self, task: Task[T, R], tf: TriggerFactory[T]): + self.task = task + self._trigger_factory = tf + tpl = ScheduledTaskTemplate.ensure(tf) + self._handler = tpl.resolve_handler(task) + self._shutting_down: EventView = Event() # Placeholder, resolved in run() + + def __repr__(self): + return f"ScheduledTask({self.name!r})" + @property def shutting_down(self) -> EventView: return self._shutting_down + @property + def name(self) -> str: + return self.task.name + async def run(self, shutting_down: EventView) -> None: self._shutting_down = shutting_down trigger = self._trigger_factory(self) @@ -93,49 +182,97 @@ async def run(self, shutting_down: EventView) -> None: async with trigger as t_events, self._handler as message_handler: async for t_event in t_events: await message_handler(t_event) - logger.debug(f"{self!r} trigger is completed") - logger.debug(f"{self!r} is done") + logger.debug("%r trigger is completed", self) + logger.debug("%r is done", self) + async def __call__(self, shutting_down: EventView) -> None: + return await self.run(shutting_down) -@final -@define() -class ScheduledTask[T, R]: - name: str - _until_running: EventView - current_run: ScheduledTaskRun[T, R] | None = None - def __init__(self, h: TaskHandler[T, R], tf: TriggerFactory[T], /, *, name: str | None = None): - self.name = name or def_full_name(h) - self._handler = h - self._trigger_f = tf +type Trigger[T] = AbstractAsyncContextManager[AsyncIterator[T]] +type TriggerFactory[T] = Callable[ + [ScheduledTask[T, Any]], AbstractAsyncContextManager[AsyncIterator[T]] # Trigger[T] +] +type TriggerFactoryMiddleware[T, T2] = Callable[ + [ + AbstractAsyncContextManager[AsyncIterator[T]], # Trigger[T] (source) + ScheduledTask, + ], + AsyncIterable[T2], # TODO AbstractAsyncContextManager[AsyncIterator[T2]] +] +type TriggerFactoryDecorator[T, T2] = Callable[ + [Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T]]]], # TriggerFactory[T] + Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T2]]], # TriggerFactory[T2] +] - def __repr__(self): - return f"<{self.__class__.__name__} {self.name!r}>" - async def get_current_run(self) -> ScheduledTaskRun[T, R]: - await self._until_running.wait() - assert self.current_run is not None - return self.current_run +def scheduled_task[T]( + tf: TriggerFactory[T], /, *, name: str | None = None +) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: + """ + Schedule a task with the given trigger. + """ - async def run(self, sl: ServiceLifetime) -> None: - # TODO - # - sl.set_started() - # - create and set current_run - # - - pass + def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> _ScheduledTask[T, R]: + t = func if isinstance(func, Task) else Task(func) + if name: + t.name = name + return _ScheduledTask(t, tf) + return _decorator -type TriggerEvents[T] = AbstractAsyncContextManager[AsyncIterator[T]] -type TriggerFactory[T] = Callable[[TaskGroup, EventView], TriggerEvents[T]] +class Scheduler: + """ + Manages a collection of periodic tasks. -def scheduled_task[T](tf: TriggerFactory[T], /, *, name: str | None = None) -> Callable[..., ScheduledTask[T, Any]]: - """Schedule a task with the given trigger.""" + Can be used standalone via `aserve()`, or integrated with hosting via `as_service()`. + """ - def _decorator[R](func: AnyTaskHandler[T, R]) -> ScheduledTask[T, R]: - st = ScheduledTask(task_handler(func), tf) - if name: - st.name = name - return st + def __init__(self, name: str = "scheduler"): + self._name = name + self._scheduled_tasks: list[_ScheduledTask[Any, Any]] = [] + self._shutting_down: Event | None = None - return _decorator + @property + def name(self) -> str: + return self._name + + def task( + self, tf: TriggerFactory[T], /, *, name: str | None = None + ) -> Callable[[TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]], _ScheduledTask[T, R]]: + """ + Schedule a task with the given trigger. + """ + + def _decorator(func: TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]): + if isinstance(func, _ScheduledTask): + func = func.task + st = scheduled_task(tf, name=name)(cast(TaskHandler[T, R] | Task[T, R], func)) + self._scheduled_tasks.append(st) + return st + + return _decorator + + def shutdown(self) -> None: + if self._shutting_down: + self._shutting_down.set() + + @asynccontextmanager + async def aserve(self): + """Run all scheduled tasks. Use `shutdown()` or exit the context to stop.""" + shutting_down = self._shutting_down = Event() + if not self._scheduled_tasks: + yield + return + async with create_task_group() as tg: + for st in self._scheduled_tasks: + start_task_soon(tg, lambda s=st: s.run(shutting_down)) + yield + shutting_down.set() + + async def as_service(self, lt) -> None: + """Run the scheduler as a hosting service (accepts a ServiceLifetime).""" + async with self.aserve(): + lt.set_started() + await lt.shutting_down.wait() diff --git a/localpost/scheduler/_trigger.py b/localpost/scheduler/_trigger.py index 9d48db4..29b3350 100644 --- a/localpost/scheduler/_trigger.py +++ b/localpost/scheduler/_trigger.py @@ -1 +1,67 @@ from __future__ import annotations + +import logging +from contextlib import AbstractAsyncContextManager +from functools import wraps +from typing import TypeVar, cast + +from localpost._utils import DelayFactory, cancellable_from, ensure_delay_factory, maybe_closing, sleep, td_str + +from ._scheduler import ScheduledTask, Trigger, TriggerFactory, TriggerFactoryDecorator, TriggerFactoryMiddleware + +T = TypeVar("T") +T2 = TypeVar("T2") + +logger = logging.getLogger("localpost.scheduler.cond") + + +def trigger_factory_middleware(middleware: TriggerFactoryMiddleware[T, T2]) -> TriggerFactoryDecorator[T, T2]: # noqa: UP047 + def _decorator(source: TriggerFactory[T]) -> TriggerFactory[T2]: + @wraps(source) + def _run(task): + source_events = source(task) + events = middleware(source_events, task) + return cast( + Trigger[T2], events if isinstance(events, AbstractAsyncContextManager) else maybe_closing(events) + ) + + return _run + + return _decorator + + +def take_first(n: int, /) -> TriggerFactoryDecorator[T, T]: + if n < 0: + raise ValueError("n must be a non-negative integer") + + @trigger_factory_middleware + async def middleware(source: Trigger[T], _): + iter_n = 0 + if iter_n >= n: + return + async with source as events: + async for event in events: + iter_n += 1 + yield event + if iter_n >= n: + break + + return middleware + + +def delay(value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: + delay_f = ensure_delay_factory(value) + + @trigger_factory_middleware + async def middleware(source: Trigger[T], task: ScheduledTask): + shutdown_aware_sleep = cancellable_from(task.shutting_down)(sleep) + async with source as events: + async for event in events: + item_jitter = delay_f() + logger.debug("Sleeping for %s (delay)", td_str(item_jitter)) + await shutdown_aware_sleep(item_jitter) + if task.shutting_down: + break + yield event + + return middleware From 881f0c8889ac09c3665a25c1091e92ef346200ea Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 14:14:04 +0400 Subject: [PATCH 106/286] chore: clean up dead commented imports/__all__ in localpost/__init__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level ``__init__.py`` had ``# from ._run import arun, run`` and ``# __all__ = [..., "arun", "run"]`` from a never-merged proposal — there is no ``localpost._run`` module. Drop the dead comments and uncomment a real ``__all__`` listing what the module actually re-exports today (``Result``, ``__version__``, ``debug``), matching the inventory in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/localpost/__init__.py b/localpost/__init__.py index d75d9bf..3c80867 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -2,8 +2,6 @@ from importlib.metadata import version from ._debug import debug - -# from ._run import arun, run from ._utils import Result try: @@ -12,7 +10,7 @@ __version__ = "dev" -# __all__ = ["Result", "__version__", "arun", "debug", "run"] +__all__ = ["Result", "__version__", "debug"] # Set up logging according to the best practices: From b9c7b4fa70a72c6ce0c12d4e9a65c7de2c87d24e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 14:27:10 +0400 Subject: [PATCH 107/286] chore: clear remaining ruff/ty errors in stable packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After fixing scheduler and DI, ``hosting`` and ``http`` still flagged a handful of issues in Zed (it runs ``ty`` as a language server). Cleared all of them so stable packages are warning-free end to end: * ``hosting/_host.py``: ``_ResolvedService.__aexit__`` returns whatever ``self._run.__aexit__`` returns (``bool | None``), not ``None``. Replaced the ``svc_f = lambda lt: …`` assignment with a real ``def`` (E731) and pulled the ``CapacityLimiter`` out of the closure so each sync-service wrapper gets a stable, single-slot limiter. * ``hosting/middleware.py``: ``start_timeout`` was generic in ``F`` for no reason — the wrapper has a fixed ``ServiceLifetime -> Awaitable[None]`` signature. Replaced with the concrete ``ServiceF`` type, mirroring ``shutdown_on_signal``. * ``hosting/services/uvicorn.py``: documented the two intentional monkey-patches on ``uvicorn.Server`` (``main_loop`` wrapper to fire lifecycle events; ``capture_signals`` no-op so our ``shutdown_on_signal`` middleware owns SIGINT/SIGTERM) and silenced ty's ``invalid-assignment`` complaints. * ``hosting/services/hypercorn.py``: kept the inline ``trio`` / ``asyncio`` ``serve`` imports — they're conditional on the running event loop and a top-level import would force one backend at import time. Suppressed ``PLC0415`` with a short rationale. * ``http/config.py``: ``host: str = "0.0.0.0"`` is the right default for an HTTP server library; suppressed ``S104`` inline. Tests: 233/233 pass across hosting, http, di, scheduler, threadtools, utils. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/hosting/_host.py | 7 +++++-- localpost/hosting/middleware.py | 4 ++-- localpost/hosting/services/hypercorn.py | 7 +++++-- localpost/hosting/services/uvicorn.py | 9 +++++++-- localpost/http/config.py | 2 +- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 3759ed0..c16a798 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -288,7 +288,7 @@ async def __aenter__(self) -> ServiceLifetimeView: self._run = serve(self.func, parent=_svc_lt.get(None)) return await self._run.__aenter__() - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool | None: assert self._run is not None return await self._run.__aexit__(exc_type, exc_val, exc_tb) @@ -317,7 +317,10 @@ def wrapper(*args, **kwargs): if is_async_callable(raw_svc_f): svc_f = raw_svc_f else: - svc_f = lambda lt: to_thread.run_sync(raw_svc_f, lt, limiter=CapacityLimiter(1)) + limiter = CapacityLimiter(1) + + async def svc_f(lt: ServiceLifetime) -> None: + await to_thread.run_sync(raw_svc_f, lt, limiter=limiter) return _ResolvedService(svc_f) if inspect.isasyncgenfunction(func): diff --git a/localpost/hosting/middleware.py b/localpost/hosting/middleware.py index b97a62a..85902b4 100644 --- a/localpost/hosting/middleware.py +++ b/localpost/hosting/middleware.py @@ -16,8 +16,8 @@ async def _observe_started(lt: ServiceLifetimeView, timeout: float) -> None: raise TimeoutError(f"Service did not start within {timeout} second(s)") -def start_timeout[F](timeout: float) -> Callable[[F], F]: - def decorator(func: F) -> F: +def start_timeout(timeout: float) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: @wraps(func) def wrapper(lt: ServiceLifetime) -> Awaitable[None]: lt.tg.start_soon(lt.view.cancel_on_shutdown(_observe_started), lt, timeout) diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py index 65a1cc1..0c217a2 100644 --- a/localpost/hosting/services/hypercorn.py +++ b/localpost/hosting/services/hypercorn.py @@ -11,10 +11,13 @@ def hypercorn_server(app, config: hypercorn.Config, /) -> ServiceF: def run(sl: ServiceLifetime) -> Awaitable[None]: # See https://hypercorn.readthedocs.io/en/latest/how_to_guides/api_usage.html + # Imports must stay inline — hypercorn ships separate ``trio`` and + # ``asyncio`` ``serve`` modules and we pick one based on the running + # event loop. A top-level import would force one backend at import time. if current_async_library() == "trio": - from hypercorn.trio import serve + from hypercorn.trio import serve # noqa: PLC0415 else: - from hypercorn.asyncio import serve # type: ignore[assignment] + from hypercorn.asyncio import serve # type: ignore[assignment] # noqa: PLC0415 observed_app = report_started(sl.started, app) return serve(observed_app, config, shutdown_trigger=sl.shutting_down.wait) diff --git a/localpost/hosting/services/uvicorn.py b/localpost/hosting/services/uvicorn.py index 38b9e85..a90f57e 100644 --- a/localpost/hosting/services/uvicorn.py +++ b/localpost/hosting/services/uvicorn.py @@ -24,8 +24,13 @@ async def lf_aware_main_loop(): await server_main_loop() sl.set_shutting_down() - server.main_loop = lf_aware_main_loop - server.capture_signals = nullcontext + # Monkey-patch the bound methods on this Server instance: the original + # ``main_loop`` is wrapped to fire ``set_started`` / ``set_shutting_down`` + # at the right moments, and ``capture_signals`` is replaced with a no-op + # CM so uvicorn doesn't install its own signal handlers (we want our + # ``shutdown_on_signal`` middleware to drive shutdown). + server.main_loop = lf_aware_main_loop # ty: ignore[invalid-assignment] + server.capture_signals = nullcontext # ty: ignore[invalid-assignment] async def observe_shutdown(trigger: AnyEventView): await trigger.wait() diff --git a/localpost/http/config.py b/localpost/http/config.py index 11d579a..b90092f 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -16,7 +16,7 @@ @final @dataclass(frozen=True, slots=True) class ServerConfig: - host: str = "0.0.0.0" + host: str = "0.0.0.0" # noqa: S104 — listen on all interfaces by default; explicit choice for a server lib port: int = 8000 backlog: int = 1024 """Maximum number of queued (in the kernel) connections.""" From 16a15b0b1aac68eff8b240c18b2bdbfb947ebf62 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 15:06:14 +0400 Subject: [PATCH 108/286] chore(deps): add vulture for dead-code detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a basic dead-code finder to the dev toolchain: * ``vulture ~=2.14`` in the ``dev-types`` group. * ``[tool.vulture]`` config in ``pyproject.toml`` — pinned to ``min_confidence = 80`` (60 is the default and dominated by false positives — public-API methods vulture can't see being used, dataclass fields exposed via reflection, decorated handlers, etc). Excludes the experimental sub-packages where dead code is expected while APIs are still being shaped. * ``ignore_names`` for the few 100%-confidence false positives that vulture catches but are real (overload-only signature parameters, protocol method parameters used in other implementations). * ``just deadcode`` recipe; pass extra args (e.g. ``just deadcode --min-confidence 60``) to override defaults. Currently zero findings against the stable packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 ++ justfile | 4 ++++ pyproject.toml | 22 ++++++++++++++++++++++ uv.lock | 11 +++++++++++ 4 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e97f8..131920e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 any `RequestHandler` so it runs on a worker thread. Compose explicitly with `http_server` when you need a worker pool; immediate handlers (including a `Router`'s 404/405 path) stay on the selector thread. +- `just deadcode` — vulture-based dead-code finder, configured in + `pyproject.toml` (`[tool.vulture]`). ### Changed diff --git a/justfile b/justfile index d4da41e..d3944e8 100755 --- a/justfile +++ b/justfile @@ -62,3 +62,7 @@ bench-micro *args: [doc("Inverse dependency tree for a package, to understand why it is installed")] why package: uv tree --invert --package {{ package }} + +[doc("Find unused code with vulture (config in pyproject.toml). Pass extra args to override.")] +deadcode *args: + vulture {{ args }} diff --git a/pyproject.toml b/pyproject.toml index af4337b..13793d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ dev-types = [ "types-protobuf ~=6.29", # Must be in sync with protobuf version, see above "types-grpcio", # Replaces grpc-stubs, see: https://github.com/python/typeshed/pull/11204 "types-boto3-lite[sqs] ~=1.38", + "vulture ~=2.14", ] examples = [ "fast-depends ~=3.0", @@ -169,6 +170,27 @@ bench = [ [tool.coverage.run] omit = ["tests/*"] +[tool.vulture] +# Dead-code finder. Run via ``just deadcode``. +# +# ``min_confidence`` is set high (80) so vulture stays a useful signal: the +# 60-confidence band is dominated by public API surface vulture can't see +# being used (decorated routes, hosting services, dataclass fields exposed +# through reflection, etc.). The whitelist below names the 80+ confidence +# false positives explicitly. +paths = ["localpost"] +exclude = ["localpost/consumers/", "localpost/openapi/"] +min_confidence = 80 +sort_by_size = true +ignore_names = [ + # Overload signature parameters — present for type-checker consumption only, + # implementation uses ``_=None``. + "ret_t", + # Bound-method protocol parameters that ARE used in other implementations + # of the same protocol; vulture sees only this file. + "this_task", +] + [[tool.mypy.overrides]] module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] follow_untyped_imports = true diff --git a/uv.lock b/uv.lock index b020e58..208611b 100644 --- a/uv.lock +++ b/uv.lock @@ -1253,6 +1253,7 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, { name = "types-protobuf" }, + { name = "vulture" }, ] examples = [ { name = "fast-depends" }, @@ -1339,6 +1340,7 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, { name = "types-protobuf", specifier = "~=6.29" }, + { name = "vulture", specifier = "~=2.14" }, ] examples = [ { name = "fast-depends", specifier = "~=3.0" }, @@ -2430,6 +2432,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8" From 3c90dd8f62be3a9104be24f19af1dcb862582a9a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 15:07:02 +0400 Subject: [PATCH 109/286] refactor: move consumers and openapi to localpost.experimental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ``localpost.consumers`` and ``localpost.openapi`` were marked "experimental" only via README notes, which was easy to miss. Move them to ``localpost.experimental.consumers`` and ``localpost.experimental.openapi`` so the import path itself is the stability marker: * Every ``from localpost.experimental. import …`` reads as "I'm using an unstable API" — visible in every call site, not just the package README. * Once a sub-package stabilises we'll move it back up to ``localpost.`` (a deliberate, single-step decision). * APIs and module contents are unchanged — this is a pure relocation plus the dotted-name updates it forces. Updated: * ``localpost/{consumers,openapi}/`` → ``localpost/experimental/{consumers,openapi}/`` (renames preserved by git, history follows). * Internal cross-imports inside both packages. * Examples (``examples/consumers/sqs/{app,app_lambda}.py``). * Tests: ``tests/consumers/`` → ``tests/experimental/consumers/`` plus a new ``tests/experimental/__init__.py``. * Top-level docs: ``README.md`` (module table), ``CLAUDE.md`` (architecture tree + module-deep-dive links), ``CHANGELOG.md``, and the relative-path links inside the moved READMEs. * ``[tool.vulture]`` exclude — single ``localpost/experimental/`` entry replacing the two old paths. Tests: 233/233 unit tests still pass across hosting, http, di, scheduler, threadtools, and utils. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 +++ CLAUDE.md | 45 ++++++++++--------- README.md | 21 ++++----- examples/consumers/sqs/app.py | 4 +- examples/consumers/sqs/app_lambda.py | 2 +- localpost/experimental/__init__.py | 14 ++++++ .../{ => experimental}/consumers/README.md | 20 ++++----- .../{ => experimental}/consumers/__init__.py | 0 .../{ => experimental}/consumers/_utils.py | 0 .../{ => experimental}/consumers/channel.py | 2 +- .../{ => experimental}/consumers/pubsub.py | 0 .../consumers/stdlib_queue.py | 2 +- .../{ => experimental}/consumers/stream.py | 2 +- .../{ => experimental}/openapi/DESIGN.md | 0 .../{ => experimental}/openapi/README.md | 22 ++++----- .../{ => experimental}/openapi/__init__.py | 0 localpost/{ => experimental}/openapi/_docs.py | 0 localpost/{ => experimental}/openapi/app.py | 4 +- .../{ => experimental}/openapi/converters.py | 0 .../{ => experimental}/openapi/msgspec.py | 0 .../{ => experimental}/openapi/pydantic.py | 0 localpost/{ => experimental}/openapi/spec.py | 0 localpost/{ => experimental}/openapi/sse.py | 0 localpost/http/README.md | 6 +-- pyproject.toml | 2 +- tests/{consumers => experimental}/__init__.py | 0 tests/experimental/consumers/__init__.py | 0 tests/{ => experimental}/consumers/stream.py | 2 +- 28 files changed, 87 insertions(+), 66 deletions(-) create mode 100644 localpost/experimental/__init__.py rename localpost/{ => experimental}/consumers/README.md (85%) rename localpost/{ => experimental}/consumers/__init__.py (100%) rename localpost/{ => experimental}/consumers/_utils.py (100%) rename localpost/{ => experimental}/consumers/channel.py (97%) rename localpost/{ => experimental}/consumers/pubsub.py (100%) rename localpost/{ => experimental}/consumers/stdlib_queue.py (97%) rename localpost/{ => experimental}/consumers/stream.py (94%) rename localpost/{ => experimental}/openapi/DESIGN.md (100%) rename localpost/{ => experimental}/openapi/README.md (88%) rename localpost/{ => experimental}/openapi/__init__.py (100%) rename localpost/{ => experimental}/openapi/_docs.py (100%) rename localpost/{ => experimental}/openapi/app.py (99%) rename localpost/{ => experimental}/openapi/converters.py (100%) rename localpost/{ => experimental}/openapi/msgspec.py (100%) rename localpost/{ => experimental}/openapi/pydantic.py (100%) rename localpost/{ => experimental}/openapi/spec.py (100%) rename localpost/{ => experimental}/openapi/sse.py (100%) rename tests/{consumers => experimental}/__init__.py (100%) create mode 100644 tests/experimental/consumers/__init__.py rename tests/{ => experimental}/consumers/stream.py (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 131920e..d46f869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 drop their `max_concurrency` kwarg for the same reason — wrap with `thread_pool_handler` if you need a pool (typical for blocking WSGI / Flask apps). +- **Experimental sub-packages moved.** `localpost.consumers` → + `localpost.experimental.consumers`; `localpost.openapi` → + `localpost.experimental.openapi`. The `experimental` segment in every + import path is the new stability marker — README notes alone were too + easy to miss. APIs themselves are unchanged. ### Removed diff --git a/CLAUDE.md b/CLAUDE.md index 6e927f7..4694419 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,14 +11,15 @@ processes. Five pillars: - **scheduler** *(stable)* — in-process, composable task scheduler. - **http** *(stable)* — lightweight sync HTTP/1.1 server. - **di** *(stable)* — small `.NET`-style IoC container with scopes. -- **consumers** *(experimental)* — message broker consumers (channel, stream, queue, Pub/Sub; more planned). -- **openapi** *(experimental)* — type-driven OpenAPI layer on top of `http`. +- **experimental.consumers** *(experimental)* — message broker consumers (channel, stream, queue, Pub/Sub; more planned). +- **experimental.openapi** *(experimental)* — type-driven OpenAPI layer on top of `http`. Built on AnyIO (runs on asyncio or Trio). **Stability note:** "stable" modules have settled public APIs — avoid -breaking changes unless explicitly asked. "Experimental" modules are usable -but their shape is still evolving; feel free to refactor them, and flag +breaking changes unless explicitly asked. Experimental modules live under +``localpost.experimental.`` so the import path itself flags them; they're +usable but their shape is still evolving — refactor freely, and flag breaking changes in the CHANGELOG. ## Development commands @@ -68,13 +69,6 @@ localpost/ │ ├── _trigger.py # Trigger decorators (WIP) │ └── cond/cron.py # cron(...) trigger — needs [cron] extra │ -├── consumers/ # Message broker consumers -│ ├── channel.py # in-memory channel -│ ├── stream.py # AnyIO ObjectReceiveStream -│ ├── stdlib_queue.py # queue.Queue / SimpleQueue -│ ├── pubsub.py # Google Cloud Pub/Sub (in-progress; imports are broken) -│ └── _utils.py # SyncHandler / AsyncHandler / AnyHandler types -│ ├── di/ # IoC container │ ├── _services.py # ServiceRegistry, ServiceProvider, AppContext │ ├── flask.py # Flask integration (RequestContext per request) @@ -87,15 +81,22 @@ localpost/ │ ├── config.py # ServerConfig │ └── _service.py # @hosting.service wrappers (http_server, wsgi_server) │ -└── openapi/ # Type-driven OpenAPI framework - ├── app.py # HttpApp, FromPath/Query/Header/Body, OpResult hierarchy - ├── spec.py # OpenAPI spec dataclasses - ├── converters.py - ├── pydantic.py # Pydantic body/result converters - ├── msgspec.py # msgspec converters (stub) - ├── sse.py # Server-Sent Events - ├── _docs.py # Swagger UI / ReDoc / Scalar HTML templates - └── DESIGN.md # Deeper design notes +└── experimental/ # Unstable APIs — wrapped in their own subpackage as a marker + ├── consumers/ # Message broker consumers + │ ├── channel.py # in-memory channel + │ ├── stream.py # AnyIO ObjectReceiveStream + │ ├── stdlib_queue.py # queue.Queue / SimpleQueue + │ ├── pubsub.py # Google Cloud Pub/Sub (in-progress; imports are broken) + │ └── _utils.py # SyncHandler / AsyncHandler / AnyHandler types + └── openapi/ # Type-driven OpenAPI framework + ├── app.py # HttpApp, FromPath/Query/Header/Body, OpResult hierarchy + ├── spec.py # OpenAPI spec dataclasses + ├── converters.py + ├── pydantic.py # Pydantic body/result converters + ├── msgspec.py # msgspec converters (stub) + ├── sse.py # Server-Sent Events + ├── _docs.py # Swagger UI / ReDoc / Scalar HTML templates + └── DESIGN.md # Deeper design notes ``` Files prefixed with `_` are internal; public API is re-exported from each @@ -137,7 +138,7 @@ For more detail on each module, see: @localpost/hosting/README.md @localpost/scheduler/README.md -@localpost/consumers/README.md @localpost/di/README.md @localpost/http/README.md -@localpost/openapi/README.md +@localpost/experimental/consumers/README.md +@localpost/experimental/openapi/README.md diff --git a/README.md b/README.md index c2cad45..3bb6bb2 100644 --- a/README.md +++ b/README.md @@ -75,18 +75,19 @@ parallel, and exits cleanly when they all stop. ## Modules -| Module | Status | Purpose | -| ----------------------------------- | -------------- | ------------------------------------------------------- | -| [`hosting`](localpost/hosting/) | stable | Service lifecycle, signals, middleware, ASGI/gRPC adapters | -| [`scheduler`](localpost/scheduler/) | stable | Composable in-process task scheduler | -| [`di`](localpost/di/) | stable | Scoped IoC container | -| [`http`](localpost/http/) | stable | Small h11-based HTTP/1.1 server | -| [`consumers`](localpost/consumers/) | experimental | Message broker consumer services | -| [`openapi`](localpost/openapi/) | experimental | Type-driven OpenAPI framework | +| Module | Status | Purpose | +| ------------------------------------------------------------------------------ | -------------- | ------------------------------------------------------- | +| [`hosting`](localpost/hosting/) | stable | Service lifecycle, signals, middleware, ASGI/gRPC adapters | +| [`scheduler`](localpost/scheduler/) | stable | Composable in-process task scheduler | +| [`di`](localpost/di/) | stable | Scoped IoC container | +| [`http`](localpost/http/) | stable | Small h11-based HTTP/1.1 server | +| [`experimental.consumers`](localpost/experimental/consumers/) | experimental | Message broker consumer services | +| [`experimental.openapi`](localpost/experimental/openapi/) | experimental | Type-driven OpenAPI framework | "Stable" means the public API is not expected to break in a patch or minor -release. "Experimental" means the module is usable and tested, but the API -surface is still being shaped — breaking changes may land before `1.0`. +release. Experimental modules live under `localpost.experimental.`; +the import path itself is the marker — the API is still being shaped and +breaking changes may land before `1.0`. Each subdirectory has its own README with a quickstart, key concepts, and extension points. diff --git a/examples/consumers/sqs/app.py b/examples/consumers/sqs/app.py index 2c9a688..b2ad340 100755 --- a/examples/consumers/sqs/app.py +++ b/examples/consumers/sqs/app.py @@ -3,8 +3,8 @@ import os from localpost import flow -from localpost.consumers import sqs_otel -from localpost.consumers.sqs import SqsMessage, sqs_queue_consumer +from localpost.experimental.consumers import sqs_otel +from localpost.experimental.consumers.sqs import SqsMessage, sqs_queue_consumer @sqs_queue_consumer("weather-forecasts") diff --git a/examples/consumers/sqs/app_lambda.py b/examples/consumers/sqs/app_lambda.py index a0ab795..e6f91d2 100755 --- a/examples/consumers/sqs/app_lambda.py +++ b/examples/consumers/sqs/app_lambda.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from localpost.consumers.sqs import lambda_handler, sqs_queue_consumer +from localpost.experimental.consumers.sqs import lambda_handler, sqs_queue_consumer @sqs_queue_consumer("weather-forecasts") diff --git a/localpost/experimental/__init__.py b/localpost/experimental/__init__.py new file mode 100644 index 0000000..73b2430 --- /dev/null +++ b/localpost/experimental/__init__.py @@ -0,0 +1,14 @@ +"""Experimental sub-packages. + +Modules under ``localpost.experimental`` have **unstable public APIs** — +they're usable but their shape is still evolving. Expect breaking changes +in patch / minor releases. The ``experimental`` segment in every import +path is the marker; once a sub-package stabilises we'll move it back up +to ``localpost.``. + +See also: + +* ``localpost.experimental.consumers`` — message-broker consumers. +* ``localpost.experimental.openapi`` — type-driven OpenAPI on top of + ``localpost.http``. +""" diff --git a/localpost/consumers/README.md b/localpost/experimental/consumers/README.md similarity index 85% rename from localpost/consumers/README.md rename to localpost/experimental/consumers/README.md index e73a6b6..e520516 100644 --- a/localpost/consumers/README.md +++ b/localpost/experimental/consumers/README.md @@ -1,6 +1,6 @@ -# localpost.consumers +# localpost.experimental.consumers -> **Status:** experimental — API surface is still being shaped; expect breaking changes before `1.0`. +> **Status:** experimental — lives under ``localpost.experimental.`` so the import path itself is the marker; expect breaking changes before `1.0`. Consumer services for message sources. A consumer is a small `@hosting.service` wrapper that reads from a source (in-memory channel, AnyIO stream, `queue.Queue`, @@ -31,14 +31,14 @@ Shipped adapters: being reworked — treat as in-progress) Adapters for SQS, Kafka, NATS and Azure exist as extras in `pyproject.toml` but -the integrations currently live in [`examples/consumers/`](../../examples/consumers/). -They will move into `localpost.consumers` as they stabilise. +the integrations currently live in [`examples/consumers/`](../../../examples/consumers/). +They will move into `localpost.experimental.consumers` as they stabilise. ## Quick start ```python import anyio -from localpost.consumers.stream import stream_consumer +from localpost.experimental.consumers.stream import stream_consumer from localpost.hosting import run_app @@ -67,7 +67,7 @@ if __name__ == "__main__": ``` For a full consumer-as-service example, see -[`examples/host/channel.py`](../../examples/host/channel.py). +[`examples/host/channel.py`](../../../examples/host/channel.py). ## Key concepts @@ -103,7 +103,7 @@ has started. Pattern from `stream.py`: ```python from anyio import Semaphore, create_task_group from localpost import hosting -from localpost.consumers._utils import AnyHandler, ensure_async_handler +from localpost.experimental.consumers._utils import AnyHandler, ensure_async_handler @hosting.service @@ -133,6 +133,6 @@ Accept a source, a handler, and at least `max_concurrency`. Honour ## See also -- Examples: [`examples/consumers/`](../../examples/consumers/) (SQS, Kafka stubs) -- Channel + host: [`examples/host/channel.py`](../../examples/host/channel.py) -- Sync primitives: [`../threadtools.py`](../threadtools.py) +- Examples: [`examples/consumers/`](../../../examples/consumers/) (SQS, Kafka stubs) +- Channel + host: [`examples/host/channel.py`](../../../examples/host/channel.py) +- Sync primitives: [`../../threadtools.py`](../../threadtools.py) diff --git a/localpost/consumers/__init__.py b/localpost/experimental/consumers/__init__.py similarity index 100% rename from localpost/consumers/__init__.py rename to localpost/experimental/consumers/__init__.py diff --git a/localpost/consumers/_utils.py b/localpost/experimental/consumers/_utils.py similarity index 100% rename from localpost/consumers/_utils.py rename to localpost/experimental/consumers/_utils.py diff --git a/localpost/consumers/channel.py b/localpost/experimental/consumers/channel.py similarity index 97% rename from localpost/consumers/channel.py rename to localpost/experimental/consumers/channel.py index ccc30c3..62fe50f 100644 --- a/localpost/consumers/channel.py +++ b/localpost/experimental/consumers/channel.py @@ -5,7 +5,7 @@ from localpost import hosting, threadtools from localpost._utils import is_async_callable -from localpost.consumers._utils import AnyHandler +from localpost.experimental.consumers._utils import AnyHandler from localpost.threadtools import Channel, ReceiveChannel __all__ = ["channel_consumer"] diff --git a/localpost/consumers/pubsub.py b/localpost/experimental/consumers/pubsub.py similarity index 100% rename from localpost/consumers/pubsub.py rename to localpost/experimental/consumers/pubsub.py diff --git a/localpost/consumers/stdlib_queue.py b/localpost/experimental/consumers/stdlib_queue.py similarity index 97% rename from localpost/consumers/stdlib_queue.py rename to localpost/experimental/consumers/stdlib_queue.py index b5f84e2..b2ec733 100644 --- a/localpost/consumers/stdlib_queue.py +++ b/localpost/experimental/consumers/stdlib_queue.py @@ -9,7 +9,7 @@ from localpost import threadtools from localpost._utils import is_async_callable -from localpost.consumers._utils import AnyHandler +from localpost.experimental.consumers._utils import AnyHandler if sys.version_info >= (3, 13): from queue import ShutDown diff --git a/localpost/consumers/stream.py b/localpost/experimental/consumers/stream.py similarity index 94% rename from localpost/consumers/stream.py rename to localpost/experimental/consumers/stream.py index 935ea0e..fa171b5 100644 --- a/localpost/consumers/stream.py +++ b/localpost/experimental/consumers/stream.py @@ -7,7 +7,7 @@ from localpost import hosting from localpost._utils import NullSemaphore, ensure_int_or_inf -from localpost.consumers._utils import AnyHandler, ensure_async_handler +from localpost.experimental.consumers._utils import AnyHandler, ensure_async_handler __all__ = ["stream_consumer"] diff --git a/localpost/openapi/DESIGN.md b/localpost/experimental/openapi/DESIGN.md similarity index 100% rename from localpost/openapi/DESIGN.md rename to localpost/experimental/openapi/DESIGN.md diff --git a/localpost/openapi/README.md b/localpost/experimental/openapi/README.md similarity index 88% rename from localpost/openapi/README.md rename to localpost/experimental/openapi/README.md index 6d5923a..db6a5ef 100644 --- a/localpost/openapi/README.md +++ b/localpost/experimental/openapi/README.md @@ -1,8 +1,8 @@ -# localpost.openapi +# localpost.experimental.openapi -> **Status:** experimental — API surface is still being shaped; expect breaking changes before `1.0`. +> **Status:** experimental — lives under ``localpost.experimental.`` so the import path itself is the marker; expect breaking changes before `1.0`. -A type-driven OpenAPI 3.0 framework sitting on top of [`localpost.http`](../http/README.md). +A type-driven OpenAPI 3.0 framework sitting on top of [`localpost.http`](../../http/README.md). Define operations as ordinary Python functions. Types, annotations, and return types are inspected to build the OpenAPI doc and to wire path / query / header / @@ -28,7 +28,7 @@ from dataclasses import dataclass from typing import Annotated from wsgiref.simple_server import make_server -from localpost.openapi.app import BadRequest, FromPath, HttpApp, NotFound +from localpost.experimental.openapi.app import BadRequest, FromPath, HttpApp, NotFound @dataclass @@ -67,7 +67,7 @@ if __name__ == "__main__": server.serve_forever() ``` -Full example: [`examples/openapi/app.py`](../../examples/openapi/app.py). +Full example: [`examples/openapi/app.py`](../../../examples/openapi/app.py). ## Key concepts @@ -75,7 +75,7 @@ Full example: [`examples/openapi/app.py`](../../examples/openapi/app.py). registered operations. Exposes `.wsgi` for any WSGI host (including `localpost.http.wsgi_server`). - **Operations** — Python functions registered with `@app.get(path)`, - `.post`, etc. The path follows [`URITemplate`](../http/README.md) syntax + `.post`, etc. The path follows [`URITemplate`](../../http/README.md) syntax (`/books/{id}`). - **Arg resolvers** — one per parameter, picked from the annotation. Factories: - `FromPath()` — path variable (auto-detected for params whose name matches a @@ -97,7 +97,7 @@ Full example: [`examples/openapi/app.py`](../../examples/openapi/app.py). ## Public API -From `localpost.openapi.app`: +From `localpost.experimental.openapi.app`: | Symbol | Purpose | | ------------------- | --------------------------------------------------- | @@ -110,7 +110,7 @@ From `localpost.openapi.app`: | `BadRequest[T]`, `Unauthorized[T]`, `NotFound[T]`, `TooManyRequests[T]` | `OpResult` subclasses | | `OpFilter` (Protocol) | Per-operation pre-filter (e.g. `limit_requests`) | -From `localpost.openapi.spec`: +From `localpost.experimental.openapi.spec`: | Symbol | Notes | | -------------------- | --------------------------------------------------- | @@ -131,7 +131,7 @@ From `localpost.openapi.spec`: ## Writing a custom arg resolver ```python -from localpost.openapi.app import ArgResolverFactory +from localpost.experimental.openapi.app import ArgResolverFactory from localpost.http.router import RequestCtx class FromCookie(ArgResolverFactory): @@ -156,6 +156,6 @@ def me(session: Annotated[str, FromCookie("session")]) -> str: ## See also -- Examples: [`examples/openapi/`](../../examples/openapi/) +- Examples: [`examples/openapi/`](../../../examples/openapi/) - Design notes: [`DESIGN.md`](DESIGN.md) -- Underlying server: [`../http/README.md`](../http/README.md) +- Underlying server: [`../../http/README.md`](../../http/README.md) diff --git a/localpost/openapi/__init__.py b/localpost/experimental/openapi/__init__.py similarity index 100% rename from localpost/openapi/__init__.py rename to localpost/experimental/openapi/__init__.py diff --git a/localpost/openapi/_docs.py b/localpost/experimental/openapi/_docs.py similarity index 100% rename from localpost/openapi/_docs.py rename to localpost/experimental/openapi/_docs.py diff --git a/localpost/openapi/app.py b/localpost/experimental/openapi/app.py similarity index 99% rename from localpost/openapi/app.py rename to localpost/experimental/openapi/app.py index 9ed4eaa..e75999c 100644 --- a/localpost/openapi/app.py +++ b/localpost/experimental/openapi/app.py @@ -11,9 +11,9 @@ import msgspec -import localpost.openapi.spec as openapi_spec +import localpost.experimental.openapi.spec as openapi_spec from localpost.http.router import RequestCtx, RequestHandler, Response, Router, URITemplate -from localpost.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML +from localpost.experimental.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML P = ParamSpec("P") R = TypeVar("R") diff --git a/localpost/openapi/converters.py b/localpost/experimental/openapi/converters.py similarity index 100% rename from localpost/openapi/converters.py rename to localpost/experimental/openapi/converters.py diff --git a/localpost/openapi/msgspec.py b/localpost/experimental/openapi/msgspec.py similarity index 100% rename from localpost/openapi/msgspec.py rename to localpost/experimental/openapi/msgspec.py diff --git a/localpost/openapi/pydantic.py b/localpost/experimental/openapi/pydantic.py similarity index 100% rename from localpost/openapi/pydantic.py rename to localpost/experimental/openapi/pydantic.py diff --git a/localpost/openapi/spec.py b/localpost/experimental/openapi/spec.py similarity index 100% rename from localpost/openapi/spec.py rename to localpost/experimental/openapi/spec.py diff --git a/localpost/openapi/sse.py b/localpost/experimental/openapi/sse.py similarity index 100% rename from localpost/openapi/sse.py rename to localpost/experimental/openapi/sse.py diff --git a/localpost/http/README.md b/localpost/http/README.md index 5091889..fea9d93 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -8,7 +8,7 @@ server core is ~540 lines of focused, sync code — easy to read, easy to embed. Pair it with `localpost.hosting` for lifecycle management, or run it standalone. For OpenAPI / content negotiation / validation, see -[`localpost.openapi`](../openapi/README.md). +[`localpost.experimental.openapi`](../experimental/openapi/README.md). ## Install @@ -309,7 +309,7 @@ for the analysis behind the current bench numbers. - **Flask** — Flask is web-first (templates, Jinja, sessions) with no built-in OpenAPI support. `localpost.http` is a low-level server; pair it with - `localpost.openapi` for type-driven OpenAPI. + `localpost.experimental.openapi` for type-driven OpenAPI. - **FastAPI** — FastAPI is async, Pydantic-only, OpenAPI-only, and ships a dependency-injection system. `localpost.http` is sync, has no opinions on serialization, and is small enough to read in one sitting. @@ -317,6 +317,6 @@ for the analysis behind the current bench numbers. ## See also - Examples: [`examples/http/`](../../examples/http/) -- OpenAPI on top: [`../openapi/README.md`](../openapi/README.md) +- OpenAPI on top: [`../experimental/openapi/README.md`](../experimental/openapi/README.md) - Server source: [`server.py`](server.py) - Router source: [`router.py`](router.py) diff --git a/pyproject.toml b/pyproject.toml index 13793d8..cc21704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,7 +179,7 @@ omit = ["tests/*"] # through reflection, etc.). The whitelist below names the 80+ confidence # false positives explicitly. paths = ["localpost"] -exclude = ["localpost/consumers/", "localpost/openapi/"] +exclude = ["localpost/experimental/"] min_confidence = 80 sort_by_size = true ignore_names = [ diff --git a/tests/consumers/__init__.py b/tests/experimental/__init__.py similarity index 100% rename from tests/consumers/__init__.py rename to tests/experimental/__init__.py diff --git a/tests/experimental/consumers/__init__.py b/tests/experimental/consumers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/consumers/stream.py b/tests/experimental/consumers/stream.py similarity index 91% rename from tests/consumers/stream.py rename to tests/experimental/consumers/stream.py index 34086a2..4e9a4c1 100644 --- a/tests/consumers/stream.py +++ b/tests/experimental/consumers/stream.py @@ -1,7 +1,7 @@ import pytest from anyio import create_memory_object_stream, sleep -from localpost.consumers.stream import stream_consumer +from localpost.experimental.consumers.stream import stream_consumer from localpost.hosting import serve pytestmark = [pytest.mark.anyio] From 70c866f0bd7d68328b02a1bd8a86d1b9ca84627e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 16:35:02 +0400 Subject: [PATCH 110/286] chore: code-quality follow-ups (dead code, PEP 695, ruff/ty) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cleanups against the stable packages and shared infra. No public-API change beyond the dead-code removal (all of which had zero callers verified by ``vulture --min-confidence 80`` plus ``rg``). **Removed (dead code in shared infra):** * ``localpost/_utils.py``: ``NO_OP_TS``, ``_IgnoredTaskStatus``, ``AsyncContextManagerAdapter``, ``Switch``, plus the unused ``MemorySendStream.send_or_drop_from_thread`` / ``send_or_drop`` methods (the subclass had no other content; ``MemoryStream.create()`` now returns the bare anyio stream pair). * ``localpost/hosting/_host.py``: the ``_serve_and_observe`` async-CM helper — no callers anywhere in ``localpost/`` or tests. **PEP 695 sweep (project minimum is Python 3.12):** * ``localpost/_utils.py`` — ``Result``, ``MemoryStream``, all type aliases, and the generic functions converted to inline ``[T, ...]`` syntax. ``TypeVar`` / ``TypeAlias`` imports gone. * ``localpost/scheduler/_cond.py`` and ``_trigger.py`` — same. The ``# noqa: UP046`` / ``UP047`` markers from the earlier scheduler restoration are gone. * ``localpost/hosting/_host.py`` — ``cancel_on_shutdown`` / ``cancel_on_stop`` overloads converted to ``[F: Callable[..., Any]]``. * ``localpost/scheduler/_scheduler.py`` — kept module-level TypeVars plus ``Generic[T, R]`` on ``Task`` (with a documenting ``# noqa: UP046``). ty does not yet reconcile PEP 695 class type parameters with the same-named module TypeVars inside nested generic functions (``scheduled_task → _decorator``); fully converting this module would silently rebind ``R`` as a fresh implicit parameter. **Ruff/ty cleanup:** * ``localpost/__init__.py`` — tightened the bare ``except Exception:`` around ``importlib.metadata.version()`` to ``PackageNotFoundError``. * ``localpost/_utils.py`` — replaced shotgun ``# type: ignore``s with targeted ``cast(...)``s for ``ensure_sync_callable``, ``ensure_td``, ``ensure_delay_factory``, ``_cancel_when``. * ``localpost/threadtools.py`` — documented the deliberate private-attribute mirroring on ``CancellableLock`` (matches stdlib ``Condition``'s duck-typing); converted ``# pyright: ignore[...]`` comments to plain ``# type: ignore`` (which ty honours); replaced ambiguous EN-DASH characters in docstring comments (RUF003). * ``pyproject.toml`` — dropped the stale ``Programming Language :: Python :: 3.11`` classifier and the pre-3.10/3.11 TODOs next to ruff ``ignore`` entries. End state across stable packages + shared infra (``localpost/__init__.py``, ``_utils.py``, ``threadtools.py``): * ``vulture`` (default config): 0 findings. * ``ruff check``: 0 errors. (Remaining 23 errors live entirely in ``localpost/experimental/``, which is excluded from this pass.) * ``ty check``: 0 errors. * ``pytest``: 233/233 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 ++++++ localpost/__init__.py | 4 +- localpost/_utils.py | 114 ++++++++---------------------- localpost/hosting/_host.py | 22 ++---- localpost/scheduler/_cond.py | 13 ++-- localpost/scheduler/_scheduler.py | 14 ++-- localpost/scheduler/_trigger.py | 15 ++-- localpost/threadtools.py | 37 ++++++---- pyproject.toml | 6 +- 9 files changed, 108 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d46f869..6d64f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `localpost.experimental.openapi`. The `experimental` segment in every import path is the new stability marker — README notes alone were too easy to miss. APIs themselves are unchanged. +- Modernised typing throughout `localpost/_utils.py`, + `localpost/scheduler/`, and `localpost/hosting/_host.py` to PEP 695 + (`class Foo[T]`, `type Foo = ...`, inline function type parameters). + No public-API change — the existing module-level `TypeVar` declarations + in `localpost/scheduler/_scheduler.py` are kept until ty learns to + reconcile PEP 695 class type parameters with same-named TypeVars + inside nested generic functions. +- Dropped the `Programming Language :: Python :: 3.11` classifier + (the project's `requires-python = ">=3.12"` since 0.6). +- Cleaner ruff/ty footprint across stable packages and shared infra + (`localpost/__init__.py`, `_utils.py`, `threadtools.py`): 0 errors + from either tool. Remaining warnings live entirely in + `localpost/experimental/`. ### Removed +- Internal helpers that were unused everywhere: `localpost._utils.NO_OP_TS`, + `AsyncContextManagerAdapter`, `Switch`, the `send_or_drop_from_thread` / + `send_or_drop` methods on the now-trivial `MemorySendStream` (so + ``MemoryStream.create()`` simply returns the bare anyio + ``MemoryObjectSendStream`` / ``MemoryObjectReceiveStream`` pair), and + `localpost.hosting._host._serve_and_observe`. + ## [0.6.0] - 2026-02-22 Complete rewrite of the hosting system, to simplify it and make it more robust. diff --git a/localpost/__init__.py b/localpost/__init__.py index 3c80867..05898b3 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -1,12 +1,12 @@ import logging -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError, version from ._debug import debug from ._utils import Result try: __version__ = version("localpost") -except Exception: +except PackageNotFoundError: __version__ = "dev" diff --git a/localpost/_utils.py b/localpost/_utils.py index 7bf406a..cfab388 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -16,12 +16,9 @@ from typing import ( Any, Final, - Generic, NotRequired, - ParamSpec, Protocol, Self, - TypeAlias, TypedDict, TypeGuard, cast, @@ -33,20 +30,13 @@ from anyio import ( CancelScope, CapacityLimiter, - WouldBlock, create_memory_object_stream, create_task_group, from_thread, to_thread, ) -from anyio.abc import TaskGroup, TaskStatus -from anyio.lowlevel import checkpoint +from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from typing_extensions import TypeVar - -T = TypeVar("T", default=Any) -P = ParamSpec("P") -R = TypeVar("R") # Sentinel object, to indicate that a value is not set (see https://python-patterns.guide/python/sentinel-object) NOT_SET: Final = object() @@ -62,18 +52,10 @@ HANDLED_SIGNALS += (signal.SIGBREAK,) # pyright: ignore[reportConstantRedefinition] -class _IgnoredTaskStatus(TaskStatus[Any]): - def started(self, value: Any = None) -> None: - pass - - -NO_OP_TS: Final = _IgnoredTaskStatus() - - # PyCharm has a bug when calling a TypeVarTuple-parameterized function with 0 arguments, # see https://youtrack.jetbrains.com/issue/PY-63820 def start_task_soon(tg: TaskGroup, func: Callable[[], Awaitable[Any]], name: object = None) -> None: - tg.start_soon(func, name=name) # type: ignore + tg.start_soon(func, name=name) def unwrap_exc(exc: BaseException) -> BaseException: @@ -82,18 +64,6 @@ def unwrap_exc(exc: BaseException) -> BaseException: return exc -@dc.dataclass(frozen=True, slots=True) -class AsyncContextManagerAdapter(Generic[T]): - source: AbstractContextManager[T] - limiter: CapacityLimiter = dc.field(default_factory=lambda: CapacityLimiter(1)) - - def __aenter__(self) -> Awaitable[T]: - return to_thread.run_sync(self.source.__enter__, limiter=self.limiter) - - def __aexit__(self, exc_type, exc_value, traceback): - return to_thread.run_sync(self.source.__exit__, exc_type, exc_value, traceback, limiter=self.limiter) - - class _SupportsClose(Protocol): def close(self) -> Any: ... @@ -144,7 +114,7 @@ def ensure_int_or_inf(value: int | float, *, min_value: int = 0, name: str = "Va @final # Actually immutable, but frozen=True has a noticeable performance impact @dc.dataclass(slots=True, eq=True, unsafe_hash=True) -class Result(Generic[T]): +class Result[T]: value: T # | NOT_SET error: BaseException # | None @@ -176,11 +146,13 @@ def get_callable_return_type(func: Callable[..., Any], /) -> type[Any]: @overload -def is_async_callable(obj: Callable[P, Any], /) -> TypeGuard[Callable[P, Awaitable[Any]]]: ... +def is_async_callable[**P](obj: Callable[P, Any], /) -> TypeGuard[Callable[P, Awaitable[Any]]]: ... @overload -def is_async_callable(obj: Callable[P, Any], ret_t: type[R], /) -> TypeGuard[Callable[P, Awaitable[R]]]: ... +def is_async_callable[**P, R]( + obj: Callable[P, Any], ret_t: type[R], / +) -> TypeGuard[Callable[P, Awaitable[R]]]: ... # See also: https://docs.python.org/3/library/inspect.html#inspect.markcoroutinefunction @@ -191,12 +163,12 @@ def is_async_callable(obj: Callable[..., Any] | object, _=None, /) -> TypeGuard[ return False return ( inspect.iscoroutinefunction(obj) - or inspect.iscoroutinefunction(obj.__call__) # type: ignore + or inspect.iscoroutinefunction(obj.__call__) or issubclass(get_callable_return_type(obj), Awaitable) ) -def ensure_async_callable( +def ensure_async_callable[**P, T]( func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], /, *, @@ -208,12 +180,12 @@ def ensure_async_callable( return functools.partial(to_thread.run_sync, func, limiter=limiter) # type: ignore[return-value] -def ensure_sync_callable( +def ensure_sync_callable[**P, T]( func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], / ) -> Callable[P, T]: if is_async_callable(func): return functools.partial(from_thread.run, func) - return func # type: ignore[return-value] + return cast("Callable[P, T]", func) def def_full_name(func: Any, /) -> str: @@ -238,10 +210,12 @@ def ensure_td(value: timedelta | str, /) -> timedelta: try: # Make sure to get timedelta, not relativedelta from dateutil pytimeparse2.HAS_RELITIVE_TIMEDELTA = False - result = pytimeparse2.parse(value, as_timedelta=True) + # ``as_timedelta=True`` makes ``parse`` return ``timedelta`` + # exclusively, but the stubs still expose ``int | float | timedelta``. + result = cast("timedelta | None", pytimeparse2.parse(value, as_timedelta=True)) if result is None: raise ValueError(f"Invalid time period: {value!r}") - return result # type: ignore[return-value] + return result finally: pytimeparse2.HAS_RELITIVE_TIMEDELTA = use_dateutil except ImportError: @@ -261,7 +235,7 @@ def td_str(td: timedelta, /) -> str: # TODO Rename to DurationFactory -DelayFactory: TypeAlias = Callable[[], timedelta] | tuple[int | float, int | float] | int | float | timedelta | None +type DelayFactory = Callable[[], timedelta] | tuple[int | float, int | float] | int | float | timedelta | None @final @@ -306,25 +280,11 @@ def __call__(self) -> timedelta: # TODO Rename to ensure_duration_factory() def ensure_delay_factory(delay: DelayFactory, /) -> Callable[[], timedelta]: if isinstance(delay, tuple): # tuple[int, int] | tuple[float, float] - return RandomDelay(delay) # type: ignore - elif callable(delay): - return delay - else: # int | float | timedelta | None - return FixedDelay.create(delay) - - -@dc.dataclass(eq=False, slots=True, init=False) -class Switch: - value: bool = False - - def __bool__(self) -> bool: - return self.value - - def __enter__(self) -> None: - self.value = True - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.value = False + return RandomDelay(cast("tuple[int, int] | tuple[float, float]", delay)) + if callable(delay): + return cast("Callable[[], timedelta]", delay) + # int | float | timedelta | None + return FixedDelay.create(cast("int | float | timedelta | None", delay)) # sleep(0) is used to return control to the event loop (in both Trio and AsyncIO) @@ -333,27 +293,12 @@ def sleep(i: timedelta | int | float | None, /) -> Coroutine[Any, Any, None]: return anyio.sleep(interval_sec) -@final -@dc.dataclass(eq=False) -class MemorySendStream(Generic[T], MemoryObjectSendStream[T]): - def send_or_drop_from_thread(self, item: T) -> None: - from_thread.run(self.send_or_drop, item) - - async def send_or_drop(self, item: T) -> None: - await checkpoint() - try: - self.send_nowait(item) - except WouldBlock: - pass - - -# TODO Remove -# For better typing in PyCharm -class MemoryStream(Generic[T]): +# Thin generic-over-T wrapper around ``create_memory_object_stream`` so callers +# get parameterised stream pairs without touching ``cast`` themselves. +class MemoryStream[T]: @staticmethod - def create(max_buffer_size: float = 0) -> tuple[MemorySendStream[T], MemoryObjectReceiveStream[T]]: - send, receive = create_memory_object_stream(max_buffer_size) - return cast(MemorySendStream[T], send), cast(MemoryObjectReceiveStream[T], receive) + def create(max_buffer_size: float = 0) -> tuple[MemoryObjectSendStream[T], MemoryObjectReceiveStream[T]]: + return create_memory_object_stream(max_buffer_size) class AsyncBackendConfig(TypedDict): @@ -428,12 +373,15 @@ async def wait(self) -> None: async def _cancel_when(trigger: AnyEventView | Callable[[], Awaitable[Any]], scope: CancelScope) -> None: - await (trigger() if callable(trigger) else trigger.wait()) + if callable(trigger): + await cast("Callable[[], Awaitable[Any]]", trigger)() + else: + await trigger.wait() scope.cancel() def cancellable_from(*events: AnyEventView): - def _decorator(func: Callable[P, Awaitable[Any]]) -> Callable[P, Awaitable[None]]: + def _decorator[**P](func: Callable[P, Awaitable[Any]]) -> Callable[P, Awaitable[None]]: @wraps(func) async def _wrapper(*args, **kwargs): # Short version: await wait_any(lambda: func(*args, **kwargs), *[e.wait for e in events]) diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index c16a798..c27b73d 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field from dataclasses import dataclass as define from functools import cached_property, wraps -from typing import Any, ClassVar, Literal, TypeVar, final, overload +from typing import Any, ClassVar, Literal, final, overload from anyio import CancelScope, CapacityLimiter, create_task_group, get_cancelled_exc_class, to_thread from anyio.abc import TaskGroup @@ -33,8 +33,6 @@ wait_all, ) -F = TypeVar("F", bound=Callable[..., Any]) - logger = logging.getLogger("localpost.hosting") @@ -130,22 +128,22 @@ def wait_shutting_down(self) -> None: return self._state.portal.start_task_soon(self.shutting_down.wait).result() @overload - def cancel_on_shutdown(self) -> Callable[[F], F]: ... + def cancel_on_shutdown[F: Callable[..., Any]](self) -> Callable[[F], F]: ... @overload - def cancel_on_shutdown(self, target: F | None = None, /) -> F: ... + def cancel_on_shutdown[F: Callable[..., Any]](self, target: F, /) -> F: ... - def cancel_on_shutdown(self, target: F | None = None, /) -> Any: + def cancel_on_shutdown(self, target: Callable[..., Any] | None = None, /) -> Any: dec = cancellable_from(self.shutting_down) return dec(target) if target is not None else dec @overload - def cancel_on_stop(self) -> Callable[[F], F]: ... + def cancel_on_stop[F: Callable[..., Any]](self) -> Callable[[F], F]: ... @overload - def cancel_on_stop(self, target: F | None = None, /) -> F: ... + def cancel_on_stop[F: Callable[..., Any]](self, target: F, /) -> F: ... - def cancel_on_stop(self, target: F | None = None, /) -> Any: + def cancel_on_stop(self, target: Callable[..., Any] | None = None, /) -> Any: dec = cancellable_from(self.stopped) return dec(target) if target is not None else dec @@ -293,12 +291,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool | None: return await self._run.__aexit__(exc_type, exc_val, exc_tb) -@asynccontextmanager -async def _serve_and_observe(svc_f: ServiceF, /): - async with serve(svc_f, parent=_svc_lt.get(None)) as lt, lt.observe(): - yield lt - - @overload def service[**P](target: Callable[P, Any]) -> Callable[P, _ResolvedService]: """Decorator to create a hosted service.""" diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index de7e6f4..61213da 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from contextlib import asynccontextmanager from datetime import timedelta -from typing import Any, Generic, TypeVar, final +from typing import Any, final from anyio import ( BrokenResourceError, @@ -31,9 +31,6 @@ from ._scheduler import ScheduledTask, ScheduledTaskTemplate, Task, Trigger -T = TypeVar("T") -ResT = TypeVar("ResT") - logger = logging.getLogger("localpost.scheduler.cond") @@ -96,7 +93,7 @@ def every(period: timedelta | str, /) -> ScheduledTaskTemplate[None]: @final @dc.dataclass(frozen=True, slots=True) -class After(Generic[ResT]): # noqa: UP046 +class After[ResT]: target: Task[Any, ResT] def __repr__(self): @@ -121,7 +118,7 @@ async def run(): return maybe_closing(run()) -def after(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[T]: # noqa: UP047 +def after[T](target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[T]: """ Trigger an event every time the target task completes successfully. """ @@ -130,7 +127,7 @@ def after(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTempl @final @dc.dataclass(frozen=True, slots=True) -class AfterAll(Generic[ResT]): # noqa: UP046 +class AfterAll[ResT]: target: Task[Any, ResT] def __repr__(self): @@ -151,7 +148,7 @@ async def run(): return maybe_closing(run()) -def after_all(target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[Result[T]]: # noqa: UP047 +def after_all[T](target: ScheduledTask[Any, T] | Task[Any, T], /) -> ScheduledTaskTemplate[Result[T]]: """ Trigger an event every time the target task completes (successfully or not). """ diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index b6eb51c..e069308 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -21,10 +21,16 @@ start_task_soon, ) +# We keep module-level TypeVars (and ``Generic[...]``) for the entire module +# rather than mixing PEP 695 ``class Foo[T]:`` with these TypeVars. ty's +# checker treats them as distinct identifiers — a class declared with +# ``Foo[T]`` does NOT see the module-level ``T`` and ends up with +# ``T@Foo`` vs ``T@module``, which breaks variance inside nested +# generic functions like ``scheduled_task → _decorator``. Once ty supports +# better outer-scope capture, the whole module can move to PEP 695. T = TypeVar("T") T2 = TypeVar("T2") R = TypeVar("R") -DecF = TypeVar("DecF", bound=Callable[..., Any]) type TaskHandler[T, R] = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] type HandlerDecorator = Callable[[Any], Any] @@ -35,8 +41,8 @@ @final @dc.dataclass() class Task( - Generic[T, R], # noqa: UP046 — kept for compat with the parameterised AbstractAsyncContextManager base - AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], # AsyncHandlerManager[T] + Generic[T, R], # noqa: UP046 — see TypeVar comment above + AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], ): name: str event_aware: bool @@ -206,7 +212,7 @@ async def __call__(self, shutting_down: EventView) -> None: ] -def scheduled_task[T]( +def scheduled_task( # noqa: UP047 — see TypeVar comment above tf: TriggerFactory[T], /, *, name: str | None = None ) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: """ diff --git a/localpost/scheduler/_trigger.py b/localpost/scheduler/_trigger.py index 29b3350..5d6a175 100644 --- a/localpost/scheduler/_trigger.py +++ b/localpost/scheduler/_trigger.py @@ -3,26 +3,25 @@ import logging from contextlib import AbstractAsyncContextManager from functools import wraps -from typing import TypeVar, cast +from typing import cast from localpost._utils import DelayFactory, cancellable_from, ensure_delay_factory, maybe_closing, sleep, td_str from ._scheduler import ScheduledTask, Trigger, TriggerFactory, TriggerFactoryDecorator, TriggerFactoryMiddleware -T = TypeVar("T") -T2 = TypeVar("T2") - logger = logging.getLogger("localpost.scheduler.cond") -def trigger_factory_middleware(middleware: TriggerFactoryMiddleware[T, T2]) -> TriggerFactoryDecorator[T, T2]: # noqa: UP047 +def trigger_factory_middleware[T, T2]( + middleware: TriggerFactoryMiddleware[T, T2], +) -> TriggerFactoryDecorator[T, T2]: def _decorator(source: TriggerFactory[T]) -> TriggerFactory[T2]: @wraps(source) def _run(task): source_events = source(task) events = middleware(source_events, task) return cast( - Trigger[T2], events if isinstance(events, AbstractAsyncContextManager) else maybe_closing(events) + "Trigger[T2]", events if isinstance(events, AbstractAsyncContextManager) else maybe_closing(events) ) return _run @@ -30,7 +29,7 @@ def _run(task): return _decorator -def take_first(n: int, /) -> TriggerFactoryDecorator[T, T]: +def take_first[T](n: int, /) -> TriggerFactoryDecorator[T, T]: if n < 0: raise ValueError("n must be a non-negative integer") @@ -49,7 +48,7 @@ async def middleware(source: Trigger[T], _): return middleware -def delay(value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: +def delay[T](value: DelayFactory, /) -> TriggerFactoryDecorator[T, T]: delay_f = ensure_delay_factory(value) @trigger_factory_middleware diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 170a76a..e50bf75 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -36,24 +36,30 @@ @final class CancellableLock: - """Same interface as threading.Lock, but acquire() is cancellation aware.""" + """Same interface as threading.Lock, but acquire() is cancellation aware. + + The class deliberately mirrors a few CPython-private attributes from + ``threading.RLock`` (``_release_save``, ``_acquire_restore``, ``_is_owned``) + so that ``threading.Condition(lock=CancellableLock(...))`` accepts it + transparently — the stdlib's ``Condition`` does its own duck-typing on + these names. This is intentional, not a leaky abstraction. + """ __slots__ = ("__exit__", "_acquire_restore", "_is_owned", "_release_save", "locked", "release", "source") - # noinspection PyProtectedMember def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None: lock = lock or threading.Lock() self.source = lock self.release = lock.release self.__exit__ = lock.__exit__ if hasattr(self.source, "locked"): - self.locked = lock.locked # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + self.locked = lock.locked # type: ignore if hasattr(lock, "_release_save"): - self._release_save = lock._release_save # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + self._release_save = lock._release_save # type: ignore if hasattr(lock, "_acquire_restore"): - self._acquire_restore = lock._acquire_restore # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + self._acquire_restore = lock._acquire_restore # type: ignore if hasattr(lock, "_is_owned"): - self._is_owned = lock._is_owned # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + self._is_owned = lock._is_owned # type: ignore def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: if not blocking: @@ -75,7 +81,9 @@ def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: def cancellable_condition(lock: CancellableLock | None = None) -> threading.Condition: - cond = threading.Condition(lock or CancellableLock(threading.RLock())) # pyright: ignore[reportArgumentType] + # ``Condition`` accepts any duck-typed lock with the right private attrs; + # ``CancellableLock`` mirrors them (see its docstring). + cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore orig_wait = cond.wait def cancellable_wait(timeout: float | None = None) -> bool: @@ -100,8 +108,11 @@ def cancellable_wait(timeout: float | None = None) -> bool: def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: + # ``Semaphore`` / ``BoundedSemaphore`` use a private ``_cond`` for blocking + # waits; swap it for our cancellable variant so the semaphore's blocking + # ``acquire`` becomes cancellation-aware. source = threading.BoundedSemaphore(value) - source._cond = cancellable_condition() # pyright: ignore[reportAttributeAccessIssue] + source._cond = cancellable_condition() # type: ignore return source @@ -151,10 +162,10 @@ def __iter__(self) -> Iterator[T]: def clone(self) -> ReceiveChannel[T]: ... # Raises: - # EndOfStream – if the sender has been closed cleanly, and no more objects are coming. This is not an error + # EndOfStream - if the sender has been closed cleanly, and no more objects are coming. This is not an error # condition. - # ClosedResourceError – if you previously closed this ReceiveChannel object. - # BrokenResourceError – if something has gone wrong, and the channel is broken. + # ClosedResourceError - if you previously closed this ReceiveChannel object. + # BrokenResourceError - if something has gone wrong, and the channel is broken. def get(self) -> T: ... def close(self): ... @@ -176,9 +187,9 @@ async def __aexit__(self, exc_type, exc_value, traceback) -> None: def clone(self) -> SendChannel[T]: ... # Raises: - # BrokenResourceError – if something has gone wrong, and the channel is broken. For example, you may get this if + # BrokenResourceError - if something has gone wrong, and the channel is broken. For example, you may get this if # the receiver has already been closed. - # ClosedResourceError – if you previously closed this SendChannel object, or if another task closes it while + # ClosedResourceError - if you previously closed this SendChannel object, or if another task closes it while # put() is running. def put(self, item: T, /) -> None: ... diff --git a/pyproject.toml b/pyproject.toml index cc21704..a68f471 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ keywords = [ ] classifiers = [ "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", @@ -249,14 +248,13 @@ ignore = [ "RUF022", # `__all__` is not sorted "PGH003", "PGH004", # Use specific rule codes when ignoring type issues "RUF005", - "PERF203", # Enforced for Python versions prior to 3.11, which introduced "zero-cost" exception handling + "PERF203", # ``try``/``except`` in a loop — kept off; readability >> the micro-cost on 3.12+ "zero-cost" exception handling "TID252", # Prefer absolute imports over relative imports from parent modules "S101", # Use of `assert` detected "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes "B011", # Do not `assert False` "SIM105", # Use `contextlib.suppress(GeneratorExit)` instead of `try`-`except`-`pass` - # TODO Remove when we drop support for Python <=3.10 - "SIM117", # Use a single `with` statement with multiple contexts + "SIM117", # Use a single ``with`` statement with multiple contexts — wide-cast nesting reads cleaner with separate ``with``s "PLR0913", # Too many arguments in function definition "PLR2004", # Magic value used in comparison # TODO Reduce complexity From 92b737aecccd4d05559fdf38c25d36632ecb2df8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 17:43:41 +0400 Subject: [PATCH 111/286] perf(threadtools): inline put_nowait body into Channel.put Avoids a redundant RLock re-entry on the put fast path, and skips the WouldBlock exception construction when the channel is full. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/localpost/threadtools.py b/localpost/threadtools.py index e50bf75..f2539e9 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -264,20 +264,25 @@ def put_nowait(self, item: T, /) -> None: @override def put(self, item: T, /) -> None: - # Phase 1: wait for space in the buffer + state = self._state + # Phase 1: wait for space in the buffer. + # Body of ``put_nowait`` is inlined here to avoid re-entering the lock + # and to skip the ``WouldBlock`` exception on the contended path. while True: check_cancelled() - with self._state as state: - try: - self.put_nowait(item) + with state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if state.can_put: + state.buffer.append(item) + state.not_empty.notify() break - except WouldBlock: - state.not_full.wait() + state.not_full.wait() # Phase 2 (rendezvous only): wait until the item is consumed - if self._state.capacity == 0: + if state.capacity == 0: while True: - with self._state as state: + with state: if self._closed: raise ClosedResourceError("send channel has been closed") if len(state.buffer) == 0: @@ -312,10 +317,11 @@ def clone(self): @override def get(self) -> T: + state = self._state check_cancelled() while not self._closed: - with self._state as state: - if len(state.buffer) > 0: + with state: + if state.buffer: item = state.buffer.popleft() state.not_full.notify() return item From 5ed678ad8e7ffb774570d78fd7d12b70d59a890a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 28 Apr 2026 19:59:34 +0400 Subject: [PATCH 112/286] refactor(scheduler): collapse Scheduler lifecycle into hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler's hosting integration was broken: `_ScheduledTask.__call__` took an `EventView` while `run_app(...)` and `lt.start(...)` invoked it with a `ServiceLifetime`. The trigger then `await lt.wait()`'d, raised AttributeError, which the task group swallowed as cancellation — `run_app(task)` looked like it worked but actually shut down after one tick. Make both `_ScheduledTask` and `Scheduler` real `ServiceF`s. Drop the parallel scheduler-only lifecycle (`aserve()`, `shutdown()`, `as_service()`) — `localpost.hosting.run_app(...)` and `serve(...)` now handle every entry-point case (top-level, FastAPI lifespan, sync embedding via `start_blocking_portal`). Also fix `_run_many` in hosting to propagate parent `shutting_down` to children. Without it, `run_app(t1, t2)` required two SIGTERMs to exit cleanly. Existing `_run_many` tests still pass. Examples swept: import fixes for `cron.py` / `failing_tasks.py` / `fastdepends.py` (the bogus `from localpost.scheduler import run` is gone — it was never shipped), `finite_task.py` middleware reorder so the handler can be 0-arg, `fastapi_lifespan.py` switched to `serve(scheduler)`, `serve_sync.py` rewritten on top of `anyio.from_thread.start_blocking_portal` + `serve(...)`. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/scheduler/cron.py | 6 ++- examples/scheduler/failing_tasks.py | 6 ++- examples/scheduler/fastapi_lifespan.py | 4 +- examples/scheduler/fastdepends.py | 6 ++- examples/scheduler/finite_task.py | 7 ++-- examples/scheduler/serve_sync.py | 25 ++++++++---- localpost/hosting/_host.py | 8 ++++ localpost/scheduler/README.md | 7 +++- localpost/scheduler/_scheduler.py | 56 ++++++++++++-------------- tests/scheduler/scheduler.py | 31 ++++++++++---- 10 files changed, 97 insertions(+), 59 deletions(-) diff --git a/examples/scheduler/cron.py b/examples/scheduler/cron.py index a6e948f..3d5ca7f 100755 --- a/examples/scheduler/cron.py +++ b/examples/scheduler/cron.py @@ -1,8 +1,10 @@ #!/usr/bin/env python import logging +import sys -from localpost.scheduler import delay, run, scheduled_task +from localpost.hosting import run_app +from localpost.scheduler import delay, scheduled_task from localpost.scheduler.cond.cron import cron @@ -17,4 +19,4 @@ async def cron_job(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run(cron_job)) + sys.exit(run_app(cron_job)) diff --git a/examples/scheduler/failing_tasks.py b/examples/scheduler/failing_tasks.py index 02e8fc8..dfd3e81 100755 --- a/examples/scheduler/failing_tasks.py +++ b/examples/scheduler/failing_tasks.py @@ -1,9 +1,11 @@ #!/usr/bin/env python import logging +import sys from datetime import timedelta -from localpost.scheduler import Scheduler, delay, every, run, take_first +from localpost.hosting import run_app +from localpost.scheduler import Scheduler, delay, every, take_first scheduler = Scheduler() @@ -25,4 +27,4 @@ def a_sync_task(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run(scheduler)) + sys.exit(run_app(scheduler)) diff --git a/examples/scheduler/fastapi_lifespan.py b/examples/scheduler/fastapi_lifespan.py index ed38b06..454edd9 100755 --- a/examples/scheduler/fastapi_lifespan.py +++ b/examples/scheduler/fastapi_lifespan.py @@ -5,6 +5,7 @@ from fastapi import FastAPI +from localpost.hosting import serve from localpost.scheduler import Scheduler, delay, every scheduler = Scheduler() @@ -17,9 +18,8 @@ async def heavy_background_task(): @asynccontextmanager async def lifespan(_: FastAPI): - async with scheduler.aserve(): # Scheduler in the same thread, same event loop + async with serve(scheduler): # Scheduler in the same thread, same event loop yield - scheduler.shutdown() app = FastAPI(lifespan=lifespan) diff --git a/examples/scheduler/fastdepends.py b/examples/scheduler/fastdepends.py index 6ed1c95..5112078 100755 --- a/examples/scheduler/fastdepends.py +++ b/examples/scheduler/fastdepends.py @@ -2,11 +2,13 @@ import logging import random +import sys from datetime import timedelta from fast_depends import Depends, inject -from localpost.scheduler import delay, every, run, scheduled_task +from localpost.hosting import run_app +from localpost.scheduler import delay, every, scheduled_task def roll_dice(): @@ -28,4 +30,4 @@ def print_task( logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run(print_task)) + sys.exit(run_app(print_task)) diff --git a/examples/scheduler/finite_task.py b/examples/scheduler/finite_task.py index 4cfe4af..a7cd363 100755 --- a/examples/scheduler/finite_task.py +++ b/examples/scheduler/finite_task.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python import logging import random import sys @@ -7,10 +8,10 @@ from localpost.scheduler import delay, every, scheduled_task, take_first -@scheduled_task(every(timedelta(seconds=3)) // take_first(3) // delay((0, 3))) -def task1(_): +@scheduled_task(every(timedelta(seconds=3)) // delay((0, 3)) // take_first(3)) +def task1(): """ - A simple repeating task that returns a random number. + Fires three times (with jitter) and then the process exits on its own. """ print("task1 running!") return random.randint(1, 22) # Not used diff --git a/examples/scheduler/serve_sync.py b/examples/scheduler/serve_sync.py index f9d39f4..88fbca7 100755 --- a/examples/scheduler/serve_sync.py +++ b/examples/scheduler/serve_sync.py @@ -2,6 +2,9 @@ import time +from anyio.from_thread import start_blocking_portal + +from localpost.hosting import serve from localpost.scheduler import Scheduler, delay, every scheduler = Scheduler() @@ -13,14 +16,20 @@ async def task1(): def main(): - # Start the scheduler in a _separate_ thread, with its own event loop - with scheduler.serve(): - try: - while True: - time.sleep(1) - print("Main thread is running...") - except KeyboardInterrupt: - pass + # Drive the async hosting layer from sync code: portal runs the event loop + # in a background thread; ``wrap_async_context_manager`` exposes + # ``serve(scheduler)`` as a regular ``with`` block. ``lt.shutdown()`` is + # already cross-thread safe. + with start_blocking_portal() as portal: + with portal.wrap_async_context_manager(serve(scheduler)) as lt: + try: + while True: + time.sleep(1) + print("Main thread is running...") + except KeyboardInterrupt: + pass + finally: + lt.shutdown() print("Main thread is done, exiting the app") diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index c27b73d..a47ce36 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -457,6 +457,14 @@ def _run_many(*svcs: ServiceF) -> ServiceF: async def _run(lt: ServiceLifetime) -> None: children = [lt.start(svc) for svc in svcs] + + async def propagate_shutdown() -> None: + await lt.shutting_down.wait() + for c in children: + c.shutdown() + + if children: + lt.tg.start_soon(propagate_shutdown) await wait_all(c.stopped for c in children) return _run diff --git a/localpost/scheduler/README.md b/localpost/scheduler/README.md index 547bc5e..bfc918a 100644 --- a/localpost/scheduler/README.md +++ b/localpost/scheduler/README.md @@ -74,6 +74,10 @@ See [`examples/scheduler/`](../../examples/scheduler/) for more same way. - **`Result[T]`** — output envelope (`Ok` or failure). `after()` filters successes and forwards the `T`; `after_all()` forwards every result. +- **Hosting integration** — both individual tasks and `Scheduler` instances + are `ServiceF`s. Pass them to `localpost.hosting.run_app(...)` (entry point, + signal handling) or `localpost.hosting.serve(...)` (async CM, e.g. for + embedding in a FastAPI lifespan). ## Public API @@ -83,8 +87,7 @@ See [`examples/scheduler/`](../../examples/scheduler/) for more | `Task` | Base task type | | `ScheduledTask` | Runtime task object | | `ScheduledTaskTemplate` | Trigger + middleware, not yet bound to handler | -| `Scheduler` | Groups multiple tasks into one hosted service | -| `run(task_or_scheduler)` | Entry point | +| `Scheduler` | Groups multiple tasks. Itself a `ServiceF` — pass to `run_app(...)` / `serve(...)` from `localpost.hosting` | | `every(period)` | Trigger: every `timedelta` or string ("3s") | | `after(task)` | Trigger: after each success of `task` | | `after_all(task)` | Trigger: after every run of `task` (success or failure) | diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index e069308..aca3b7c 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -5,10 +5,10 @@ import logging import math from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, ExitStack, asynccontextmanager -from typing import Any, Generic, Protocol, TypeVar, cast, final +from contextlib import AbstractAsyncContextManager, ExitStack +from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, final -from anyio import BrokenResourceError, WouldBlock, create_task_group, to_thread +from anyio import BrokenResourceError, WouldBlock, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( @@ -18,9 +18,12 @@ Result, def_full_name, is_async_callable, - start_task_soon, + wait_all, ) +if TYPE_CHECKING: + from localpost.hosting import ServiceLifetime + # We keep module-level TypeVars (and ``Generic[...]``) for the entire module # rather than mixing PEP 695 ``class Foo[T]:`` with these TypeVars. ty's # checker treats them as distinct identifiers — a class declared with @@ -191,8 +194,10 @@ async def run(self, shutting_down: EventView) -> None: logger.debug("%r trigger is completed", self) logger.debug("%r is done", self) - async def __call__(self, shutting_down: EventView) -> None: - return await self.run(shutting_down) + async def __call__(self, lt: ServiceLifetime) -> None: + """ServiceF entry point: drives the trigger lifecycle from a hosting lifetime.""" + lt.set_started() + await self.run(lt.shutting_down) type Trigger[T] = AbstractAsyncContextManager[AsyncIterator[T]] @@ -230,15 +235,13 @@ def _decorator(func: TaskHandler[T, R] | Task[T, R]) -> _ScheduledTask[T, R]: class Scheduler: """ - Manages a collection of periodic tasks. - - Can be used standalone via `aserve()`, or integrated with hosting via `as_service()`. + Manages a collection of periodic tasks. ``Scheduler`` is itself a ``ServiceF`` — + pass it to ``localpost.hosting.run_app(...)`` or ``serve(...)`` to run. """ def __init__(self, name: str = "scheduler"): self._name = name self._scheduled_tasks: list[_ScheduledTask[Any, Any]] = [] - self._shutting_down: Event | None = None @property def name(self) -> str: @@ -260,25 +263,18 @@ def _decorator(func: TaskHandler[T, R] | Task[T, R] | _ScheduledTask[T, R]): return _decorator - def shutdown(self) -> None: - if self._shutting_down: - self._shutting_down.set() - - @asynccontextmanager - async def aserve(self): - """Run all scheduled tasks. Use `shutdown()` or exit the context to stop.""" - shutting_down = self._shutting_down = Event() - if not self._scheduled_tasks: - yield + async def __call__(self, lt: ServiceLifetime) -> None: + """ServiceF entry point: starts every registered task as a child service.""" + children = [lt.start(st) for st in self._scheduled_tasks] + lt.set_started() + if not children: + await lt.shutting_down.wait() return - async with create_task_group() as tg: - for st in self._scheduled_tasks: - start_task_soon(tg, lambda s=st: s.run(shutting_down)) - yield - shutting_down.set() - - async def as_service(self, lt) -> None: - """Run the scheduler as a hosting service (accepts a ServiceLifetime).""" - async with self.aserve(): - lt.set_started() + + async def propagate_shutdown() -> None: await lt.shutting_down.wait() + for c in children: + c.shutdown() + + lt.tg.start_soon(propagate_shutdown) + await wait_all(c.stopped for c in children) diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py index 56ae4a8..e2fb0c2 100644 --- a/tests/scheduler/scheduler.py +++ b/tests/scheduler/scheduler.py @@ -5,6 +5,7 @@ import pytest from anyio import fail_after +from localpost.hosting import serve from localpost.scheduler import Scheduler, every, scheduled_task pytestmark = [pytest.mark.anyio, pytest.mark.integration] @@ -12,18 +13,17 @@ async def test_task_decorator(): results = [] + scheduler = Scheduler() - @scheduled_task(every(timedelta(seconds=1))) + @scheduler.task(every(timedelta(seconds=1))) def sample_task(): results.append(random.randint(0, 10)) assert callable(sample_task) - scheduler = Scheduler() - scheduler._scheduled_tasks.append(sample_task) - async with scheduler.aserve(): + async with serve(scheduler) as lt: await anyio.sleep(0.5) # "App is working" - scheduler.shutdown() + lt.shutdown() assert len(results) == 1 # Only one initial run @@ -42,9 +42,9 @@ def sample_task2(): assert callable(sample_task1) - async with scheduler.aserve(): + async with serve(scheduler) as lt: await anyio.sleep(0.5) # "App is working" - scheduler.shutdown() + lt.shutdown() assert len(results) == 2 # Only one initial run for each task assert "task1 result" in results @@ -56,5 +56,20 @@ async def test_empty_scheduler(): scheduler = Scheduler() with fail_after(1): - async with scheduler.aserve(): + async with serve(scheduler): pass + + +async def test_single_scheduled_task(): + """A single ``@scheduled_task``-decorated function is itself a ServiceF.""" + results = [] + + @scheduled_task(every(timedelta(seconds=1))) + def sample_task(): + results.append(1) + + async with serve(sample_task) as lt: + await anyio.sleep(0.5) + lt.shutdown() + + assert len(results) == 1 From ef6f562e1f12f98a84e49b46d6b1c7e9257c63ef Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 11:34:06 +0400 Subject: [PATCH 113/286] feat(http): optional httptools backend alongside h11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second HTTP/1.1 server implementation backed by ``httptools`` (llhttp), opt-in via the ``[http-fast]`` extra. h11 stays the default. Public API gains neutral ``Request`` / ``NativeResponse`` / ``InformationalResponse`` types so handlers no longer import the parser directly. ``HTTPReqCtx`` is now a Protocol; ``HTTPReqCtx.request`` and the ``start_response`` / ``complete`` arguments are neutral types (field shapes match h11's, so the migration is mechanical). Architecture: - ``_base.py``: parser-agnostic ``BaseServer`` (selector loop, accept, op queue, stale sweep, shutdown) lifted from the old ``Server`` and parameterised on a ``conn_factory``. ``BaseHTTPConn`` ABC carries only the surface ``BaseServer`` actually needs — no parser methods leak through. - ``server_h11.py``: existing logic moved here; translates at the boundary between neutral types and h11 events. - ``server_httptools.py``: native push-callback driven; ready-queue for pipelined requests; hand-written response serializer with auto-``Transfer-Encoding: chunked`` when the response lacks both Content-Length and Transfer-Encoding (otherwise HTTP/1.1 clients hang waiting for FIN). Each chunk is a single ``sendall``. - ``server.py``: thin re-export façade for back-compat. Hosted-service form: ``httptools_server`` (peer of ``http_server``, lazy-imports the backend so the [http-fast] extra stays optional). Bench (single-process Python 3.13 on arm64, 8s/cell, vs h11): plaintext +35%, path_param +34%, json_post +45%; profile_update +1% (handler dominated by ``time.sleep``, parser overhead negligible). 12 new backend parity tests cover GET, POST with body, oversize → 413, malformed → 400, keep-alive, and ``Expect: 100-continue`` across both backends. All 142 http tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 26 + benchmarks/http/apps/localpost_httptools.py | 63 ++ benchmarks/http/runner.py | 1 + examples/http/simple_server.py | 13 +- localpost/http/README.md | 40 +- localpost/http/__init__.py | 14 +- localpost/http/_base.py | 589 +++++++++++++++ localpost/http/_service.py | 45 +- localpost/http/_types.py | 65 ++ localpost/http/flask.py | 8 +- localpost/http/router.py | 11 +- localpost/http/server.py | 790 +------------------- localpost/http/server_h11.py | 324 ++++++++ localpost/http/server_httptools.py | 479 ++++++++++++ localpost/http/wsgi.py | 9 +- pyproject.toml | 4 + tests/http/backend_parity.py | 249 ++++++ tests/http/server.py | 43 +- tests/http/service.py | 18 +- uv.lock | 35 +- 20 files changed, 1987 insertions(+), 839 deletions(-) create mode 100644 benchmarks/http/apps/localpost_httptools.py create mode 100644 localpost/http/_base.py create mode 100644 localpost/http/_types.py create mode 100644 localpost/http/server_h11.py create mode 100644 localpost/http/server_httptools.py create mode 100644 tests/http/backend_parity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d64f52..04610cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (including a `Router`'s 404/405 path) stay on the selector thread. - `just deadcode` — vulture-based dead-code finder, configured in `pyproject.toml` (`[tool.vulture]`). +- **Optional `httptools` HTTP server backend** under the `[http-fast]` + extra. New entry points `localpost.http.start_httptools_server` and + `localpost.http.httptools_server` (hosted-service form) drive a + C-based llhttp parser as a peer of the existing h11 server. Same + selector / accept loop / connection bookkeeping (lifted into a shared + `BaseServer`); each backend uses its parser's natural idioms — no + internal `next_event/send_response` Protocol forced over both. + h11 stays the default. Initial scope: `Content-Length` responses + only (chunked transfer-encoding on the httptools side is a follow-up; + matches what `Router` and `wrap_wsgi` produce today). +- Neutral wire types `Request`, `NativeResponse`, `InformationalResponse` + (re-exported from `localpost.http`) — backend-agnostic shapes both + servers populate. User code no longer imports `h11` or `httptools` + directly. ### Changed +- **`localpost.http` no longer leaks h11 types into the public API.** + `HTTPReqCtx.request` is now `localpost.http.Request` (was + `h11.Request`); `HTTPReqCtx.start_response` and `complete` accept + `localpost.http.NativeResponse` / `InformationalResponse` (was + `h11.Response` / `h11.InformationalResponse`). Field shapes are + identical (lowercased header-name bytes, byte values), so the + migration is mechanical: replace `import h11` / + `h11.Response(status_code=…, headers=…)` with + `from localpost.http import NativeResponse` / + `NativeResponse(status_code=…, headers=…)`. The `http` module is + marked stable, but absorbing this cost once enables the + alternative-backend support above. - `localpost.http._service.http_server` no longer accepts `max_concurrency` and no longer owns a worker pool. Wrap your handler with `thread_pool_handler` to opt back into worker dispatch. diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py new file mode 100644 index 0000000..78727b0 --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools.py @@ -0,0 +1,63 @@ +"""LocalPost native handler — Router + httptools_server (C parser). + +Same handler set as ``localpost_native``; only the server backend differs. +""" + +from __future__ import annotations + +import sys +import time + +from benchmarks.http.apps._cli import parse_port +from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost.hosting import run_app, service +from localpost.http import ( + RequestCtx, + Response, + Routes, + ServerConfig, + httptools_server, + thread_pool_handler, +) + + +def _ping(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [PING_BODY]) + + +def _hello(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) + + +def _echo(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "application/json"}, [ctx.body()]) + + +def _profile_update(ctx: RequestCtx) -> Response: + body = profile_update_body(ctx.path_args["user_id"], ctx.body()) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response(200, {"content-type": "application/json"}, [body]) + + +def main() -> int: + port = parse_port() + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/hello/{name}")(_hello) + routes.post("/echo")(_echo) + routes.post("/users/{user_id}/profile")(_profile_update) + handler = routes.build().as_handler() + cfg = ServerConfig(host="127.0.0.1", port=port) + + @service + async def app(): + async with thread_pool_handler(handler, max_concurrency=32) as wrapped: + async with httptools_server(cfg, wrapped): + yield + + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index 32f5aad..2d1a176 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -41,6 +41,7 @@ STACKS: tuple[str, ...] = ( "localpost_native", + "localpost_httptools", "localpost_wsgi", "localpost_flask", "flask_cheroot", diff --git a/examples/http/simple_server.py b/examples/http/simple_server.py index 8c25a61..42ea942 100644 --- a/examples/http/simple_server.py +++ b/examples/http/simple_server.py @@ -1,14 +1,17 @@ import logging -import h11 - -from localpost.http.config import ServerConfig -from localpost.http.server import HTTPReqCtx, start_http_server +from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig, start_http_server def _main(): def simple_app(ctx: HTTPReqCtx): - ctx.complete(h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"Hello, World!\n") + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"Content-Type", b"text/plain"), (b"Content-Length", b"14")], + ), + b"Hello, World!\n", + ) with start_http_server(ServerConfig(), simple_app) as server: while True: diff --git a/localpost/http/README.md b/localpost/http/README.md index fea9d93..cfc17ff 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -13,7 +13,8 @@ For OpenAPI / content negotiation / validation, see ## Install ```bash -pip install localpost[http-server] +pip install localpost[http-server] # h11 backend (default, pure Python) +pip install localpost[http-server,http-fast] # also adds the httptools backend ``` ## Quick start @@ -291,19 +292,36 @@ worked but introduced a 3-way state machine with cross-thread races. The pull-based variant collapses to two states and one syscall per `check_cancelled()` call. -## Roadmap +## Server backends -Items that are not currently bugs but where there's a known better -implementation worth tackling later. +Two implementations live side-by-side. They share the listening socket, +selector loop, op queue, stale-conn sweep, and shutdown coordination +(everything in `_base.py`). They differ in how they drive the parser: -### Faster HTTP/1.1 parsing +| Entry point | Parser | Extra | Notes | +| ---------------------------- | ----------- | ------------ | ------------------------------- | +| `start_http_server` | h11 | `[http-server]` | default; pure Python, readable | +| `start_httptools_server` | httptools | `[http-fast]` | C-based llhttp; faster header parsing | -The selector thread is CPU-bound on `h11` (pure Python). Switching to a C -parser (`httptools` or similar) is the largest realistic single-step -perf win — a 30-50% RPS uplift would be plausible. Trade-off: adds a C -dependency and weakens the "minimal, readable, in-Python" pitch. See -[`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md) -for the analysis behind the current bench numbers. +Hosted-service equivalents: `http_server(...)` / `httptools_server(...)` +(both decorated with `@hosting.service`). + +Pick whichever fits — handler code is identical. Both populate the same +neutral `Request` / `NativeResponse` types from `localpost.http`. The +two implementations are intentionally **not** unified behind a parser +Protocol: h11 is pull-events + parse/serialize, httptools is +push-callbacks + parse-only, and forcing one shape over both restricts +the faster backend without buying anything. + +httptools backend caveats (initial scope): +- `Content-Length` response bodies only (chunked transfer-encoding is a + follow-up). `Router` and `wrap_wsgi` already set `Content-Length`, + so this matches today's behaviour. +- HTTP Upgrade negotiation surfaces as 400 Bad Request — revisit if/when + WebSockets come back on the roadmap. + +For perf context, see +[`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md). ## How is it different from… diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index b85fd0a..3400325 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,6 +1,8 @@ from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._pool import thread_pool_handler -from localpost.http._service import http_server, wsgi_server +from localpost.http._service import http_server, httptools_server, wsgi_server +from localpost.http._types import BodyTooLarge, InformationalResponse, Request +from localpost.http._types import Response as NativeResponse from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( RequestCtx, @@ -16,6 +18,9 @@ from localpost.http.server import HTTPReqCtx, RequestHandler, start_http_server from localpost.http.wsgi import wrap_wsgi +# ``Response`` (the public name) is the high-level router response. +# ``NativeResponse`` is the low-level wire-format response used directly +# with ``HTTPReqCtx.complete`` / ``start_response``. __all__ = [ # config "ServerConfig", @@ -24,6 +29,13 @@ "start_http_server", "HTTPReqCtx", "RequestHandler", + # backend selection + "httptools_server", + # neutral wire types (used directly with HTTPReqCtx) + "Request", + "NativeResponse", + "InformationalResponse", + "BodyTooLarge", # router "Router", "Routes", diff --git a/localpost/http/_base.py b/localpost/http/_base.py new file mode 100644 index 0000000..ade8753 --- /dev/null +++ b/localpost/http/_base.py @@ -0,0 +1,589 @@ +"""Parser-agnostic HTTP server infrastructure. + +This module owns the bits of the server that don't touch the HTTP parser: +the listening socket, the selector loop, the op queue / wakeup pipe, +stale-connection sweep, and shutdown. Each concrete backend (h11, httptools) +provides a ``BaseHTTPConn`` subclass driven by its parser's natural idioms. + +ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. +""" + +from __future__ import annotations + +import collections +import fcntl +import logging +import os +import selectors +import socket +import threading +import time +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, closing, contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol, final + +from localpost.http._types import InformationalResponse, Request, Response +from localpost.http.config import LOGGER_NAME, ServerConfig + +if TYPE_CHECKING: + from collections.abc import Buffer + +__all__ = [ + "BaseServer", + "BaseHTTPConn", + "HTTPReqCtx", + "RequestHandler", + "start_http_server_base", +] + + +# Selector data-tag for ``BaseServer._wakeup_r`` — distinguishable in the for-event +# loop so the selector thread knows to drain the wakeup pipe + op queue. +_WAKEUP_SENTINEL: object = object() + + +@final +@dataclass(eq=False, slots=True, frozen=True) +class _OpTrack: + """Worker-enqueued op: register ``conn`` in the selector with ``data=conn``.""" + + conn: BaseHTTPConn + + +@final +@dataclass(eq=False, slots=True, frozen=True) +class _OpClose: + """Worker-enqueued op: clean up ``selector._fd_to_key[fd]`` after ``sock.close()``. + + The kernel auto-removes the fd from kqueue/epoll on ``close()``. The + Python-side ``_fd_to_key`` dict is what we actually need to clean up so + the conn instance can be GC'd. + """ + + fd: int + + +_Op = _OpTrack | _OpClose + + +# Canned protocol-error responses, expressed as neutral types. Each backend +# serialises them with its own writer (h11.Connection.send for the h11 impl, +# the hand-written serialiser for the httptools impl). + +INTERNAL_ERROR_BODY = b"Internal Server Error" +INTERNAL_ERROR_RESPONSE = Response( + status_code=500, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(INTERNAL_ERROR_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + +BAD_REQUEST_BODY = b"Bad Request" +BAD_REQUEST_RESPONSE = Response( + status_code=400, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(BAD_REQUEST_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + +PAYLOAD_TOO_LARGE_BODY = b"Payload Too Large" +PAYLOAD_TOO_LARGE_RESPONSE = Response( + status_code=413, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(PAYLOAD_TOO_LARGE_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + +REQUEST_TIMEOUT_BODY = b"Request Timeout" +REQUEST_TIMEOUT_RESPONSE = Response( + status_code=408, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(REQUEST_TIMEOUT_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + + +class HTTPReqCtx(Protocol): + """Per-request context handed to a :data:`RequestHandler`. + + Both server backends populate concrete implementations of this Protocol + with the same observable surface. ``_server`` and ``_conn`` are not + public API but are documented here because :func:`thread_pool_handler` + reaches into them to borrow the connection for a worker. They're + declared as read-only properties so covariant subtypes (concrete + ``HTTPConnH11`` / ``HTTPConnHttptools``) satisfy the Protocol. + """ + + request: Request + response_status: int | None + + @property + def _server(self) -> BaseServer: ... + @property + def _conn(self) -> BaseHTTPConn: ... + @property + def borrowed(self) -> bool: ... + def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... + def receive(self, size: int = ..., /) -> bytes: ... + def start_response(self, response: Response | InformationalResponse, /) -> None: ... + def send(self, chunk: Buffer, /) -> None: ... + def finish_response(self) -> None: ... + def complete(self, response: Response, body: bytes | None = None) -> None: ... + + +RequestHandler = Callable[[HTTPReqCtx], None] + + +def emit_handler_error(ctx: HTTPReqCtx) -> None: + """Best-effort recovery when a request handler raises. + + Emits a 500 response if no headers have been sent yet; otherwise closes + the connection (we can't go back and prepend a status line to bytes + already on the wire). All I/O failures are swallowed — the goal is to + avoid amplifying one error into another. + """ + logger = logging.getLogger(LOGGER_NAME) + if ctx.response_status is None: + try: + ctx.complete(INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_BODY) + except Exception: + logger.exception("Failed to send 500 after handler error; closing") + else: + return + ctx._conn.close() + + +class BaseHTTPConn(ABC): + """Abstract per-connection surface used by :class:`BaseServer`. + + Subclasses own the parser instance and per-connection state. The base + server only observes the small surface declared here: tracking flag, + socket, fd, idle / close-at timestamps, ``__call__`` entry point, + ``close()``, and the stale-408 emission hook. + """ + + server: BaseServer + sock: socket.socket + addr: tuple[str, int] + fd: int + tracked: bool + """``True`` iff this conn is registered in the selector. ``False`` while a + worker has borrowed it (between ``stop_tracking`` and the next ``track``).""" + close_at: float | None + """Used only when tracked, to enforce keep-alive and read timeouts.""" + idle: bool + """``True`` between requests (after a parser cycle reset) or before the + first byte arrives. Flips to ``False`` once any byte has been received + for the current request. Distinguishes idle keep-alive (close silently) + from a stalled mid-request (emit 408 Request Timeout).""" + + @abstractmethod + def __call__(self, h: RequestHandler, /) -> None: + """Drive the connection: read, parse, dispatch, write. Returns when the + worker should be released (handler took the conn / connection closed / + more data needed from selector).""" + + def close(self) -> None: + """Tear down the connection. + + Synchronously sends FIN + closes the socket so the kernel fd is + freed immediately. The selector-side ``_fd_to_key`` cleanup happens + on the selector thread — inline if we are it, else enqueued via + ``_OpClose``. Safe to call from any thread. + """ + was_tracked = self.tracked + self.tracked = False + try: + self.sock.shutdown(socket.SHUT_WR) + except OSError: + pass + try: + self.sock.close() + except OSError: + pass + if not was_tracked: + return + if threading.get_ident() == self.server._selector_thread_id: + sel = self.server.selector + if self.fd in sel.get_map(): + try: + sel.unregister(self.fd) + except (KeyError, ValueError): + pass + else: + self.server._ops.append(_OpClose(self.fd)) + self.server._wake() + + @abstractmethod + def emit_stale_408(self) -> None: + """Best-effort 408 emission for a stalled mid-request connection. + + Called by :meth:`BaseServer._cleanup_stale` for conns that received + bytes but never produced a complete request. The h11 impl sends via + the parser; the httptools impl writes raw bytes. Implementations + no-op when no response is appropriate (idle keep-alive, response + already started). Failures are swallowed — the conn is being torn + down anyway. + """ + + +_ConnFactory = Callable[["BaseServer", socket.socket, tuple[str, int]], BaseHTTPConn] + + +@final +class BaseServer: + """Parser-agnostic server: owns the listening socket, selector, and op queue. + + A :class:`BaseHTTPConn` factory is injected at construction time — each + accepted connection is built by ``conn_factory(server, sock, addr)``. + All parser-specific behaviour lives behind that interface. + """ + + def __init__( + self, + config: ServerConfig, + handler: RequestHandler, + logger: logging.Logger, + server_sock: socket.socket, + selector: selectors.BaseSelector, + conn_factory: _ConnFactory, + ) -> None: + self.sock = server_sock + self.port: int = server_sock.getsockname()[1] + """ + Actual port the server is listening on. + + Can be useful when port 0 is specified to auto-assign a free port. + """ + self.selector = selector + self.config = config + self.handler = handler + self.logger = logger + self._conn_factory = conn_factory + self.shutting_down: bool = False + """Set to True on context-manager exit. Once set, ``track`` rejects + new registrations and ``_maybe_give_back`` closes connections + instead of returning them to the selector.""" + # Lock-free op queue + self-pipe wakeup. The selector thread is the + # single writer to ``self.selector``; cross-thread mutations from + # workers (``track`` from ``_maybe_give_back``, ``close`` from error + # paths) enqueue ops and write a wakeup byte. The selector drains at + # the top of every iteration and on wakeup-sentinel events. + self._ops: collections.deque[_Op] = collections.deque() + r, w = os.pipe() + os.set_blocking(r, False) + os.set_blocking(w, False) + for fd in (r, w): + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) + self._wakeup_r: int = r + self._wakeup_w: int = w + selector.register(r, selectors.EVENT_READ, data=_WAKEUP_SENTINEL) + self._selector_thread_id: int | None = None + """Cached on the first ``run()`` call. Used to route ``track`` / + ``close`` calls inline when invoked from the selector thread itself + (e.g. from the accept branch of ``run``).""" + # Stale-connection cleanup runs lazily — at most once per + # ``_cleanup_interval``. Avoids walking ``selector.get_map()`` on + # every iteration when nothing's actually stale (which is the + # common case under load: keep_alive_timeout=15s, rw_timeout=1s). + # Floor at 100 ms so users with degenerate tiny timeouts don't + # pathologically over-cleanup. + self._cleanup_interval: float = max( + min(config.rw_timeout, config.keep_alive_timeout) / 2, + 0.1, + ) + self._last_cleanup_at: float = 0.0 + + def _find_stale(self): + now = time.monotonic() + for key in self.selector.get_map().values(): + if (conn := key.data) and isinstance(conn, BaseHTTPConn) and conn.close_at and now > conn.close_at: + yield conn + + def _cleanup_stale(self): + # Selector-thread only. Lock-free: ``self.selector`` and + # ``conn.tracked`` are owned by the selector thread. + # + # We deliberately keep this on the selector thread rather than a + # dedicated cleanup thread. With the lazy gate below the common + # case is an O(1) timestamp compare (no walk); the actual O(N) + # sweep runs at most every ``_cleanup_interval``, ~twice a second + # by default. A separate thread would either need a lock around + # ``selector.get_map()`` (losing the lock-free property of the + # current op-queue model) or maintain a parallel data structure + # — both add machinery without buying anything visible. + # + # Lazy: skip the O(N) walk over registered conns when not enough + # time has passed since the last sweep. Worst-case extra detection + # latency is ``_cleanup_interval`` (default 0.5 s) on top of the + # configured ``rw_timeout`` / ``keep_alive_timeout`` — both already + # measure non-fatal conditions. + now = time.monotonic() + if now - self._last_cleanup_at < self._cleanup_interval: + return + self._last_cleanup_at = now + stale = list(self._find_stale()) + for conn in stale: + try: + self.selector.unregister(conn.sock) + except (KeyError, ValueError): + pass + conn.tracked = False + for conn in stale: + # Stalled mid-request gets a 408; idle keep-alive gets silently + # dropped. The decision and the bytes-on-wire are the conn's + # job — backends differ. + try: + conn.emit_stale_408() + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + try: + conn.sock.close() + except OSError: + pass + + def track(self, conn: BaseHTTPConn) -> None: + """Register ``conn`` in the selector for normal HTTP processing. + + Safe from any thread. When called from a worker, the actual + ``selector.register`` happens on the selector thread (drained from + ``self._ops`` at the top of the next iteration). ``conn.tracked`` + is flipped optimistically so concurrent readers see the intended + state. + """ + sock = conn.sock + try: + sock.settimeout(0) + except OSError: + conn.tracked = False + return + if self.shutting_down: + try: + sock.close() + except OSError: + pass + conn.tracked = False + return + if conn.tracked: + return # already tracked — no-op, nothing to enqueue + conn.tracked = True # optimistic + if threading.get_ident() == self._selector_thread_id: + self._apply_track(conn) + else: + self._ops.append(_OpTrack(conn)) + self._wake() + + def stop_tracking(self, conn: BaseHTTPConn) -> None: + """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. + + Selector-thread only (called from the dispatcher inside ``BaseServer.run``'s + for-event loop). Switches the socket to blocking-with-timeout + (``rw_timeout``) so the worker can do synchronous send/recv. + Client-disconnect detection while the conn is borrowed lives in + :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). + """ + sock = conn.sock + try: + self.selector.unregister(sock) + except (KeyError, ValueError): + pass + conn.tracked = False + sock.settimeout(self.config.rw_timeout) + + # ----- Op queue + self-pipe helpers ----- + + def _wake(self) -> None: + """Signal the selector that ``self._ops`` has work. + + ``BlockingIOError`` (pipe full) is benign: a wakeup is already pending, + the existing byte will fire ``select()`` and the queue is the source + of truth. Other ``OSError`` (pipe closed during shutdown) is also + swallowed. + """ + try: + os.write(self._wakeup_w, b"\x00") + except (BlockingIOError, OSError): + pass + + def _drain_wakeup(self) -> None: + try: + while os.read(self._wakeup_r, 4096): + pass + except (BlockingIOError, OSError): + pass + + def _drain_ops(self) -> None: + """Apply all pending ops on the selector thread.""" + while True: + try: + op = self._ops.popleft() + except IndexError: + return + try: + if isinstance(op, _OpTrack): + self._apply_track(op.conn) + elif isinstance(op, _OpClose) and op.fd in self.selector.get_map(): + try: + self.selector.unregister(op.fd) + except (KeyError, ValueError): + pass + except Exception: + self.logger.exception("Op handler raised for %r", op) + + def _apply_track(self, conn: BaseHTTPConn) -> None: + """Selector-thread handler for ``_OpTrack``. + + Probes ``selector.get_map()`` (an O(1) dict-``in`` check) to choose + ``modify`` vs ``register``, instead of catching ``KeyError`` from + ``modify``. The error path inside ``selectors.modify`` builds the + exception message as ``f"{fileobj!r}"`` — and ``socket.__repr__`` + is surprisingly expensive (~25 µs/call). Per-request overhead. + """ + sock = conn.sock + sel = self.selector + try: + if conn.fd in sel.get_map(): + sel.modify(sock, selectors.EVENT_READ, data=conn) + else: + sel.register(sock, selectors.EVENT_READ, data=conn) + except Exception: # noqa: BLE001 + conn.tracked = False + try: + sock.close() + except OSError: + pass + + def _shutdown_active_connections(self) -> None: + """Set the shutdown flag and close any connections still in the selector. + + Called from ``start_http_server.__exit__`` *after* the selector loop + has stopped. Workers calling ``track`` / ``close`` post-shutdown + self-clean via the ``shutting_down`` check. + """ + self.shutting_down = True + + # Drain residual ops — selector won't process them, so the conn + # references would otherwise leak. + while True: + try: + op = self._ops.popleft() + except IndexError: + break + if isinstance(op, _OpTrack): + try: + op.conn.sock.close() + except OSError: + pass + op.conn.tracked = False + # _OpClose just cleans _fd_to_key — handled by the walk below. + + for key in list(self.selector.get_map().values()): + if key.fileobj is self.sock: + continue # listening socket — closed by the outer CM + if key.fileobj == self._wakeup_r: + continue # wakeup pipe — closed below + if not isinstance(key.data, BaseHTTPConn): + continue + conn = key.data + try: + self.selector.unregister(conn.sock) + except (KeyError, ValueError): + pass + try: + conn.sock.close() + except OSError: + pass + conn.tracked = False + + # Close the wakeup pipe. + try: + self.selector.unregister(self._wakeup_r) + except (KeyError, ValueError): + pass + for fd in (self._wakeup_r, self._wakeup_w): + try: + os.close(fd) + except OSError: + pass + + def run(self, *, timeout: float | None = None) -> None: + """One iteration of the server loop. Should be called repeatedly until the server is stopped. + + ``timeout`` bounds the underlying ``selector.select`` call — it caps how long this + method blocks before returning to the caller, giving the caller a chance to check + for shutdown / cancellation. Defaults to ``config.select_timeout``. + """ + if self._selector_thread_id is None: + self._selector_thread_id = threading.get_ident() + if timeout is None: + timeout = self.config.select_timeout + server_sock = self.sock + h = self.handler + self._drain_ops() + self._cleanup_stale() + for key, _ in self.selector.select(timeout=timeout): + data = key.data + if data is _WAKEUP_SENTINEL: + self._drain_wakeup() + self._drain_ops() + continue + if key.fileobj is server_sock: + client_sock, client_addr = server_sock.accept() + conn = self._conn_factory(self, client_sock, client_addr) + self.track(conn) + continue + if isinstance(data, BaseHTTPConn): + try: + data(h) + except Exception: + self.logger.exception("Unhandled exception from connection %s", data.addr) + try: + data.close() + except Exception: # noqa: BLE001, S110 + pass + else: + raise RuntimeError(f"Unexpected selector key: {key!r}") # noqa: TRY004 + + +@contextmanager +def start_http_server_base( + config: ServerConfig, + handler: RequestHandler, + conn_factory: _ConnFactory, + /, +) -> Iterator[BaseServer]: + """Open a listening socket and yield a :class:`BaseServer` driving ``conn_factory``. + + Each public entry point (``start_http_server``, ``start_httptools_server``) + is a thin wrapper supplying its own conn factory. The handler is fixed + for the lifetime of the server. + """ + logger = logging.getLogger(LOGGER_NAME) + server_sock = socket.create_server( + (config.host, config.port), + backlog=config.backlog, + reuse_port=True, + ) + selector = selectors.DefaultSelector() + + server_sock.settimeout(0) + selector.register(server_sock, selectors.EVENT_READ) + + with closing(server_sock), closing(selector): + server = BaseServer(config, handler, logger, server_sock, selector, conn_factory) + logger.info("Serving on %s:%d", config.host, server.port) + try: + yield server + finally: + server._shutdown_active_connections() diff --git a/localpost/http/_service.py b/localpost/http/_service.py index b080c16..fa66477 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,17 +1,36 @@ from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable +from contextlib import AbstractContextManager from wsgiref.types import WSGIApplication from anyio import from_thread, to_thread from localpost import hosting from localpost.hosting import ServiceLifetime +from localpost.http._base import BaseServer from localpost.http.config import ServerConfig from localpost.http.server import RequestHandler, start_http_server from localpost.http.wsgi import wrap_wsgi -__all__ = ["http_server", "wsgi_server"] +__all__ = ["http_server", "httptools_server", "wsgi_server"] + + +_StartFn = Callable[[ServerConfig, RequestHandler], AbstractContextManager[BaseServer]] + + +def _serve(config: ServerConfig, handler: RequestHandler, start: _StartFn): + def run(lt: ServiceLifetime) -> Awaitable[None]: + def run_server() -> None: + with start(config, handler) as server: + lt.set_started() + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + server.run() + + return to_thread.run_sync(run_server) + + return run @hosting.service @@ -30,18 +49,22 @@ def http_server(config: ServerConfig, handler: RequestHandler, /): needs it — selector-thread handlers run to completion synchronously and have no thread to signal). """ + return _serve(config, handler, start_http_server) - def run(lt: ServiceLifetime) -> Awaitable[None]: - def run_server() -> None: - with start_http_server(config, handler) as server: - lt.set_started() - while not lt.shutting_down.is_set(): - from_thread.check_cancelled() - server.run() - return to_thread.run_sync(run_server) +@hosting.service +def httptools_server(config: ServerConfig, handler: RequestHandler, /): + """Same as :func:`http_server`, but using the httptools backend. - return run + Requires the ``[http-fast]`` extra. See + :func:`localpost.http.start_httptools_server` for backend differences. + """ + # Lazy: importing server_httptools at module top would require httptools to + # be installed for users that only need ``http_server`` (h11). The extra + # ``[http-fast]`` is opt-in. + from localpost.http.server_httptools import start_httptools_server # noqa: PLC0415 + + return _serve(config, handler, start_httptools_server) def wsgi_server(config: ServerConfig, app: WSGIApplication, /): diff --git a/localpost/http/_types.py b/localpost/http/_types.py new file mode 100644 index 0000000..8d81738 --- /dev/null +++ b/localpost/http/_types.py @@ -0,0 +1,65 @@ +"""Neutral HTTP message types — independent of any specific parser. + +Both server backends (h11, httptools) populate the same shapes. User code +imports these from :mod:`localpost.http` and never touches a parser-specific +type directly. + +Field shapes intentionally match h11's wire-level conventions: + +- header names are lowercased ASCII bytes +- header values are bytes (server-side: ISO-8859-1 / ASCII per RFC 7230) +- ``http_version`` is the bare version (``b"1.1"``), not the prefixed form +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +__all__ = [ + "Request", + "Response", + "InformationalResponse", + "BodyTooLarge", +] + + +@dataclass(frozen=True, slots=True) +class Request: + """Parsed HTTP request line + headers. Body is streamed via :meth:`HTTPReqCtx.receive`.""" + + method: bytes + """e.g. ``b"GET"`` — uppercased ASCII as sent by the client.""" + target: bytes + """Raw request-URI from the request line, including query string.""" + headers: list[tuple[bytes, bytes]] + """Header pairs in arrival order. Names are lowercased; values are as-sent.""" + http_version: bytes = b"1.1" + """HTTP version as bare bytes (``b"1.1"`` or ``b"1.0"``).""" + + +@dataclass(frozen=True, slots=True) +class Response: + """Final response (2xx-5xx) — exactly one per request.""" + + status_code: int + headers: list[tuple[bytes, bytes]] + reason: bytes = b"" + """Reason phrase. Empty → backend supplies a default for the status code.""" + + +@dataclass(frozen=True, slots=True) +class InformationalResponse: + """1xx response (100 Continue, 102 Processing, …). Multiple may precede the final response.""" + + status_code: int + headers: list[tuple[bytes, bytes]] = field(default_factory=list) + reason: bytes = b"" + + +class BodyTooLarge(Exception): + """Raised when an incoming request body would exceed ``ServerConfig.max_body_size``. + + Surfaces both from ``HTTPReqCtx.receive`` (when the handler is reading the body) + and from the connection's drain path (when the handler skipped reading it). The + connection loop converts it into a 413 Payload Too Large response. + """ diff --git a/localpost/http/flask.py b/localpost/http/flask.py index ae287c2..310be62 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -18,10 +18,10 @@ from __future__ import annotations -import h11 from flask import Flask from localpost.http._service import http_server +from localpost.http._types import Response as _NativeResponse from localpost.http.config import ServerConfig from localpost.http.server import HTTPReqCtx, RequestHandler from localpost.http.wsgi import _build_environ @@ -57,11 +57,11 @@ def handle(http_ctx: HTTPReqCtx) -> None: def _write_response(http_ctx: HTTPReqCtx, response) -> None: # response is a werkzeug.Response (Flask's Response subclasses it) reason = (response.status.split(" ", 1)[1] if " " in response.status else "").encode("iso-8859-1") - h11_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items()] + wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items()] http_ctx.start_response( - h11.Response( + _NativeResponse( status_code=response.status_code, - headers=h11_headers, + headers=wire_headers, reason=reason, ) ) diff --git a/localpost/http/router.py b/localpost/http/router.py index 4745307..407bee4 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -9,9 +9,8 @@ from typing import Self, final from urllib.parse import parse_qs -import h11 - from localpost._utils import NOT_SET +from localpost.http._types import Response as _NativeResponse from localpost.http.config import DEFAULT_BUFFER_SIZE from localpost.http.server import HTTPReqCtx from localpost.http.server import RequestHandler as NativeRequestHandler @@ -350,11 +349,11 @@ def handle(http_ctx: HTTPReqCtx) -> None: ) response = match.handler(ctx) - h11_headers = [(k.encode("iso-8859-1"), v.encode("iso-8859-1")) for k, v in response.headers.items()] + wire_headers = [(k.encode("iso-8859-1"), v.encode("iso-8859-1")) for k, v in response.headers.items()] http_ctx.start_response( - h11.Response( + _NativeResponse( status_code=response.status_code, - headers=h11_headers, + headers=wire_headers, reason=_http_phrases.get(response.status_code, "Unknown").encode("iso-8859-1"), ) ) @@ -446,7 +445,7 @@ def _send_plain( if extra_headers: headers.extend(extra_headers) ctx.complete( - h11.Response( + _NativeResponse( status_code=status_code, headers=headers, reason=_http_phrases.get(status_code, "Unknown").encode("iso-8859-1"), diff --git a/localpost/http/server.py b/localpost/http/server.py index e01a16c..2aa28ad 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -1,771 +1,33 @@ -""" -Simple WSGI server implementation using h11 for HTTP protocol handling. +"""Back-compat re-export for the historic ``localpost.http.server`` import path. -Notes: -- ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. -- The server supports keep-alive connections and graceful shutdown. +The h11 server lives in :mod:`localpost.http.server_h11` and is the default +backend. New code should import from :mod:`localpost.http`. """ from __future__ import annotations -import collections -import fcntl -import logging -import os -import selectors -import socket -import threading -import time -from collections.abc import Buffer, Callable, Iterator -from contextlib import closing, contextmanager -from dataclasses import dataclass, field -from typing import final - -import h11 - -from localpost.http.config import DEFAULT_BUFFER_SIZE, LOGGER_NAME, ServerConfig - -__all__ = ["start_http_server", "HTTPReqCtx", "RequestHandler"] - - -# Selector data-tag for ``Server._wakeup_r`` — distinguishable in the for-event -# loop so the selector thread knows to drain the wakeup pipe + op queue. -_WAKEUP_SENTINEL: object = object() - - -@final -@dataclass(eq=False, slots=True, frozen=True) -class _OpTrack: - """Worker-enqueued op: register ``conn`` in the selector with ``data=conn``.""" - - conn: HTTPConn - - -@final -@dataclass(eq=False, slots=True, frozen=True) -class _OpClose: - """Worker-enqueued op: clean up ``selector._fd_to_key[fd]`` after ``sock.close()``. - - The kernel auto-removes the fd from kqueue/epoll on ``close()``. The - Python-side ``_fd_to_key`` dict is what we actually need to clean up so - the conn instance can be GC'd. - """ - - fd: int - - -_Op = _OpTrack | _OpClose - - -_INTERNAL_ERROR_BODY = b"Internal Server Error" -_INTERNAL_ERROR_RESPONSE = h11.Response( - status_code=500, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(_INTERNAL_ERROR_BODY)).encode("ascii")), - (b"connection", b"close"), - ], +from localpost.http._base import ( + BaseServer as Server, ) - -_BAD_REQUEST_BODY = b"Bad Request" -_BAD_REQUEST_RESPONSE = h11.Response( - status_code=400, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(_BAD_REQUEST_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) - -_PAYLOAD_TOO_LARGE_BODY = b"Payload Too Large" -_PAYLOAD_TOO_LARGE_RESPONSE = h11.Response( - status_code=413, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(_PAYLOAD_TOO_LARGE_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) - -_REQUEST_TIMEOUT_BODY = b"Request Timeout" -_REQUEST_TIMEOUT_RESPONSE = h11.Response( - status_code=408, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(_REQUEST_TIMEOUT_BODY)).encode("ascii")), - (b"connection", b"close"), - ], +from localpost.http._base import ( + HTTPReqCtx, + RequestHandler, + emit_handler_error, ) - - -class BodyTooLarge(Exception): - """Raised when an incoming request body would exceed ``ServerConfig.max_body_size``. - - Surfaces both from ``HTTPReqCtx.receive`` (when the handler is reading the body) - and from ``HTTPConn``'s drain path (when the handler skipped reading it). The - connection loop converts it into a 413 Payload Too Large response. - """ - - -def _content_length(headers) -> int | None: - # h11 normalizes header names to lowercase bytes — direct equality is enough. - for name, value in headers: - if name == b"content-length": - try: - return int(value) - except ValueError: - return None - return None - - -def emit_handler_error(ctx: HTTPReqCtx) -> None: - """Best-effort recovery when a request handler raises. - - Emits a 500 response if no headers have been sent yet; otherwise closes - the connection (we can't go back and prepend a status line to bytes - already on the wire). All I/O failures are swallowed — the goal is to - avoid amplifying one error into another. - """ - logger = logging.getLogger(LOGGER_NAME) - if ctx.response_status is None: - try: - ctx.complete(_INTERNAL_ERROR_RESPONSE, _INTERNAL_ERROR_BODY) - except Exception: - logger.exception("Failed to send 500 after handler error; closing") - else: - return - ctx._conn.close() - - -@contextmanager -def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Iterator[Server]: - """Open a listening socket and yield a ``Server`` bound to ``handler``. - - The handler is fixed for the lifetime of the server — every accepted request - is dispatched to it. Per-iteration overrides are not supported. - - On context exit the server signals shutdown, closes any active client - connections still registered in the selector (idle keep-alive or - mid-request), and tears down the listening socket. Borrowed connections - held by handler threads are closed when the handler returns and tries to - re-register them on the (now shutting-down) server. - """ - logger = logging.getLogger(LOGGER_NAME) - server_sock = socket.create_server( - (config.host, config.port), - backlog=config.backlog, - reuse_port=True, - ) - selector = selectors.DefaultSelector() - - server_sock.settimeout(0) - selector.register(server_sock, selectors.EVENT_READ) - - # server_sock.close() # Safe to call it from another thread, will cause accept() to raise OSError - with closing(server_sock), closing(selector): - server = Server(config, handler, logger, server_sock, selector) - logger.info("Serving on %s:%d", config.host, server.port) - try: - yield server - finally: - server._shutdown_active_connections() - - -@final -class Server: - def __init__( - self, - config: ServerConfig, - handler: RequestHandler, - logger: logging.Logger, - server_sock: socket.socket, - selector: selectors.BaseSelector, - ) -> None: - self.sock = server_sock - self.port: int = server_sock.getsockname()[1] - """ - Actual port the server is listening on. - - Can be useful when port 0 is specified to auto-assign a free port. - """ - self.selector = selector - self.config = config - self.handler = handler - self.logger = logger - self.shutting_down: bool = False - """Set to True on context-manager exit. Once set, ``track`` rejects - new registrations and ``_maybe_give_back`` closes connections - instead of returning them to the selector.""" - # Lock-free op queue + self-pipe wakeup. The selector thread is the - # single writer to ``self.selector``; cross-thread mutations from - # workers (``track`` from ``_maybe_give_back``, ``close`` from error - # paths) enqueue ops and write a wakeup byte. The selector drains at - # the top of every iteration and on wakeup-sentinel events. - self._ops: collections.deque[_Op] = collections.deque() - r, w = os.pipe() - os.set_blocking(r, False) - os.set_blocking(w, False) - for fd in (r, w): - flags = fcntl.fcntl(fd, fcntl.F_GETFD) - fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) - self._wakeup_r: int = r - self._wakeup_w: int = w - selector.register(r, selectors.EVENT_READ, data=_WAKEUP_SENTINEL) - self._selector_thread_id: int | None = None - """Cached on the first ``run()`` call. Used to route ``track`` / - ``close`` calls inline when invoked from the selector thread itself - (e.g. from the accept branch of ``run``).""" - # Stale-connection cleanup runs lazily — at most once per - # ``_cleanup_interval``. Avoids walking ``selector.get_map()`` on - # every iteration when nothing's actually stale (which is the - # common case under load: keep_alive_timeout=15s, rw_timeout=1s). - # Floor at 100 ms so users with degenerate tiny timeouts don't - # pathologically over-cleanup. - self._cleanup_interval: float = max( - min(config.rw_timeout, config.keep_alive_timeout) / 2, - 0.1, - ) - self._last_cleanup_at: float = 0.0 - - def _find_stale(self): - now = time.monotonic() - for key in self.selector.get_map().values(): - if (conn := key.data) and isinstance(conn, HTTPConn) and conn.close_at and now > conn.close_at: - yield conn - - def _cleanup_stale(self): - # Selector-thread only. Lock-free: ``self.selector`` and - # ``conn.tracked`` are owned by the selector thread. - # - # We deliberately keep this on the selector thread rather than a - # dedicated cleanup thread. With the lazy gate below the common - # case is an O(1) timestamp compare (no walk); the actual O(N) - # sweep runs at most every ``_cleanup_interval``, ~twice a second - # by default. A separate thread would either need a lock around - # ``selector.get_map()`` (losing the lock-free property of the - # current op-queue model) or maintain a parallel data structure - # — both add machinery without buying anything visible. - # - # Lazy: skip the O(N) walk over registered conns when not enough - # time has passed since the last sweep. Worst-case extra detection - # latency is ``_cleanup_interval`` (default 0.5 s) on top of the - # configured ``rw_timeout`` / ``keep_alive_timeout`` — both already - # measure non-fatal conditions. - now = time.monotonic() - if now - self._last_cleanup_at < self._cleanup_interval: - return - self._last_cleanup_at = now - stale = list(self._find_stale()) - for conn in stale: - try: - self.selector.unregister(conn.sock) - except (KeyError, ValueError): - pass - conn.tracked = False - for conn in stale: - # Stalled mid-request (some bytes received but no complete request - # yet, and no response started) gets a 408; idle keep-alive gets - # silently dropped. - if not conn.idle and conn.parser.our_state is h11.IDLE: - conn.sock.settimeout(self.config.rw_timeout) - try: - payload = conn.parser.send(_REQUEST_TIMEOUT_RESPONSE) - if payload: - conn.sock.sendall(payload) - payload = conn.parser.send(h11.Data(data=_REQUEST_TIMEOUT_BODY)) - if payload: - conn.sock.sendall(payload) - payload = conn.parser.send(h11.EndOfMessage()) - if payload: - conn.sock.sendall(payload) - except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway - pass - try: - conn.sock.close() - except OSError: - pass - - def track(self, conn: HTTPConn) -> None: - """Register ``conn`` in the selector for normal HTTP processing. - - Safe from any thread. When called from a worker, the actual - ``selector.register`` happens on the selector thread (drained from - ``self._ops`` at the top of the next iteration). ``conn.tracked`` - is flipped optimistically so concurrent readers see the intended - state. - """ - sock = conn.sock - try: - sock.settimeout(0) - except OSError: - conn.tracked = False - return - if self.shutting_down: - try: - sock.close() - except OSError: - pass - conn.tracked = False - return - if conn.tracked: - return # already tracked — no-op, nothing to enqueue - conn.tracked = True # optimistic - if threading.get_ident() == self._selector_thread_id: - self._apply_track(conn) - else: - self._ops.append(_OpTrack(conn)) - self._wake() - - def stop_tracking(self, conn: HTTPConn) -> None: - """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. - - Selector-thread only (called from the dispatcher inside ``Server.run``'s - for-event loop). Switches the socket to blocking-with-timeout - (``rw_timeout``) so the worker can do synchronous send/recv. - Client-disconnect detection while the conn is borrowed lives in - :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). - """ - sock = conn.sock - try: - self.selector.unregister(sock) - except (KeyError, ValueError): - pass - conn.tracked = False - sock.settimeout(self.config.rw_timeout) - - # ----- Op queue + self-pipe helpers ----- - - def _wake(self) -> None: - """Signal the selector that ``self._ops`` has work. - - ``BlockingIOError`` (pipe full) is benign: a wakeup is already pending, - the existing byte will fire ``select()`` and the queue is the source - of truth. Other ``OSError`` (pipe closed during shutdown) is also - swallowed. - """ - try: - os.write(self._wakeup_w, b"\x00") - except (BlockingIOError, OSError): - pass - - def _drain_wakeup(self) -> None: - try: - while os.read(self._wakeup_r, 4096): - pass - except (BlockingIOError, OSError): - pass - - def _drain_ops(self) -> None: - """Apply all pending ops on the selector thread.""" - while True: - try: - op = self._ops.popleft() - except IndexError: - return - try: - if isinstance(op, _OpTrack): - self._apply_track(op.conn) - elif isinstance(op, _OpClose) and op.fd in self.selector.get_map(): - try: - self.selector.unregister(op.fd) - except (KeyError, ValueError): - pass - except Exception: - self.logger.exception("Op handler raised for %r", op) - - def _apply_track(self, conn: HTTPConn) -> None: - """Selector-thread handler for ``_OpTrack``. - - Probes ``selector.get_map()`` (an O(1) dict-``in`` check) to choose - ``modify`` vs ``register``, instead of catching ``KeyError`` from - ``modify``. The error path inside ``selectors.modify`` builds the - exception message as ``f"{fileobj!r}"`` — and ``socket.__repr__`` - is surprisingly expensive (~25 µs/call). Per-request overhead. - """ - sock = conn.sock - sel = self.selector - try: - if conn.fd in sel.get_map(): - sel.modify(sock, selectors.EVENT_READ, data=conn) - else: - sel.register(sock, selectors.EVENT_READ, data=conn) - except Exception: # noqa: BLE001 - conn.tracked = False - try: - sock.close() - except OSError: - pass - - def _shutdown_active_connections(self) -> None: - """Set the shutdown flag and close any connections still in the selector. - - Called from ``start_http_server.__exit__`` *after* the selector loop - has stopped (verified in ``_service.py:run_server`` and - ``serve_in_thread`` test fixture). Workers calling ``track`` / - ``close`` post-shutdown self-clean via the ``shutting_down`` check. - """ - self.shutting_down = True - - # Drain residual ops — selector won't process them, so the conn - # references would otherwise leak. - while True: - try: - op = self._ops.popleft() - except IndexError: - break - if isinstance(op, _OpTrack): - try: - op.conn.sock.close() - except OSError: - pass - op.conn.tracked = False - # _OpClose just cleans _fd_to_key — handled by the walk below. - - for key in list(self.selector.get_map().values()): - if key.fileobj is self.sock: - continue # listening socket — closed by the outer CM - if key.fileobj == self._wakeup_r: - continue # wakeup pipe — closed below - if not isinstance(key.data, HTTPConn): - continue - conn = key.data - try: - self.selector.unregister(conn.sock) - except (KeyError, ValueError): - pass - try: - conn.sock.close() - except OSError: - pass - conn.tracked = False - - # Close the wakeup pipe. - try: - self.selector.unregister(self._wakeup_r) - except (KeyError, ValueError): - pass - for fd in (self._wakeup_r, self._wakeup_w): - try: - os.close(fd) - except OSError: - pass - - def run(self, *, timeout: float | None = None) -> None: - """One iteration of the server loop. Should be called repeatedly until the server is stopped. - - ``timeout`` bounds the underlying ``selector.select`` call — it caps how long this - method blocks before returning to the caller, giving the caller a chance to check - for shutdown / cancellation. Defaults to ``config.select_timeout``. - """ - if self._selector_thread_id is None: - self._selector_thread_id = threading.get_ident() - if timeout is None: - timeout = self.config.select_timeout - server_sock = self.sock - h = self.handler - self._drain_ops() - self._cleanup_stale() - for key, _ in self.selector.select(timeout=timeout): - data = key.data - if data is _WAKEUP_SENTINEL: - self._drain_wakeup() - self._drain_ops() - continue - if key.fileobj is server_sock: - client_sock, client_addr = server_sock.accept() - conn = HTTPConn(self, client_sock, client_addr) - self.track(conn) - continue - if isinstance(data, HTTPConn): - try: - data(h) - except Exception: - self.logger.exception("Unhandled exception from connection %s", data.addr) - try: - data.close() - except Exception: # noqa: BLE001, S110 - pass - else: - raise RuntimeError(f"Unexpected selector key: {key!r}") # noqa: TRY004 - - -@final -@dataclass(eq=False, slots=True) -class HTTPConn: - server: Server - sock: socket.socket - addr: tuple[str, int] - fd: int = field(init=False) - """The integer file descriptor captured at construction time. Used to - clean up ``selector._fd_to_key`` after ``sock.close()`` (where - ``sock.fileno()`` returns -1).""" - recv_closed: bool = False - parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) - close_at: float | None = None # Used only when tracked, to enforce keep-alive and read timeouts - tracked: bool = False - """``True`` iff this conn is registered in the selector. ``False`` while a - worker has borrowed it (between ``stop_tracking`` and the next ``track``).""" - body_bytes_received: int = 0 - """Cumulative body bytes received for the current request — reset on - ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` - to enforce the upload cap.""" - idle: bool = True - """``True`` between requests (after ``start_next_cycle``) or before the - first byte arrives. Flips to ``False`` once any byte has been received - for the current request. Distinguishes idle keep-alive (close silently) - from a stalled mid-request (emit 408 Request Timeout).""" - - def __post_init__(self) -> None: - self.fd = self.sock.fileno() - - def close(self) -> None: - """Tear down the connection. - - Synchronously sends FIN + closes the socket so the kernel fd is - freed immediately. The selector-side ``_fd_to_key`` cleanup happens - on the selector thread — inline if we are it, else enqueued via - ``_OpClose``. Safe to call from any thread. - """ - was_tracked = self.tracked - self.tracked = False - try: - self.sock.shutdown(socket.SHUT_WR) - except OSError: - pass - try: - self.sock.close() - except OSError: - pass - if not was_tracked: - return - # Selector still has _fd_to_key[fd]. Clean up. - if threading.get_ident() == self.server._selector_thread_id: - sel = self.server.selector - if self.fd in sel.get_map(): - try: - sel.unregister(self.fd) - except (KeyError, ValueError): - pass - else: - self.server._ops.append(_OpClose(self.fd)) - self.server._wake() - - def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: - data = self.sock.recv(size) - self.parser.receive_data(data) - if not data: - self.recv_closed = True - else: - self.idle = False - - # Helper method when a req (conn) is borrowed - def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11.EndOfMessage) -> None: - payload = self.parser.send(event) - payload_len = len(payload) - sock, total_sent = self.sock, 0 - while total_sent < payload_len: - sent = sock.send(payload[total_sent:]) - if sent == 0: - raise ConnectionAbortedError("socket is broken") - total_sent = total_sent + sent - - def __call__(self, h: RequestHandler) -> None: - try: - self._loop(h) - except h11.RemoteProtocolError as e: - self.server.logger.warning("Bad client input from %s: %s", self.addr, e) - self._try_send_status(_BAD_REQUEST_RESPONSE, _BAD_REQUEST_BODY) - self.close() - except h11.LocalProtocolError: - self.server.logger.exception("Local protocol error from %s", self.addr) - self._try_send_status(_INTERNAL_ERROR_RESPONSE, _INTERNAL_ERROR_BODY) - self.close() - except BodyTooLarge: - self.server.logger.warning( - "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size - ) - self._try_send_status(_PAYLOAD_TOO_LARGE_RESPONSE, _PAYLOAD_TOO_LARGE_BODY) - self.close() - - def _loop(self, h: RequestHandler) -> None: - parser = self.parser - - while self.tracked: - if parser.our_state is h11.MUST_CLOSE: - self.close() # close() shuts down WR + unregisters - return - if parser.our_state is h11.DONE and parser.their_state is h11.DONE: - parser.start_next_cycle() - self.body_bytes_received = 0 - self.idle = True - self.close_at = time.monotonic() + self.server.config.keep_alive_timeout - - event = parser.next_event() - - if event is h11.NEED_DATA: - if parser.they_are_waiting_for_100_continue: # Drain the request body - self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) - continue - try: - self.receive() - except BlockingIOError: - return # Wait for it in the selector - self.close_at = time.monotonic() + self.server.config.rw_timeout - elif isinstance(event, h11.Data): - self.body_bytes_received += len(event.data) - if self.body_bytes_received > self.server.config.max_body_size: - raise BodyTooLarge(self.body_bytes_received) - continue # Drain the request body - elif isinstance(event, h11.EndOfMessage): - continue - elif isinstance(event, h11.Request): - cl = _content_length(event.headers) - if cl is not None and cl > self.server.config.max_body_size: - raise BodyTooLarge(cl) - req_ctx = HTTPReqCtx(self.server, self, event) - try: - h(req_ctx) - except BodyTooLarge: - raise # outer handler emits 413 - except Exception: - self.server.logger.exception("Handler raised for %s %r", event.method, event.target) - emit_handler_error(req_ctx) - if req_ctx.borrowed: - return - if not self.tracked: - return # connection was closed during error recovery - elif isinstance(event, h11.ConnectionClosed): - self.server.logger.debug("Client closed connection") - self.close() - return - else: - raise RuntimeError(f"Unexpected {event!r} in the connection loop") - - def _try_send_status(self, response: h11.Response, body: bytes) -> None: - """Best-effort: try to send a response if the parser is still in a writable state. - - Used as a recovery path when the connection is about to be closed due to a - protocol error. Failures are silently swallowed. - """ - if self.parser.our_state is not h11.IDLE and self.parser.our_state is not h11.SEND_RESPONSE: - return - try: - self.send(response) - if body: - self.send(h11.Data(data=body)) - self.send(h11.EndOfMessage()) - except Exception: # noqa: BLE001, S110 — connection is being closed anyway - pass - - -@dataclass(eq=False, frozen=True, slots=True) -class HTTPReqCtx: - _server: Server - _conn: HTTPConn - request: h11.Request - - response_status: int | None = field(default=None, init=False) - """Status code of the response sent for this request (set by start_response / complete).""" - - @property - def borrowed(self) -> bool: - return not self._conn.tracked - - @contextmanager - def borrow(self): - """Switch the conn out of selector tracking for the duration of the block. - - Useful for selector-thread handlers that need to do blocking I/O - (e.g. reading a request body after sending ``100 Continue``). - ``finish_response`` re-tracks the conn via ``_maybe_give_back`` on - the success path; on early exit we re-track here. - - The :func:`thread_pool_handler` dispatch path doesn't go through this - CM — it calls ``stop_tracking`` directly before queueing the conn to - a worker, and the response-completion logic re-tracks via - ``finish_response``. - """ - assert not self.borrowed - self._server.stop_tracking(self._conn) - try: - yield self - finally: - self._maybe_give_back() - - def _maybe_give_back(self) -> None: - if self.borrowed: - self._server.track(self._conn) - - # Usually with a simple response, like 404 or 405, with a small body (so it can fit into the kernel socket buffer, - # to not block the server thread) - def complete(self, response: h11.Response, body: bytes | None = None) -> None: - self.start_response(response) - if body is not None: - self.send(body) - self.finish_response() - - def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - parser = self._conn.parser - if parser.their_state is h11.DONE: # Request body exhausted - return b"" - if parser.they_are_waiting_for_100_continue: - self._conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) - while True: - event = parser.next_event() - if event is h11.NEED_DATA: - # Sync handlers can't tolerate BlockingIOError on a non-blocking - # socket: switch to a brief blocking read bounded by rw_timeout, - # then restore. Borrowed connections are already blocking, so - # the settimeout calls are no-ops on the rw_timeout value there. - sock = self._conn.sock - rw = self._server.config.rw_timeout - try: - self._conn.receive(size) - except BlockingIOError: - sock.settimeout(rw) - try: - self._conn.receive(size) - finally: - if self._conn.tracked: - sock.settimeout(0) - elif isinstance(event, h11.Data): - self._conn.body_bytes_received += len(event.data) - if self._conn.body_bytes_received > self._server.config.max_body_size: - raise BodyTooLarge(self._conn.body_bytes_received) - return event.data - elif isinstance(event, h11.EndOfMessage): - return b"" - else: # h11.ConnectionClosed is not possible, it will be a protocol error - raise RuntimeError(f"Unexpected h11 event: {event!r}") - - def start_response(self, response: h11.Response | h11.InformationalResponse, /) -> None: - if isinstance(response, h11.Response): - object.__setattr__(self, "response_status", response.status_code) - self._conn.send(response) - - def send(self, chunk: Buffer, /) -> None: - # h11 wants bytes; widen the public API to any Buffer (memoryview, - # bytearray, …) so callers can avoid an explicit copy. - self._conn.send(h11.Data(bytes(chunk) if not isinstance(chunk, bytes) else chunk)) - - def finish_response(self) -> None: - self._conn.send(h11.EndOfMessage()) - # Drain h11's pending ``EndOfMessage`` for the request side before - # giving the conn back. For a no-body request the selector parsed - # ``Request`` and stopped — h11 still has the implicit EndOfMessage - # queued, and ``their_state`` won't reach ``DONE`` until something - # consumes it. Without this drain, the next selector wake on a - # keep-alive request hits ``PAUSED`` from ``parser.next_event``. - # - # If h11 needs more bytes (handler didn't read a non-empty body), - # close the conn — keep-alive isn't safe with un-drained body bytes. - parser = self._conn.parser - while parser.their_state is not h11.DONE: - event = parser.next_event() - if event is h11.NEED_DATA or event is h11.PAUSED or isinstance(event, h11.ConnectionClosed): - self._conn.close() - return - self._maybe_give_back() - - -RequestHandler = Callable[[HTTPReqCtx], None] +from localpost.http._types import BodyTooLarge +from localpost.http.server_h11 import HTTPConnH11 as HTTPConn +from localpost.http.server_h11 import HTTPReqCtxH11, start_http_server + +# Historic alias — concrete type, used in some test type hints. +HTTPReqCtxConcrete = HTTPReqCtxH11 + +__all__ = [ + "start_http_server", + "Server", + "HTTPConn", + "HTTPReqCtx", + "HTTPReqCtxH11", + "RequestHandler", + "BodyTooLarge", + "emit_handler_error", +] diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py new file mode 100644 index 0000000..ba243bc --- /dev/null +++ b/localpost/http/server_h11.py @@ -0,0 +1,324 @@ +"""HTTP/1.1 server backend driven by h11. + +Pure-Python parser/state-machine. The default backend; readable, no C deps. +For the C-based alternative see :mod:`localpost.http.server_httptools`. +""" + +from __future__ import annotations + +import socket +import time +from collections.abc import Buffer, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import final + +import h11 + +from localpost.http._base import ( + BAD_REQUEST_BODY, + BAD_REQUEST_RESPONSE, + INTERNAL_ERROR_BODY, + INTERNAL_ERROR_RESPONSE, + PAYLOAD_TOO_LARGE_BODY, + PAYLOAD_TOO_LARGE_RESPONSE, + REQUEST_TIMEOUT_BODY, + REQUEST_TIMEOUT_RESPONSE, + BaseHTTPConn, + BaseServer, + RequestHandler, + emit_handler_error, + start_http_server_base, +) +from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.config import DEFAULT_BUFFER_SIZE, ServerConfig + +__all__ = ["start_http_server", "HTTPConnH11", "HTTPReqCtxH11"] + + +def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11.InformationalResponse: + if isinstance(r, Response): + return h11.Response(status_code=r.status_code, headers=r.headers, reason=r.reason) + return h11.InformationalResponse(status_code=r.status_code, headers=r.headers, reason=r.reason) + + +def _content_length(headers) -> int | None: + # h11 normalizes header names to lowercase bytes — direct equality is enough. + for name, value in headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + +@final +@dataclass(eq=False, slots=True) +class HTTPConnH11(BaseHTTPConn): + server: BaseServer + sock: socket.socket + addr: tuple[str, int] + fd: int = field(init=False) + """The integer file descriptor captured at construction time. Used to + clean up ``selector._fd_to_key`` after ``sock.close()`` (where + ``sock.fileno()`` returns -1).""" + recv_closed: bool = False + parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) + close_at: float | None = None + tracked: bool = False + body_bytes_received: int = 0 + """Cumulative body bytes received for the current request — reset on + ``parser.start_next_cycle``. Compared against ``ServerConfig.max_body_size`` + to enforce the upload cap.""" + idle: bool = True + + def __post_init__(self) -> None: + self.fd = self.sock.fileno() + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> None: + data = self.sock.recv(size) + self.parser.receive_data(data) + if not data: + self.recv_closed = True + else: + self.idle = False + + def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11.EndOfMessage) -> None: + payload = self.parser.send(event) + if payload is None: + return + payload_len = len(payload) + sock, total_sent = self.sock, 0 + while total_sent < payload_len: + sent = sock.send(payload[total_sent:]) + if sent == 0: + raise ConnectionAbortedError("socket is broken") + total_sent = total_sent + sent + + def __call__(self, h: RequestHandler, /) -> None: + try: + self._loop(h) + except h11.RemoteProtocolError as e: + self.server.logger.warning("Bad client input from %s: %s", self.addr, e) + self._try_send_status(BAD_REQUEST_RESPONSE, BAD_REQUEST_BODY) + self.close() + except h11.LocalProtocolError: + self.server.logger.exception("Local protocol error from %s", self.addr) + self._try_send_status(INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_BODY) + self.close() + except BodyTooLarge: + self.server.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size + ) + self._try_send_status(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) + self.close() + + def _loop(self, h: RequestHandler) -> None: + parser = self.parser + + while self.tracked: + if parser.our_state is h11.MUST_CLOSE: + self.close() + return + if parser.our_state is h11.DONE and parser.their_state is h11.DONE: + parser.start_next_cycle() + self.body_bytes_received = 0 + self.idle = True + self.close_at = time.monotonic() + self.server.config.keep_alive_timeout + + event = parser.next_event() + + if event is h11.NEED_DATA: + if parser.they_are_waiting_for_100_continue: + self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) + continue + try: + self.receive() + except BlockingIOError: + return # Wait for it in the selector + self.close_at = time.monotonic() + self.server.config.rw_timeout + elif isinstance(event, h11.Data): + self.body_bytes_received += len(event.data) + if self.body_bytes_received > self.server.config.max_body_size: + raise BodyTooLarge(self.body_bytes_received) + continue # Drain the request body + elif isinstance(event, h11.EndOfMessage): + continue + elif isinstance(event, h11.Request): + cl = _content_length(event.headers) + if cl is not None and cl > self.server.config.max_body_size: + raise BodyTooLarge(cl) + req = Request( + method=bytes(event.method), + target=bytes(event.target), + headers=[(bytes(n), bytes(v)) for n, v in event.headers], + http_version=bytes(event.http_version), + ) + req_ctx = HTTPReqCtxH11(self.server, self, req) + try: + h(req_ctx) + except BodyTooLarge: + raise + except Exception: + self.server.logger.exception("Handler raised for %s %r", event.method, event.target) + emit_handler_error(req_ctx) + if req_ctx.borrowed: + return + if not self.tracked: + return # connection was closed during error recovery + elif isinstance(event, h11.ConnectionClosed): + self.server.logger.debug("Client closed connection") + self.close() + return + else: + raise RuntimeError(f"Unexpected {event!r} in the connection loop") + + def _try_send_status(self, response: Response, body: bytes) -> None: + """Best-effort: try to send a response if the parser is still in a writable state. + + Used as a recovery path when the connection is about to be closed due to a + protocol error. Failures are silently swallowed. + """ + if self.parser.our_state is not h11.IDLE and self.parser.our_state is not h11.SEND_RESPONSE: + return + try: + self.send(_to_h11_response(response)) + if body: + self.send(h11.Data(data=body)) + self.send(h11.EndOfMessage()) + except Exception: # noqa: BLE001, S110 — connection is being closed anyway + pass + + def emit_stale_408(self) -> None: + """Stalled mid-request → 408. Idle keep-alive → silently dropped.""" + if self.idle or self.parser.our_state is not h11.IDLE: + return + try: + self.sock.settimeout(self.server.config.rw_timeout) + payload = self.parser.send(_to_h11_response(REQUEST_TIMEOUT_RESPONSE)) + if payload: + self.sock.sendall(payload) + payload = self.parser.send(h11.Data(data=REQUEST_TIMEOUT_BODY)) + if payload: + self.sock.sendall(payload) + payload = self.parser.send(h11.EndOfMessage()) + if payload: + self.sock.sendall(payload) + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + + +@dataclass(eq=False, frozen=True, slots=True) +class HTTPReqCtxH11: + """Per-request context for the h11 backend. + + Structurally satisfies :class:`localpost.http._base.HTTPReqCtx`. + """ + + _server: BaseServer + _conn: HTTPConnH11 + request: Request + + response_status: int | None = field(default=None, init=False) + + @property + def borrowed(self) -> bool: + return not self._conn.tracked + + @contextmanager + def borrow(self) -> Iterator[HTTPReqCtxH11]: + """Switch the conn out of selector tracking for the duration of the block.""" + assert not self.borrowed + self._server.stop_tracking(self._conn) + try: + yield self + finally: + self._maybe_give_back() + + def _maybe_give_back(self) -> None: + if self.borrowed: + self._server.track(self._conn) + + def complete(self, response: Response, body: bytes | None = None) -> None: + self.start_response(response) + if body is not None: + self.send(body) + self.finish_response() + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + parser = self._conn.parser + if parser.their_state is h11.DONE: + return b"" + if parser.they_are_waiting_for_100_continue: + self._conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) + while True: + event = parser.next_event() + if event is h11.NEED_DATA: + # Sync handlers can't tolerate BlockingIOError on a non-blocking + # socket: switch to a brief blocking read bounded by rw_timeout, + # then restore. Borrowed connections are already blocking, so + # the settimeout calls are no-ops on the rw_timeout value there. + sock = self._conn.sock + rw = self._server.config.rw_timeout + try: + self._conn.receive(size) + except BlockingIOError: + sock.settimeout(rw) + try: + self._conn.receive(size) + finally: + if self._conn.tracked: + sock.settimeout(0) + elif isinstance(event, h11.Data): + self._conn.body_bytes_received += len(event.data) + if self._conn.body_bytes_received > self._server.config.max_body_size: + raise BodyTooLarge(self._conn.body_bytes_received) + return bytes(event.data) + elif isinstance(event, h11.EndOfMessage): + return b"" + else: # h11.ConnectionClosed is not possible, it will be a protocol error + raise RuntimeError(f"Unexpected h11 event: {event!r}") + + def start_response(self, response: Response | InformationalResponse, /) -> None: + if isinstance(response, Response): + object.__setattr__(self, "response_status", response.status_code) + self._conn.send(_to_h11_response(response)) + + def send(self, chunk: Buffer, /) -> None: + # h11 wants bytes; widen the public API to any Buffer (memoryview, + # bytearray, …) so callers can avoid an explicit copy. + self._conn.send(h11.Data(data=bytes(chunk) if not isinstance(chunk, bytes) else chunk)) + + def finish_response(self) -> None: + self._conn.send(h11.EndOfMessage()) + # Drain h11's pending ``EndOfMessage`` for the request side before + # giving the conn back. For a no-body request the selector parsed + # ``Request`` and stopped — h11 still has the implicit EndOfMessage + # queued, and ``their_state`` won't reach ``DONE`` until something + # consumes it. Without this drain, the next selector wake on a + # keep-alive request hits ``PAUSED`` from ``parser.next_event``. + # + # If h11 needs more bytes (handler didn't read a non-empty body), + # close the conn — keep-alive isn't safe with un-drained body bytes. + parser = self._conn.parser + while parser.their_state is not h11.DONE: + event = parser.next_event() + if event is h11.NEED_DATA or event is h11.PAUSED or isinstance(event, h11.ConnectionClosed): + self._conn.close() + return + self._maybe_give_back() + + +def start_http_server(config: ServerConfig, handler: RequestHandler, /): + """Open a listening socket and yield a server driving the h11 backend. + + Default HTTP server: pure-Python parser, no C dependencies. For the + httptools backend (faster, opt-in via ``[http-fast]``), use + :func:`localpost.http.start_httptools_server`. + """ + + def _factory(server, sock, addr) -> HTTPConnH11: + return HTTPConnH11(server, sock, addr) + + return start_http_server_base(config, handler, _factory) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py new file mode 100644 index 0000000..7752f38 --- /dev/null +++ b/localpost/http/server_httptools.py @@ -0,0 +1,479 @@ +"""HTTP/1.1 server backend driven by ``httptools`` (llhttp wrapper). + +Faster, opt-in alternative to the default :mod:`localpost.http.server_h11` +backend. httptools is a parse-only C extension — response bytes and +keep-alive bookkeeping are handled here. + +Initial scope: + +- request body streaming via per-conn body buffer +- ``Expect: 100-continue`` (response written inline on first ``receive()``) +- keep-alive driven by ``parser.should_keep_alive()`` and response headers +- pipelining (multiple requests parsed from one ``feed_data`` call) supported + via a ready-queue +- request bodies serialised by the caller using ``Content-Length`` + (matching what ``Router`` and ``wrap_wsgi`` already produce) + +Out of scope for v1: chunked Transfer-Encoding on the response side, HTTP +upgrade negotiation (WebSockets), HTTP/2. +""" + +from __future__ import annotations + +import collections +import socket +import time +from collections.abc import Buffer, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import final + +try: + import httptools +except ImportError as _e: # pragma: no cover + raise ImportError( + "httptools is not installed. Install with: pip install localpost[http-fast]" + ) from _e + +from localpost.http._base import ( + BAD_REQUEST_BODY, + BAD_REQUEST_RESPONSE, + PAYLOAD_TOO_LARGE_BODY, + PAYLOAD_TOO_LARGE_RESPONSE, + REQUEST_TIMEOUT_BODY, + REQUEST_TIMEOUT_RESPONSE, + BaseHTTPConn, + BaseServer, + RequestHandler, + emit_handler_error, + start_http_server_base, +) +from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.config import DEFAULT_BUFFER_SIZE, ServerConfig + +__all__ = ["start_httptools_server", "HTTPConnHttptools", "HTTPReqCtxHttptools"] + + +# RFC 7231 §6.1 reason phrases for the codes the server-side actually emits. +_DEFAULT_REASONS: dict[int, bytes] = { + 100: b"Continue", + 200: b"OK", + 204: b"No Content", + 301: b"Moved Permanently", + 302: b"Found", + 304: b"Not Modified", + 400: b"Bad Request", + 401: b"Unauthorized", + 403: b"Forbidden", + 404: b"Not Found", + 405: b"Method Not Allowed", + 408: b"Request Timeout", + 413: b"Payload Too Large", + 417: b"Expectation Failed", + 500: b"Internal Server Error", + 501: b"Not Implemented", + 502: b"Bad Gateway", + 503: b"Service Unavailable", +} + + +def _serialize_response(r: Response | InformationalResponse) -> bytes: + """Serialise a status + headers block to wire bytes (no body).""" + out = bytearray(b"HTTP/1.1 ") + out += str(r.status_code).encode("ascii") + out += b" " + out += r.reason or _DEFAULT_REASONS.get(r.status_code, b"") + out += b"\r\n" + for name, value in r.headers: + out += name + out += b": " + out += value + out += b"\r\n" + out += b"\r\n" + return bytes(out) + + +def _has_connection_close(headers: list[tuple[bytes, bytes]]) -> bool: + return any(name.lower() == b"connection" and b"close" in value.lower() for name, value in headers) + + +def _has_content_length_or_te(headers: list[tuple[bytes, bytes]]) -> bool: + """True if the user already framed the response (Content-Length or Transfer-Encoding).""" + for name, _ in headers: + n = name.lower() + if n == b"content-length" or n == b"transfer-encoding": + return True + return False + + +@dataclass(eq=False, slots=True) +class _ReadyRequest: + """A request whose headers have been parsed (and possibly more). + + Body chunks accumulate as they arrive from subsequent ``feed_data`` calls; + ``complete`` flips when ``on_message_complete`` fires. + """ + + request: Request + body: collections.deque[bytes] = field(default_factory=collections.deque) + complete: bool = False + expect_100_continue: bool = False + keep_alive: bool = True + body_received: int = 0 + + +@final +@dataclass(eq=False, slots=True) +class HTTPConnHttptools(BaseHTTPConn): + server: BaseServer + sock: socket.socket + addr: tuple[str, int] + fd: int = field(init=False) + parser: httptools.HttpRequestParser = field(init=False) + close_at: float | None = None + tracked: bool = False + idle: bool = True + + # Currently-being-parsed request state (between on_message_begin and on_message_complete): + _cur_target: bytes = b"" + _cur_headers: list[tuple[bytes, bytes]] = field(default_factory=list) + _cur_expect_100: bool = False + _cur_oversize: bool = False # set by on_headers_complete if Content-Length exceeds cap + + # Most recently promoted ready request — body / EOM callbacks update its state. + _last_ready: _ReadyRequest | None = None + + # Completed requests waiting for dispatch (head = next to dispatch). A request is + # appended here on on_headers_complete; its body chunks accumulate via on_body until + # on_message_complete flips ``complete``. + _ready: collections.deque[_ReadyRequest] = field(default_factory=collections.deque) + + # Body cap tracking. Populated either eagerly from Content-Length + # (on_headers_complete) or progressively via on_body. + _body_too_large: int | None = None + + # Set when the response indicates ``Connection: close`` or the request lacked + # keep-alive support — the conn is closed once finish_response returns. + _close_after_response: bool = False + _response_started: bool = False + + def __post_init__(self) -> None: + self.fd = self.sock.fileno() + self.parser = httptools.HttpRequestParser(self) + + # ----- httptools callbacks (fired inside parser.feed_data) ----- + + def on_message_begin(self) -> None: + self._cur_target = b"" + self._cur_headers = [] + self._cur_expect_100 = False + self._cur_oversize = False + + def on_url(self, url: bytes) -> None: + self._cur_target = self._cur_target + url if self._cur_target else url + + def on_header(self, name: bytes, value: bytes) -> None: + n = name.lower() + self._cur_headers.append((n, value)) + if n == b"expect" and value.lower() == b"100-continue": + self._cur_expect_100 = True + + def on_headers_complete(self) -> None: + # Build the neutral Request envelope and promote it to the ready queue. + method = self.parser.get_method() + version = self.parser.get_http_version().encode("ascii") + keep_alive = self.parser.should_keep_alive() + + # Eager content-length cap check. + cl: int | None = None + for n, v in self._cur_headers: + if n == b"content-length": + try: + cl = int(v) + except ValueError: + cl = None + break + if cl is not None and cl > self.server.config.max_body_size: + self._body_too_large = cl + self._cur_oversize = True + return + + req = Request( + method=bytes(method), + target=bytes(self._cur_target), + headers=self._cur_headers, + http_version=version, + ) + ready = _ReadyRequest( + request=req, + expect_100_continue=self._cur_expect_100, + keep_alive=keep_alive, + ) + self._ready.append(ready) + self._last_ready = ready + + def on_body(self, data: bytes) -> None: + if self._cur_oversize or self._last_ready is None: + return + new_total = self._last_ready.body_received + len(data) + if new_total > self.server.config.max_body_size: + self._body_too_large = new_total + return + self._last_ready.body_received = new_total + self._last_ready.body.append(bytes(data)) + + def on_message_complete(self) -> None: + if self._last_ready is not None: + self._last_ready.complete = True + self._last_ready = None + + # ----- BaseHTTPConn surface ----- + + def __call__(self, h: RequestHandler, /) -> None: + try: + self._loop(h) + except _ProtocolError as e: + self.server.logger.warning("Bad client input from %s: %s", self.addr, e) + self._try_send_status(BAD_REQUEST_RESPONSE, BAD_REQUEST_BODY) + self.close() + except BodyTooLarge: + self.server.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size + ) + self._try_send_status(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) + self.close() + + def _feed(self, data: bytes) -> None: + try: + self.parser.feed_data(data) + except httptools.HttpParserUpgrade as e: + raise _ProtocolError(f"HTTP upgrade not supported: {e}") from e + except httptools.HttpParserError as e: + raise _ProtocolError(str(e)) from e + if self._body_too_large is not None: + n = self._body_too_large + self._body_too_large = None + raise BodyTooLarge(n) + + def _loop(self, h: RequestHandler) -> None: + config = self.server.config + while self.tracked: + # Dispatch any ready request before reading more bytes. We pop + # before dispatching: the request is committed to ``req_ctx`` + # and ownership transfers — otherwise a worker-pool handler + # (which sets ``borrowed=True`` and returns) would leave the + # request in the queue and we'd re-dispatch it on re-track. + if self._ready: + ready = self._ready.popleft() + req_ctx = HTTPReqCtxHttptools(self.server, self, ready) + try: + h(req_ctx) + except BodyTooLarge: + raise + except Exception: + self.server.logger.exception( + "Handler raised for %s %r", ready.request.method, ready.request.target + ) + emit_handler_error(req_ctx) + if req_ctx.borrowed: + return + if not self.tracked: + return # connection was closed during error recovery + if self._close_after_response or not ready.keep_alive: + self.close() + return + self._reset_for_next_request() + continue + + # No request ready — pump bytes from the socket. + try: + data = self.sock.recv(DEFAULT_BUFFER_SIZE) + except BlockingIOError: + return # back to selector + if not data: + self.close() + return + self.idle = False + self.close_at = time.monotonic() + config.rw_timeout + self._feed(data) + + def _reset_for_next_request(self) -> None: + # Per-request parsing scratch is cleared on the next on_message_begin. + # Reset our own conn-level fields here. + self._response_started = False + self.idle = True + self.close_at = time.monotonic() + self.server.config.keep_alive_timeout + + def _try_send_status(self, response: Response, body: bytes) -> None: + """Best-effort: write a status line + body if we haven't started a response yet.""" + if self._response_started: + return + try: + self.sock.settimeout(self.server.config.rw_timeout) + self.sock.sendall(_serialize_response(response)) + if body: + self.sock.sendall(body) + except Exception: # noqa: BLE001, S110 — connection is being closed anyway + pass + + def emit_stale_408(self) -> None: + """Stalled mid-request → 408. Idle keep-alive → silently dropped.""" + if self.idle or self._response_started: + return + try: + self.sock.settimeout(self.server.config.rw_timeout) + self.sock.sendall(_serialize_response(REQUEST_TIMEOUT_RESPONSE)) + if REQUEST_TIMEOUT_BODY: + self.sock.sendall(REQUEST_TIMEOUT_BODY) + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + + +class _ProtocolError(Exception): + """Translated httptools parser error. Mapped to 400 by the conn loop.""" + + +@dataclass(eq=False, frozen=True, slots=True) +class HTTPReqCtxHttptools: + """Per-request context for the httptools backend.""" + + _server: BaseServer + _conn: HTTPConnHttptools + _ready: _ReadyRequest + + response_status: int | None = field(default=None, init=False) + _continue_sent: bool = field(default=False, init=False) + _chunked: bool = field(default=False, init=False) + """``True`` if the backend auto-added ``Transfer-Encoding: chunked`` because + the response had neither ``Content-Length`` nor an explicit + ``Transfer-Encoding`` — chunks must be framed and a terminator written.""" + + @property + def request(self) -> Request: + return self._ready.request + + @property + def borrowed(self) -> bool: + return not self._conn.tracked + + @contextmanager + def borrow(self) -> Iterator[HTTPReqCtxHttptools]: + assert not self.borrowed + self._server.stop_tracking(self._conn) + try: + yield self + finally: + self._maybe_give_back() + + def _maybe_give_back(self) -> None: + if self.borrowed: + self._server.track(self._conn) + + def complete(self, response: Response, body: bytes | None = None) -> None: + self.start_response(response) + if body is not None: + self.send(body) + self.finish_response() + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + # 100-continue: caller is opting to read the body; tell the client + # to actually send it. + if self._ready.expect_100_continue and not self._continue_sent: + self._send_continue() + # Drain accumulated chunks before pumping more bytes. + if self._ready.body: + chunk = self._ready.body.popleft() + return chunk + if self._ready.complete: + return b"" + # No buffered body, no EOM seen — pump more bytes from the socket. + sock = self._conn.sock + rw = self._server.config.rw_timeout + while True: + try: + data = sock.recv(size) + except BlockingIOError: + # Selector-thread handler on a non-blocking socket: switch + # to blocking-with-timeout for the duration of this receive, + # mirroring the h11 backend. + sock.settimeout(rw) + try: + data = sock.recv(size) + finally: + if self._conn.tracked: + sock.settimeout(0) + if not data: + # Peer closed mid-body — surface as EOF for the handler. + self._ready.complete = True + return b"" + self._conn._feed(data) + if self._ready.body: + return self._ready.body.popleft() + if self._ready.complete: + return b"" + + def _send_continue(self) -> None: + self._conn.sock.sendall(b"HTTP/1.1 100 Continue\r\n\r\n") + object.__setattr__(self, "_continue_sent", True) + + def start_response(self, response: Response | InformationalResponse, /) -> None: + if isinstance(response, Response): + object.__setattr__(self, "response_status", response.status_code) + self._conn._response_started = True + if _has_connection_close(response.headers) or not self._ready.keep_alive: + self._conn._close_after_response = True + # Auto-frame: no Content-Length / Transfer-Encoding → chunked. + # Without framing, an HTTP/1.1 client would wait for the connection + # to close before considering the response complete. + if not _has_content_length_or_te(response.headers): + response = Response( + status_code=response.status_code, + headers=[*response.headers, (b"transfer-encoding", b"chunked")], + reason=response.reason, + ) + object.__setattr__(self, "_chunked", True) + self._conn.sock.sendall(_serialize_response(response)) + + def send(self, chunk: Buffer, /) -> None: + if not isinstance(chunk, bytes): + chunk = bytes(chunk) + if not chunk: + return + if self._chunked: + # RFC 7230 §4.1: CRLF CRLF — concatenated into + # a single ``sendall`` to keep this to one syscall per chunk. + framed = f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n" + self._conn.sock.sendall(framed) + else: + self._conn.sock.sendall(chunk) + + def finish_response(self) -> None: + if self._chunked: + # Terminating chunk: ``0\r\n\r\n``. + self._conn.sock.sendall(b"0\r\n\r\n") + # Drain any remaining request-body bytes the handler skipped — leftover + # on the wire would corrupt the next pipelined request. + if not self._ready.complete: + try: + while not self._ready.complete: + chunk = self.receive(DEFAULT_BUFFER_SIZE) + if not chunk: + break + except (BlockingIOError, OSError, _ProtocolError, BodyTooLarge): + # We can't safely recover — the conn must close. + self._conn._close_after_response = True + self._maybe_give_back() + + +def start_httptools_server(config: ServerConfig, handler: RequestHandler, /): + """Open a listening socket and yield a server driving the httptools backend. + + Faster than the default h11 backend for header parsing. Requires the + ``[http-fast]`` extra. For the default backend see + :func:`localpost.http.start_http_server`. + """ + + def _factory(server: BaseServer, sock: socket.socket, addr: tuple[str, int]) -> HTTPConnHttptools: + return HTTPConnHttptools(server, sock, addr) + + return start_http_server_base(config, handler, _factory) diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 0dd3c11..52968ae 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -6,8 +6,7 @@ from typing import Any, final, override from wsgiref.types import WSGIApplication -import h11 - +from localpost.http._types import Response as _NativeResponse from localpost.http.server import HTTPReqCtx, RequestHandler __all__ = ["wrap_wsgi"] @@ -90,12 +89,12 @@ def start_response( exc_info = None status_code = int(status.split(" ", 1)[0]) reason = status.split(" ", 1)[1] if " " in status else "" - h11_headers = [ + wire_headers = [ (name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in headers ] - response_state["response"] = h11.Response( + response_state["response"] = _NativeResponse( status_code=status_code, - headers=h11_headers, + headers=wire_headers, reason=reason.encode("iso-8859-1") if reason else b"", ) return _wsgi_write_deprecated diff --git a/pyproject.toml b/pyproject.toml index a68f471..734fd89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,10 @@ http-server = [ # "werkzeug ~=3.1", # "multipart", ] +http-fast = [ + # C-based HTTP/1.1 parser (llhttp); enables ``start_httptools_server``. + "httptools >=0.6,<0.8", +] http-openapi = [ "msgspec ~=0.19", ] diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py new file mode 100644 index 0000000..f0f9070 --- /dev/null +++ b/tests/http/backend_parity.py @@ -0,0 +1,249 @@ +"""Parity tests across the h11 and httptools server backends. + +Same handler, same canonical request set, asserted against both backends. +""" + +from __future__ import annotations + +import socket +import threading +from collections.abc import Callable +from contextlib import AbstractContextManager + +import httpx +import pytest + +from localpost.http import ( + HTTPReqCtx, + NativeResponse, + RequestHandler, + ServerConfig, + start_http_server, +) +from localpost.http.server_httptools import start_httptools_server + +_StartFn = Callable[[ServerConfig, RequestHandler], AbstractContextManager] + +BACKENDS: list[tuple[str, _StartFn]] = [ + ("h11", start_http_server), + ("httptools", start_httptools_server), +] + + +def _serve(start_fn: _StartFn, config: ServerConfig, handler: RequestHandler): + """Background-thread server harness — yields the bound port.""" + + cm = start_fn(config, handler) + stop = threading.Event() + + class _Ctx: + def __enter__(self) -> int: + self._cm_state = cm.__enter__() + server = self._cm_state + + def loop() -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + + self._t = threading.Thread(target=loop, daemon=True) + self._t.start() + return server.port + + def __exit__(self, *exc): + stop.set() + self._t.join(timeout=5) + cm.__exit__(*exc) + + return _Ctx() + + +@pytest.fixture(params=BACKENDS, ids=[b[0] for b in BACKENDS]) +def serve_parity(request) -> Callable[[RequestHandler], AbstractContextManager]: + _name, start_fn = request.param + + def make(handler: RequestHandler): + return _serve(start_fn, ServerConfig(host="127.0.0.1", port=0), handler) + + return make + + +def _ok(body: bytes = b"hello"): + def handler(ctx: HTTPReqCtx) -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + + return handler + + +def test_simple_get(serve_parity): + with serve_parity(_ok(b"hi")) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + assert r.status_code == 200 + assert r.text == "hi" + + +def test_post_with_body(serve_parity): + received: list[bytes] = [] + + def handler(ctx: HTTPReqCtx) -> None: + body = b"" + while True: + chunk = ctx.receive(4096) + if not chunk: + break + body += chunk + received.append(body) + out = b"echo:" + body + ctx.complete( + NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(out)).encode("ascii")), + ], + ), + out, + ) + + with serve_parity(handler) as port: + r = httpx.post(f"http://127.0.0.1:{port}/x", content=b"hello world", timeout=2) + assert r.status_code == 200 + assert r.text == "echo:hello world" + assert received == [b"hello world"] + + +@pytest.mark.parametrize("start_fn", [b for _, b in BACKENDS], ids=[n for n, _ in BACKENDS]) +def test_oversize_body_returns_413(start_fn): + """Content-Length-flagged oversize body → 413 from the parser layer.""" + + def handler(ctx: HTTPReqCtx) -> None: # never called + ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + + body = b"x" * 1024 + cm = _serve(start_fn, ServerConfig(host="127.0.0.1", port=0, max_body_size=100), handler) + with cm as port: + r = httpx.post(f"http://127.0.0.1:{port}/", content=body, timeout=2) + assert r.status_code == 413 + + +def test_keep_alive_two_requests(serve_parity): + counter = {"n": 0} + + def handler(ctx: HTTPReqCtx) -> None: + counter["n"] += 1 + body = str(counter["n"]).encode("ascii") + ctx.complete( + NativeResponse( + 200, + [(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + with serve_parity(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as s: + s.sendall(b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n") + data1 = b"" + while b"\r\n\r\n" not in data1: + chunk = s.recv(4096) + if not chunk: + break + data1 += chunk + # consume body (Content-Length: 1) + while not data1.endswith(b"1"): + chunk = s.recv(4096) + if not chunk: + break + data1 += chunk + + s.sendall(b"GET /b HTTP/1.1\r\nHost: x\r\n\r\n") + data2 = b"" + while b"\r\n\r\n" not in data2: + chunk = s.recv(4096) + if not chunk: + break + data2 += chunk + while not data2.endswith(b"2"): + chunk = s.recv(4096) + if not chunk: + break + data2 += chunk + + assert b"HTTP/1.1 200" in data1 + assert data1.endswith(b"1") + assert b"HTTP/1.1 200" in data2 + assert data2.endswith(b"2") + assert counter["n"] == 2 + + +def test_malformed_request_returns_400(serve_parity): + def handler(ctx: HTTPReqCtx) -> None: # not called + ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + + with serve_parity(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as s: + s.sendall(b"NOT-AN-HTTP-REQUEST\r\n\r\n") + data = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + assert b"400" in data, data + + +def test_expect_100_continue(serve_parity): + def handler(ctx: HTTPReqCtx) -> None: + body = b"" + while True: + chunk = ctx.receive(4096) + if not chunk: + break + body += chunk + out = b"got:" + body + ctx.complete( + NativeResponse( + 200, + [(b"content-type", b"text/plain"), (b"content-length", str(len(out)).encode("ascii"))], + ), + out, + ) + + with serve_parity(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as s: + s.sendall( + b"POST /x HTTP/1.1\r\n" + b"Host: x\r\n" + b"Content-Length: 5\r\n" + b"Expect: 100-continue\r\n" + b"\r\n" + ) + # Read intermediate 100 Continue + buf = b"" + s.settimeout(2) + while b"\r\n\r\n" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + assert b"100" in buf, buf + # Send body now + s.sendall(b"hello") + # Read final response + while b"got:hello" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + assert b"got:hello" in buf diff --git a/tests/http/server.py b/tests/http/server.py index 6b108c9..2c5028d 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -6,11 +6,10 @@ import socket import threading -import h11 import httpx import pytest -from localpost.http import HTTPReqCtx, ServerConfig, start_http_server +from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig, start_http_server def _drain(sock: socket.socket, deadline: float = 2.0) -> bytes: @@ -64,7 +63,7 @@ class TestBasicRequestResponse: def test_simple_200(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete( - h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + NativeResponse(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"OK", ) @@ -77,7 +76,7 @@ def handler(ctx: HTTPReqCtx): def test_404_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete( - h11.Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), + NativeResponse(status_code=404, headers=[(b"Content-Type", b"text/plain")]), b"Not Found", ) @@ -89,7 +88,7 @@ def handler(ctx: HTTPReqCtx): def test_empty_body(self, serve_in_thread): def handler(ctx: HTTPReqCtx): - ctx.complete(h11.Response(status_code=204, headers=[])) + ctx.complete(NativeResponse(status_code=204, headers=[])) with serve_in_thread(handler) as port: resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) @@ -105,7 +104,7 @@ def test_handler_sees_method_and_target(self, serve_in_thread): def handler(ctx: HTTPReqCtx): captured["method"] = ctx.request.method captured["target"] = ctx.request.target - ctx.complete(h11.Response(status_code=200, headers=[]), b"") + ctx.complete(NativeResponse(status_code=200, headers=[]), b"") with serve_in_thread(handler) as port: httpx.post(f"http://127.0.0.1:{port}/api/items?q=1", timeout=5) @@ -119,7 +118,7 @@ def test_handler_sees_headers(self, serve_in_thread): def handler(ctx: HTTPReqCtx): for name, value in ctx.request.headers: captured_headers[name] = value - ctx.complete(h11.Response(status_code=200, headers=[]), b"") + ctx.complete(NativeResponse(status_code=200, headers=[]), b"") with serve_in_thread(handler) as port: httpx.get(f"http://127.0.0.1:{port}/", headers={"X-Custom": "hello"}, timeout=5) @@ -137,7 +136,7 @@ def handler(ctx: HTTPReqCtx): if not chunk: break received_body.extend(chunk) - ctx.complete(h11.Response(status_code=200, headers=[]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[]), b"ok") with serve_in_thread(handler) as port: httpx.post(f"http://127.0.0.1:{port}/", content=b"hello world body", timeout=5) @@ -149,7 +148,7 @@ class TestChunkedResponse: def test_streaming_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.start_response( - h11.Response( + NativeResponse( status_code=200, headers=[(b"Transfer-Encoding", b"chunked")], ) @@ -173,7 +172,7 @@ def handler(ctx: HTTPReqCtx): borrow_states.append(ctx.borrowed) # False — still tracked with ctx.borrow(): borrow_states.append(ctx.borrowed) # True — untracked - ctx.complete(h11.Response(status_code=200, headers=[]), b"borrowed") + ctx.complete(NativeResponse(status_code=200, headers=[]), b"borrowed") borrow_states.append(ctx.borrowed) # False — re-tracked after finish_response with serve_in_thread(handler) as port: @@ -190,7 +189,7 @@ def handler(ctx: HTTPReqCtx): nonlocal call_count call_count += 1 ctx.complete( - h11.Response(status_code=200, headers=[(b"Content-Length", b"2")]), + NativeResponse(status_code=200, headers=[(b"Content-Length", b"2")]), b"ok", ) @@ -214,7 +213,7 @@ def handler(ctx: HTTPReqCtx): nonlocal call_count call_count += 1 ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -243,7 +242,7 @@ def handler(ctx: HTTPReqCtx): def _ok_handler(ctx: HTTPReqCtx) -> None: body = b"ok" ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + NativeResponse(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), body, ) @@ -287,7 +286,7 @@ def boom(ctx: HTTPReqCtx) -> None: with crash_lock: crash_count += 1 ctx.start_response( - h11.Response( + NativeResponse( status_code=200, headers=[(b"transfer-encoding", b"chunked")], ) @@ -361,7 +360,7 @@ def handler(ctx: HTTPReqCtx) -> None: break captured.extend(chunk) ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -406,7 +405,7 @@ def handler(ctx: HTTPReqCtx) -> None: (b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode()), ] - response = h11.Response(status_code=200, headers=headers) + response = NativeResponse(status_code=200, headers=headers) if ctx.request.method == b"HEAD": ctx.complete(response, None) else: @@ -429,7 +428,7 @@ def handler(ctx: HTTPReqCtx) -> None: served.append(ctx.request.target) body = b"resp-for-" + ctx.request.target ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + NativeResponse(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), body, ) @@ -462,7 +461,7 @@ def test_send_accepts_memoryview(self, serve_in_thread): def handler(ctx: HTTPReqCtx) -> None: ctx.start_response( - h11.Response(status_code=200, headers=[(b"content-length", b"5")]) + NativeResponse(status_code=200, headers=[(b"content-length", b"5")]) ) payload = bytearray(b"hello") ctx.send(memoryview(payload)[:5]) @@ -484,7 +483,7 @@ def test_idle_keep_alive_silently_closed_after_timeout(self): def handler(ctx: HTTPReqCtx) -> None: ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -512,7 +511,7 @@ def test_mid_request_stale_returns_408(self): """Client starts sending headers and stops; server emits 408 once stale.""" def handler(ctx: HTTPReqCtx) -> None: - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=0, keep_alive_timeout=0.1, rw_timeout=0.1) with start_http_server(cfg, handler) as server: @@ -542,7 +541,7 @@ def test_oversized_content_length_returns_413(self): def handler(ctx: HTTPReqCtx) -> None: captured["called"] = True - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=0, max_body_size=10) with start_http_server(cfg, handler) as server: @@ -624,7 +623,7 @@ def test_idle_keep_alive_connection_closed_on_exit(self, serve_in_thread): def handler(ctx: HTTPReqCtx) -> None: ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) diff --git a/tests/http/service.py b/tests/http/service.py index 1eadc3c..5727867 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -13,7 +13,6 @@ from contextlib import asynccontextmanager import anyio -import h11 import httpx import pytest from anyio import to_thread @@ -29,6 +28,7 @@ check_cancelled, http_server, thread_pool_handler, + NativeResponse, ) from localpost.http.server import RequestHandler @@ -54,7 +54,7 @@ async def _serve_pooled( def _handler_200(body: bytes = b"ok"): def handler(ctx: HTTPReqCtx): ctx.complete( - h11.Response( + NativeResponse( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], ), @@ -126,7 +126,7 @@ def handler(ctx: HTTPReqCtx): # Block until the test releases us; this forces parallelism. release.wait(timeout=5.0) ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", b"2")]), + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -181,7 +181,7 @@ def handler(ctx: HTTPReqCtx): time.sleep(0.1) with lock: in_flight -= 1 - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: @@ -216,7 +216,7 @@ def handler(ctx: HTTPReqCtx): except RequestCancelled: handler_cancelled.set() raise - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: @@ -333,7 +333,7 @@ def handler(ctx: HTTPReqCtx): gate.wait(timeout=5.0) with lock: in_flight -= 1 - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=3) as lt: @@ -394,7 +394,7 @@ async def test_slot_released_after_normal_request(self, free_port): """ def handler(ctx: HTTPReqCtx): - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: @@ -416,7 +416,7 @@ async def test_many_requests_served_from_worker_threads(self, free_port): def handler(ctx: HTTPReqCtx): tid = str(threading.get_ident()).encode() ctx.complete( - h11.Response(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), + NativeResponse(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), tid, ) @@ -465,7 +465,7 @@ def handler(ctx: HTTPReqCtx): handler_cancelled.set() raise # Should not be reached - ctx.complete(h11.Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: diff --git a/uv.lock b/uv.lock index 208611b..7642e31 100644 --- a/uv.lock +++ b/uv.lock @@ -992,6 +992,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1180,6 +1209,9 @@ azure-servicebus = [ cron = [ { name = "croniter" }, ] +http-fast = [ + { name = "httptools" }, +] http-flask = [ { name = "flask" }, ] @@ -1290,13 +1322,14 @@ requires-dist = [ { name = "flask", marker = "extra == 'http-flask'", specifier = "~=3.1" }, { name = "google-cloud-pubsub", marker = "extra == 'pubsub'", specifier = "~=2.28" }, { name = "h11", marker = "extra == 'http-server'", specifier = "~=0.16" }, + { name = "httptools", marker = "extra == 'http-fast'", specifier = ">=0.6,<0.8" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, { name = "msgspec", marker = "extra == 'http-openapi'", specifier = "~=0.19" }, { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, { name = "sentry-sdk", marker = "extra == 'http-sentry'", specifier = "~=2.51" }, ] -provides-extras = ["cron", "scheduler", "http-server", "http-openapi", "http-flask", "http-sentry", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] +provides-extras = ["cron", "scheduler", "http-server", "http-fast", "http-openapi", "http-flask", "http-sentry", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] [package.metadata.requires-dev] bench = [ From 4935501801f4c7c78d45d8be310ac3377026f9bc Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 11:43:11 +0400 Subject: [PATCH 114/286] docs(http): add Phase 6 (httptools backend) to PERF_FINDINGS Records the bench numbers, the two bugs caught only under load, and the trade-offs notes (auto-chunked is now in scope, since Router and WSGI don't always set Content-Length). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 127 +++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index f2d5f80..027bf14 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -437,3 +437,130 @@ single-writer to the selector**, and the lock is gone. Future work that needs to add cross-thread selector mutations (e.g. scheduled FD timers) just enqueues an op — no lock to contend with, no consistency invariants to re-prove. + +## Phase 6 (shipped): optional httptools backend (2026-04-29) + +The Phase 5 closing line — "remaining selector-thread CPU is now +overwhelmingly inside h11" — was the explicit handoff to this phase. +Cashed it in. + +### What shipped + +A second HTTP/1.1 server backed by ``httptools`` (llhttp) ships as a +**peer** of the existing h11 server, opt-in via the ``[http-fast]`` extra. +Not a parser plugin under one server — two implementations sharing only +the parser-agnostic infrastructure: + +- ``localpost/http/_base.py``: ``BaseServer`` (selector loop, accept, + op queue + wakeup pipe, stale sweep, shutdown) lifted from the old + ``Server``, parameterised on a ``conn_factory``. ``BaseHTTPConn`` ABC + with a small surface (``__call__``, ``close``, ``sock``, ``fd``, + ``tracked``, ``close_at``, ``idle``, ``emit_stale_408``) — no parser + methods leak through. +- ``localpost/http/server_h11.py``: existing logic moved here; translates + at the boundary (``h11.Request`` ⇄ neutral ``Request``, neutral + ``Response`` → ``h11.Response`` before ``parser.send``). +- ``localpost/http/server_httptools.py``: native push-callback driven; + ``_ReadyRequest`` queue handles pipelining naturally; hand-written + response serializer. Each accepted conn gets its own + ``HttpRequestParser`` with the conn instance as the protocol target. +- ``localpost/http/_types.py``: neutral ``Request`` / ``NativeResponse`` + / ``InformationalResponse`` so handlers no longer import ``h11`` or + ``httptools`` directly. + +Hosted-service form: ``httptools_server`` (peer of ``http_server``, +lazy-imports the backend so the extra stays optional). + +Why two implementations rather than a parser Protocol: h11 is +pull-events + parse-AND-serialize, httptools is push-callbacks + +parse-only. Forcing one shape over both restricts the faster backend +without buying anything (the dispatch path / selector / pool are +already shared by ``BaseServer``). Each backend uses its parser's +natural idioms. + +### Bench (8 s/cell, Python 3.13 on Darwin arm64, 64 / 32 clients) + +| Scenario | h11 RPS / p50 | httptools RPS / p50 | Δ RPS | +| ----------------- | -----------------: | ---------------------: | -------: | +| `plaintext` | 9,494 / 6.67 ms | **12,845 / 4.96 ms** | **+35%** | +| `path_param` | 9,500 / 6.67 ms | **12,775 / 4.98 ms** | **+34%** | +| `json_post` | 8,616 / 3.66 ms | **12,504 / 2.54 ms** | **+45%** | +| `profile_update` | 5,841 / 5.43 ms | 5,885 / 5.25 ms | +1% | + +Lands almost exactly on the 30–50% projection from the original Phase 1 +diagnosis. ``profile_update`` doesn't move because the synthetic handler +holds three ``time.sleep`` totalling ~4 ms — at that point parser +overhead is a rounding error; the gain is in the noise. + +p50 latency improves ~25–31% on parser-bound scenarios. We're still +~3–5× behind ``starlette_uvicorn`` / ``starlette_granian`` on absolute +RPS — that's the async-ASGI + uvloop ceiling, well outside what a +sync-handler + thread-pool design gives. + +All 142 http tests pass (130 existing + 12 new backend-parity tests +covering GET / POST-with-body / oversize → 413 / malformed → 400 / +keep-alive×2 / ``Expect: 100-continue`` across both backends). + +### Two bugs the parity tests didn't catch + +Both surfaced only under bench load (``oha`` with persistent HTTP/1.1 +connections), not in the parity suite — pinpointed and fixed in the +same session. + +1. **``_ready`` not popped on borrow.** Under ``thread_pool_handler``, + ``h(req_ctx)`` returns immediately with ``borrowed=True``; my loop + checked ``req_ctx.borrowed`` *before* ``popleft``, so the same + request got re-dispatched on every conn re-track. Symptom: RPS = the + concurrency limit, every request hitting the 1 s ``rw_timeout``. + Fix: ``popleft`` *before* dispatch — ownership transfers to the + ``HTTPReqCtx`` regardless of whether the handler runs synchronously + or hands the conn off. +2. **No body framing for responses without Content-Length.** h11 + silently inserts ``Transfer-Encoding: chunked`` when neither + ``Content-Length`` nor ``Transfer-Encoding`` is set; my hand-written + serializer wrote raw bytes and HTTP/1.1 clients waited for FIN + before considering the response complete. The CHANGELOG/README + "Content-Length only" claim was wrong: ``Router.as_handler`` doesn't + compute lengths from ``Iterable[bytes]`` bodies, and many WSGI apps + don't set the header either. Fix: in the httptools backend's + ``start_response``, when the response lacks both headers, append + ``Transfer-Encoding: chunked`` and frame chunks + (``\r\n\r\n``) plus the ``0\r\n\r\n`` terminator on + ``finish_response``. **One** ``sendall`` per chunk — the first + version did three, and the syscall overhead alone (~75 µs/req at + small bodies) was eating the entire C-parser win, leaving the bench + at parity with h11. + +### Trade-offs + +- **Two parallel implementations, not one.** ~340 lines for the h11 + backend (mostly verbatim from the old ``server.py``) + ~430 lines for + the httptools backend (callback bookkeeping + response serializer + + chunked framing). The shared ``BaseServer`` is ~370 lines, lifted + unchanged. The duplication is intentional: each backend reads as a + straight translation of its parser's natural idioms. +- **Auto-chunked is now in scope.** The CHANGELOG / README originally + said "Content-Length only initially". That stance was based on the + assumption that ``Router`` and ``wrap_wsgi`` always set + Content-Length. They don't. The httptools backend now matches h11's + effective behaviour (silent chunked) for un-framed responses; chunked + remains absent from the trailer / Transfer-Encoding-other-than-chunked + paths, which is fine. +- **Public API took a one-time break.** ``HTTPReqCtx.request`` is now a + neutral ``Request`` (was ``h11.Request``); ``start_response`` / + ``complete`` accept neutral ``NativeResponse`` / + ``InformationalResponse``. Field shapes match h11's — migration is a + mechanical import swap, not a semantic change. Documented in + CHANGELOG. + +### What's left for future perf work + +- **Sendfile / vectored writes** for response bodies. Each + ``sock.sendall`` on the response path is one syscall; status + first + chunk + terminator could be one ``writev`` for small responses. +- **Pre-baked common headers** (Date, Server) cached per-second on the + ``BaseServer``, written by both backends. +- The selector-thread CPU is now lower (parser cheaper). If we ever + return to closing the gap to async-ASGI stacks, **multi-selector + + SO_REUSEPORT** is the next architectural lever — the op-queue + design from Phase 3-B already accommodates multiple selectors. From 7d94641dfffd7e62a6a85a2015ffebdb679ca70d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 13:06:20 +0400 Subject: [PATCH 115/286] feat(http): selectors=N knob and Phase 7/7b validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `selectors: int = 1` to http_server / httptools_server / wsgi_server. >1 spawns N independent BaseServer threads bound to the same address via SO_REUSEPORT (already enabled in _base.py); shared handler / shared thread_pool_handler worker pool. Port=0 is resolved once up-front so all selectors agree on the actual ephemeral. - Phase 7 bench (standard CPython 3.13 / Darwin arm64): flat — within run-to-run noise on every scenario. GIL-held fraction of selector wall time is large; macOS SO_REUSEPORT does not load-balance accepts. - Phase 7b bench (CPython 3.14t no-GIL, httptools 0.8.0 from git which declares Py_mod_gil = Py_MOD_GIL_NOT_USED): +188% RPS at selectors=1 on httptools plaintext (12,563 → 36,208), p50 5.05ms → 1.76ms. The existing single-selector + worker-pool architecture is already multi-threaded; no-GIL lets the selector and workers actually overlap. Multi-selector under 3.14t still flat on macOS — diagnostic shows 100% of accepts go to one selector thread (proves macOS REUSEPORT doesn't distribute). - Document optimisation boundaries (in-process / no-async / GIL-or-free-threaded) in localpost/http/README.md and benchmarks/http/PERF_FINDINGS.md. - Bench harness: new app variants localpost_{httptools,native}_{s2,s4}, inline (no thread pool) variants, and a diagnostic that prints per-selector accept distribution. - 137/137 http tests pass on standard CPython; full service-test suite (36 tests) passes under 3.14t on both asyncio and trio. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 22 ++ benchmarks/http/PERF_FINDINGS.md | 208 +++++++++++++++++- benchmarks/http/apps/_cli.py | 20 +- benchmarks/http/apps/localpost_httptools.py | 8 +- .../http/apps/localpost_httptools_diag.py | 106 +++++++++ .../http/apps/localpost_httptools_inline.py | 67 ++++++ .../apps/localpost_httptools_inline_s2.py | 11 + .../apps/localpost_httptools_inline_s4.py | 11 + .../http/apps/localpost_httptools_s2.py | 15 ++ .../http/apps/localpost_httptools_s4.py | 15 ++ benchmarks/http/apps/localpost_native.py | 8 +- benchmarks/http/apps/localpost_native_s2.py | 15 ++ benchmarks/http/apps/localpost_native_s4.py | 15 ++ benchmarks/http/runner.py | 7 + localpost/http/README.md | 17 ++ localpost/http/_service.py | 122 ++++++++-- tests/http/service.py | 71 +++++- 17 files changed, 705 insertions(+), 33 deletions(-) create mode 100644 benchmarks/http/apps/localpost_httptools_diag.py create mode 100644 benchmarks/http/apps/localpost_httptools_inline.py create mode 100644 benchmarks/http/apps/localpost_httptools_inline_s2.py create mode 100644 benchmarks/http/apps/localpost_httptools_inline_s4.py create mode 100644 benchmarks/http/apps/localpost_httptools_s2.py create mode 100644 benchmarks/http/apps/localpost_httptools_s4.py create mode 100644 benchmarks/http/apps/localpost_native_s2.py create mode 100644 benchmarks/http/apps/localpost_native_s4.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04610cf..7631345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Free-threaded CPython (3.14t) support** — verified end-to-end with the + full http test suite passing and the bench delivering a ~3x RPS jump at + `selectors=1` (httptools plaintext: 12,563 → 36,208 RPS) just from + switching interpreters. The existing single-selector + worker-pool + architecture is already multi-threaded, so removing the GIL lets the + selector and workers actually overlap. Tested with `httptools >= 0.8` + (declares `Py_mod_gil = Py_MOD_GIL_NOT_USED`); the released 0.7.1 wheel + auto-re-enables the GIL on import. See + `benchmarks/http/PERF_FINDINGS.md` Phase 7b. +- **Multi-selector single-process** via the new `selectors: int = 1` knob + on `localpost.http.http_server` and `httptools_server`. With + `selectors > 1`, each selector thread binds its own listening socket on + the same address via `SO_REUSEPORT`; the kernel distributes incoming + connections across them. Handlers (and any wrapped + `thread_pool_handler`) are shared. **Note: on macOS this knob is a + no-op.** Verified empirically (`localpost_httptools_diag.py`): with + `selectors=4`, 100% of accepts go to one selector thread. macOS's + `SO_REUSEPORT` permits the bind but does not load-balance accepts — + unlike Linux 3.9+. An accept-dispatch design (one acceptor thread + + N selectors fed via the existing op queue) is the platform-portable + fix and is the next planned step. The knob still works on Linux, + pending a Linux bench cell to confirm scaling. - `localpost.http.thread_pool_handler` — async context manager that wraps any `RequestHandler` so it runs on a worker thread. Compose explicitly with `http_server` when you need a worker pool; immediate handlers diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 027bf14..2592837 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -3,6 +3,26 @@ Initial diagnosis from the 2026-04-27 bench run (3s per cell, before Phase 1). Phase 1 results in [Phase 1 results](#phase-1-results-2026-04-27) below. +## Optimisation boundaries + +Every optimisation in this document operates within these hard constraints: + +1. **In-process only.** No multi-processing, no `fork` / `spawn`. Multi-core + fanout is the user's deployment problem (systemd, k8s, external + supervisors). Multi-*selector* inside one process is in scope. +2. **No async Python.** Sync handlers + threads only on the server side. + `asyncio` / `uvloop` / ASGI are out of scope. The async-ASGI comparators + (`starlette_uvicorn`, `starlette_granian`) stay in the bench matrix as + reference points only — they're not goals. +3. **GIL or free-threaded.** Standard CPython 3.12+ is the baseline. + Free-threaded builds (3.13t / 3.14t) are an accepted target — the + architecture should hold up there with care, and `selectors=N` is where + no-GIL scaling actually pays off. + +These constraints exist because LocalPost is a *library*, not a deployment +platform. Process supervision, multi-worker orchestration, and hot reloads +belong upstream. + ## Headline numbers | Stack | RPS | p50 (ms) | concurrency | @@ -553,14 +573,184 @@ same session. mechanical import swap, not a semantic change. Documented in CHANGELOG. -### What's left for future perf work +## Phase 7 (shipped, but flat): multi-selector single-process (2026-04-29) + +Added the `selectors: int = 1` knob to `http_server` / `httptools_server` / +`wsgi_server`. With `selectors > 1`, N independent `BaseServer` threads +each bind their own listening socket on the same address via +`SO_REUSEPORT` (already enabled in `_base.py`); the kernel hashes +incoming SYNs across them. Shared handler / shared `thread_pool_handler` +worker pool — only the selector layer fans out. Port 0 is resolved once +up-front so all selectors agree on the actual ephemeral. + +### Bench (httptools backend, 8s/cell, Python 3.13 on Darwin arm64) + +| Scenario | s1 RPS / p50 | s2 RPS / p50 | s4 RPS / p50 | +| ----------------- | -------------------: | -------------------: | -------------------: | +| `plaintext` | 12,563 / 5.05 ms | 12,902 / 4.93 ms | 12,784 / 4.98 ms | +| `path_param` | 12,606 / 5.04 ms | 12,691 / 5.01 ms | 12,761 / 5.00 ms | +| `json_post` | 12,359 / 2.57 ms | 12,369 / 2.57 ms | 12,301 / 2.58 ms | +| `profile_update` | 5,978 / 5.21 ms | 6,007 / 5.20 ms | 6,020 / 5.20 ms | + +All deltas (≤+2.7% on s2, ≤+1.8% on s4) are within run-to-run noise. +**The projected 1.5-2x gain on standard CPython did not materialise.** + +### Why the projection was wrong + +Two compounding factors: + +1. **The GIL-held fraction of selector wall time is higher than I + estimated.** I assumed `recv` + the bulk of `httptools.feed_data` + release the GIL, leaving roughly 50% of wall time for parallelism. + In reality, the parser callbacks (`on_url`, `on_header`, + `on_headers_complete`, `on_body`, `on_message_complete`), the + dispatch decision, `Channel.put`, op-queue enqueue, and the + wakeup-pipe `os.write` all hold the GIL — together they're more + like 80-90% of per-request work. Two selector threads serialise on + that, leaving little to overlap. +2. **macOS `SO_REUSEPORT` is not Linux's `SO_REUSEPORT`.** macOS + permits multiple binds to the same address, but the BPF-style + round-robin / 4-tuple-hash distribution that Linux 3.9+ ships is + not part of the macOS contract. Distribution behaviour is + unspecified — connections can funnel to one selector. We didn't + instrument per-selector accept counts in this bench, but the flat + result is consistent with either "GIL pinch" or "no kernel + distribution" or both. + +The implementation itself is correct (137/137 http tests pass, including +parity tests for `selectors ∈ {2, 3}`). The architectural lever just +doesn't pay on this platform / interpreter. + +### What this means for next steps + +- **Free-threaded Python (Phase 7b) is now the more interesting test.** + Removing the GIL is the only thing that lifts factor #1. macOS + `SO_REUSEPORT` semantics still apply — if distribution is also weak + there, we'd need an explicit accept-dispatch fallback (one acceptor + thread + N selector threads handing off via the op queue). +- **Tier 2 micro-opts deliver more reliably on standard CPython.** + Single-`sendall` per response, `socket.sendmsg` for chunked, and the + Tier 3 allocation diet are all `selectors`-independent and cut + per-request CPU on the path that's actually hot. + +`selectors=N` stays in the public API. It's free for users on Linux who +already get kernel-level load balancing, and it's the right shape for +the eventual no-GIL world. We just don't claim it as a perf win on +standard-CPython / macOS. + +## Phase 7b (shipped): free-threaded Python (3.14t) validation (2026-04-29) + +Tested LocalPost under CPython 3.14.4 free-threaded (no-GIL) on Darwin +arm64. Setup: separate venv (``.venv-ft``), `httptools` built from +git main (declares `Py_mod_gil = Py_MOD_GIL_NOT_USED` since 0.8.0; +the released 0.7.1 wheel auto-re-enables the GIL on import). + +### Headline: free-threading alone is the big win + +**Same code, same hardware, same bench harness — switching from standard +CPython 3.13 to free-threaded 3.14t at `selectors=1`:** + +| Backend / scenario | 3.13 RPS / p50 | 3.14t RPS / p50 | Δ RPS | +| --------------------------- | ------------------: | -------------------: | -------: | +| `localpost_httptools` plaintext | 12,563 / 5.05 ms | **36,208 / 1.76 ms** | **+188%** | +| `localpost_httptools` path_param | 12,606 / 5.04 ms | **35,491 / 1.78 ms** | **+182%** | +| `localpost_httptools` json_post | 12,359 / 2.57 ms | **34,220 / 0.93 ms** | **+177%** | +| `localpost_native` plaintext | ~9,500 / 6.7 ms | **23,688 / 2.69 ms** | **+150%** | + +p50 collapses ~3x on every parser-bound scenario. The reason is the +existing single-selector + worker-pool architecture is itself a +multi-threaded design (1 selector thread + 32 workers); under standard +CPython all those threads serialise on the GIL during the +parser-callbacks / dispatch / handler / response-write critical +sections. No-GIL lets the selector and workers actually overlap. The +selector loop is no longer the throughput ceiling — the workers are. + +### Multi-selector under 3.14t: still flat on macOS + +| Scenario | s1 RPS / p50 | s2 RPS / p50 | s4 RPS / p50 | +| ----------------- | -------------------: | -------------------: | -------------------: | +| `plaintext` | 36,208 / 1.76 ms | 36,089 / 1.75 ms | 36,112 / 1.75 ms | +| `path_param` | 35,491 / 1.78 ms | 35,965 / 1.77 ms | 36,638 / 1.74 ms | +| `json_post` | 34,220 / 0.93 ms | 33,740 / 0.94 ms | 34,538 / 0.92 ms | + +Same pattern at higher concurrency (`oha -c 256`) and in inline mode +(no thread pool — handlers run directly on the selector thread, +isolating selector-level throughput): all configurations converge on +the same number. + +### Why multi-selector is flat: macOS `SO_REUSEPORT` does not distribute + +Confirmed empirically with a diagnostic build +(`benchmarks/http/apps/localpost_httptools_diag.py`) that counts requests +per selector thread. At `selectors=4`, `oha -c 512 -z 8s`: + +``` +=== selector distribution (total=402,871) === + tid=6168244224: 402,871 reqs (100.0%) +``` -- **Sendfile / vectored writes** for response bodies. Each - ``sock.sendall`` on the response path is one syscall; status + first - chunk + terminator could be one ``writev`` for small responses. +**100% of accepts went to one selector thread.** The other three +selectors — each with its own listening socket bound to the same port +via `SO_REUSEPORT` — got zero connections. This is the documented +divergence between Linux's `SO_REUSEPORT` (BPF-style hash distribution +since 3.9) and macOS's, which permits the bind but does not load-balance. + +The implication: on macOS, the `selectors > 1` knob is a no-op. +On Linux (kernel-level distribution) we expect it to scale, but that's +unverified pending a Linux bench. + +### What this means for next steps + +- **Free-threaded Python is now the default for serious perf.** A 3x + jump just by switching interpreters dwarfs every micro-optimisation + we'd land in pure code. We should treat 3.14t as the supported fast + path and keep the codebase free-threaded-clean (no GIL-dependent + patterns; verify deps' `Py_mod_gil` declarations). +- **Accept-dispatch fallback is the next architectural step** — and now + has a clear motivation. One acceptor thread doing `accept()` on a + shared listening socket, dispatching new conns to N selector threads + via the existing op queue. Platform-portable; works on macOS where + `SO_REUSEPORT` doesn't help. The selector loop already accommodates + cross-thread `_OpTrack` enqueues from Phase 3-B — the wiring is + small. +- **Linux-side multi-selector is still worth verifying** — the existing + `selectors > 1` path should scale there. A free CI cell (Linux x86_64, + 3.14t) would settle it. + +The implementation that shipped (`selectors: int = 1`) is correct and +keeps its place in the public API; under the current macOS bench it +just doesn't pay. The right framing for users: "use `> 1` only if your +kernel distributes (Linux 3.9+) or paired with the upcoming +accept-dispatch design." + +## What's left for future perf work + +Within the [Optimisation boundaries](#optimisation-boundaries) at the top of +this doc: + +- **Linux multi-selector validation.** The shipped `selectors > 1` path + should scale on Linux (kernel-level `SO_REUSEPORT` distribution since + 3.9). Untested locally because dev is macOS-only; settle it on the + first Linux bench cell — no code change needed. Real deployments are + ~99% Linux, so this is the actual perf focus, not the macOS dev-box + result above. +- **Accept-dispatch alternative — explicitly dropped.** A + one-acceptor + N-selector design (dispatching new conns via the + existing Phase 3-B op queue) would close the macOS multi-selector + gap, but adds an in-process accept hop on Linux where the kernel + already distributes for free. Not worth the maintenance cost given + the deployment target. +- **Worker-side syscall coalescing.** One ``sendall`` per response (status + + headers + body in a single ``bytearray``); ``socket.sendmsg`` for + chunked responses (status + first chunk + terminator in one syscall). - **Pre-baked common headers** (Date, Server) cached per-second on the - ``BaseServer``, written by both backends. -- The selector-thread CPU is now lower (parser cheaper). If we ever - return to closing the gap to async-ASGI stacks, **multi-selector - + SO_REUSEPORT** is the next architectural lever — the op-queue - design from Phase 3-B already accommodates multiple selectors. + ``BaseServer``, written by both backends — only if we ever auto-emit + them. +- **Per-request allocation diet.** Drop redundant ``bytes()`` copies in + the httptools callbacks; cache header-presence flags so we stop scanning + ``_has_connection_close`` / ``_has_content_length_or_te`` linearly per + response. + +Explicit non-goals (per [Optimisation boundaries](#optimisation-boundaries)): +multi-process, async/ASGI on the server side, thread-per-connection rewrite +(Cheroot already exists), sendfile (no benched workload exercises it). diff --git a/benchmarks/http/apps/_cli.py b/benchmarks/http/apps/_cli.py index 23ec187..00b83b1 100644 --- a/benchmarks/http/apps/_cli.py +++ b/benchmarks/http/apps/_cli.py @@ -1,14 +1,32 @@ """Tiny shared CLI helper for app modules. -Each app reads a ``--port`` (default 8000) and binds 127.0.0.1. +Each app reads a ``--port`` (default 8000) and binds 127.0.0.1. LocalPost +apps additionally accept ``--selectors`` (default 1) for multi-selector +mode. """ from __future__ import annotations import argparse +from dataclasses import dataclass def parse_port(default: int = 8000) -> int: p = argparse.ArgumentParser() p.add_argument("--port", type=int, default=default) return p.parse_args().port + + +@dataclass(slots=True, frozen=True) +class AppArgs: + port: int + selectors: int + + +def parse_args(default_port: int = 8000) -> AppArgs: + """Port + selectors. Used by LocalPost bench apps.""" + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=default_port) + p.add_argument("--selectors", type=int, default=1) + a = p.parse_args() + return AppArgs(port=a.port, selectors=a.selectors) diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index 78727b0..31df69d 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -8,7 +8,7 @@ import sys import time -from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( @@ -41,19 +41,19 @@ def _profile_update(ctx: RequestCtx) -> Response: def main() -> int: - port = parse_port() + args = parse_args() routes = Routes() routes.get("/ping")(_ping) routes.get("/hello/{name}")(_hello) routes.post("/echo")(_echo) routes.post("/users/{user_id}/profile")(_profile_update) handler = routes.build().as_handler() - cfg = ServerConfig(host="127.0.0.1", port=port) + cfg = ServerConfig(host="127.0.0.1", port=args.port) @service async def app(): async with thread_pool_handler(handler, max_concurrency=32) as wrapped: - async with httptools_server(cfg, wrapped): + async with httptools_server(cfg, wrapped, selectors=args.selectors): yield return run_app(app()) diff --git a/benchmarks/http/apps/localpost_httptools_diag.py b/benchmarks/http/apps/localpost_httptools_diag.py new file mode 100644 index 0000000..782f1e2 --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_diag.py @@ -0,0 +1,106 @@ +"""Diagnostic: inline httptools app that counts requests per selector thread. + +Each connection is owned by exactly one ``BaseServer`` (the one whose +selector thread accepted it), and inline mode runs the handler on the +selector thread. So ``threading.get_ident()`` inside the handler maps +1:1 to "which selector served this request" — letting us inspect whether +``SO_REUSEPORT`` is actually distributing across the N selector threads. + +On shutdown (SIGTERM/SIGINT) prints a histogram to stderr, e.g.:: + + selector tid=6175285248: 12,341 reqs (32.1%) + selector tid=6175821824: 13,012 reqs (33.9%) + selector tid=6176358400: 13,041 reqs (34.0%) + +A flat distribution → REUSEPORT is balancing. A skewed one → it isn't, +and selectors past the first are starved. +""" + +from __future__ import annotations + +import atexit +import signal +import sys +import threading +import time +from collections import Counter + +from benchmarks.http.apps._cli import parse_args +from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost.hosting import run_app, service +from localpost.http import ( + RequestCtx, + Response, + Routes, + ServerConfig, + httptools_server, +) + +_per_selector: Counter[int] = Counter() +_lock = threading.Lock() + + +def _record() -> None: + tid = threading.get_ident() + with _lock: + _per_selector[tid] += 1 + + +def _ping(_: RequestCtx) -> Response: + _record() + return Response(200, {"content-type": "text/plain"}, [PING_BODY]) + + +def _hello(ctx: RequestCtx) -> Response: + _record() + return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) + + +def _echo(ctx: RequestCtx) -> Response: + _record() + return Response(200, {"content-type": "application/json"}, [ctx.body()]) + + +def _profile_update(ctx: RequestCtx) -> Response: + _record() + body = profile_update_body(ctx.path_args["user_id"], ctx.body()) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response(200, {"content-type": "application/json"}, [body]) + + +def _print_distribution() -> None: + with _lock: + snapshot = dict(_per_selector) + if not snapshot: + return + total = sum(snapshot.values()) + print(f"\n=== selector distribution (total={total:,}) ===", file=sys.stderr) + for tid, n in sorted(snapshot.items(), key=lambda kv: -kv[1]): + pct = 100.0 * n / total if total else 0.0 + print(f" tid={tid}: {n:,} reqs ({pct:.1f}%)", file=sys.stderr) + + +def main() -> int: + args = parse_args() + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/hello/{name}")(_hello) + routes.post("/echo")(_echo) + routes.post("/users/{user_id}/profile")(_profile_update) + handler = routes.build().as_handler() + cfg = ServerConfig(host="127.0.0.1", port=args.port) + + atexit.register(_print_distribution) + signal.signal(signal.SIGTERM, lambda *_: (_print_distribution(), sys.exit(0))) + + @service + async def app(): + async with httptools_server(cfg, handler, selectors=args.selectors): + yield + + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py new file mode 100644 index 0000000..21c01d0 --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -0,0 +1,67 @@ +"""LocalPost httptools backend — inline (no thread pool). + +Runs every request directly on the selector thread; bypasses +``thread_pool_handler`` to isolate selector-level throughput. Used to +characterise whether multi-selector unlocks parallelism when the worker +pool isn't the bottleneck. + +Reads the same ``--port`` / ``--selectors`` flags as the standard +``localpost_httptools`` app. +""" + +from __future__ import annotations + +import sys +import time + +from benchmarks.http.apps._cli import parse_args +from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost.hosting import run_app, service +from localpost.http import ( + RequestCtx, + Response, + Routes, + ServerConfig, + httptools_server, +) + + +def _ping(_: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [PING_BODY]) + + +def _hello(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) + + +def _echo(ctx: RequestCtx) -> Response: + return Response(200, {"content-type": "application/json"}, [ctx.body()]) + + +def _profile_update(ctx: RequestCtx) -> Response: + body = profile_update_body(ctx.path_args["user_id"], ctx.body()) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return Response(200, {"content-type": "application/json"}, [body]) + + +def main() -> int: + args = parse_args() + routes = Routes() + routes.get("/ping")(_ping) + routes.get("/hello/{name}")(_hello) + routes.post("/echo")(_echo) + routes.post("/users/{user_id}/profile")(_profile_update) + handler = routes.build().as_handler() + cfg = ServerConfig(host="127.0.0.1", port=args.port) + + @service + async def app(): + async with httptools_server(cfg, handler, selectors=args.selectors): + yield + + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_inline_s2.py b/benchmarks/http/apps/localpost_httptools_inline_s2.py new file mode 100644 index 0000000..742f9b5 --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_inline_s2.py @@ -0,0 +1,11 @@ +"""LocalPost httptools backend, inline (no pool), ``selectors=2``.""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_httptools_inline import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "2"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_inline_s4.py b/benchmarks/http/apps/localpost_httptools_inline_s4.py new file mode 100644 index 0000000..409df8d --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_inline_s4.py @@ -0,0 +1,11 @@ +"""LocalPost httptools backend, inline (no pool), ``selectors=4``.""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_httptools_inline import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_s2.py b/benchmarks/http/apps/localpost_httptools_s2.py new file mode 100644 index 0000000..bae3aef --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_s2.py @@ -0,0 +1,15 @@ +"""LocalPost httptools backend with ``selectors=2``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_httptools` that injects +``--selectors 2`` so the runner can pick it up as a separate stack. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_httptools import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "2"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_s4.py b/benchmarks/http/apps/localpost_httptools_s4.py new file mode 100644 index 0000000..2d09813 --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_s4.py @@ -0,0 +1,15 @@ +"""LocalPost httptools backend with ``selectors=4``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_httptools` that injects +``--selectors 4`` so the runner can pick it up as a separate stack. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_httptools import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index 2826f24..1912d83 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -5,7 +5,7 @@ import sys import time -from benchmarks.http.apps._cli import parse_port +from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( @@ -38,19 +38,19 @@ def _profile_update(ctx: RequestCtx) -> Response: def main() -> int: - port = parse_port() + args = parse_args() routes = Routes() routes.get("/ping")(_ping) routes.get("/hello/{name}")(_hello) routes.post("/echo")(_echo) routes.post("/users/{user_id}/profile")(_profile_update) handler = routes.build().as_handler() - cfg = ServerConfig(host="127.0.0.1", port=port) + cfg = ServerConfig(host="127.0.0.1", port=args.port) @service async def app(): async with thread_pool_handler(handler, max_concurrency=32) as wrapped: - async with http_server(cfg, wrapped): + async with http_server(cfg, wrapped, selectors=args.selectors): yield return run_app(app()) diff --git a/benchmarks/http/apps/localpost_native_s2.py b/benchmarks/http/apps/localpost_native_s2.py new file mode 100644 index 0000000..ab860c6 --- /dev/null +++ b/benchmarks/http/apps/localpost_native_s2.py @@ -0,0 +1,15 @@ +"""LocalPost h11 backend with ``selectors=2``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_native` that injects +``--selectors 2`` so the runner can pick it up as a separate stack. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_native import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "2"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_native_s4.py b/benchmarks/http/apps/localpost_native_s4.py new file mode 100644 index 0000000..9af30ad --- /dev/null +++ b/benchmarks/http/apps/localpost_native_s4.py @@ -0,0 +1,15 @@ +"""LocalPost h11 backend with ``selectors=4``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_native` that injects +``--selectors 4`` so the runner can pick it up as a separate stack. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_native import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4"] + sys.exit(main()) diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index 2d1a176..7cbca90 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -41,7 +41,14 @@ STACKS: tuple[str, ...] = ( "localpost_native", + "localpost_native_s2", + "localpost_native_s4", "localpost_httptools", + "localpost_httptools_s2", + "localpost_httptools_s4", + "localpost_httptools_inline", + "localpost_httptools_inline_s2", + "localpost_httptools_inline_s4", "localpost_wsgi", "localpost_flask", "flask_cheroot", diff --git a/localpost/http/README.md b/localpost/http/README.md index cfc17ff..73cb098 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -10,6 +10,23 @@ Pair it with `localpost.hosting` for lifecycle management, or run it standalone. For OpenAPI / content negotiation / validation, see [`localpost.experimental.openapi`](../experimental/openapi/README.md). +## Scope and constraints + +The server is intentionally bounded: + +- **In-process only.** No multi-processing, no `fork` / `spawn`. If you need + multi-core fanout, run multiple `localpost` processes under an external + supervisor (systemd, gunicorn, k8s replicas, etc.). Multi-*selector* + inside one process is on the roadmap. +- **Sync handlers only.** No `asyncio` / `uvloop` / ASGI on the server side. + The hosting layer's AnyIO integration is unaffected — that's lifecycle + plumbing, not the request hot path. If you need an async server, use one + of the ASGI servers via `localpost.hosting.services/`. +- **GIL or free-threaded.** Standard CPython 3.12+ is the baseline. + Free-threaded builds (3.13t / 3.14t) are an accepted target — the design + is single-writer-per-selector and uses only thread-safe primitives, but + see `benchmarks/http/PERF_FINDINGS.md` for any noted caveats per release. + ## Install ```bash diff --git a/localpost/http/_service.py b/localpost/http/_service.py index fa66477..55ffaaa 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,10 +1,12 @@ from __future__ import annotations +import dataclasses +import socket from collections.abc import Awaitable, Callable from contextlib import AbstractContextManager from wsgiref.types import WSGIApplication -from anyio import from_thread, to_thread +from anyio import Event, create_task_group, from_thread, to_thread from localpost import hosting from localpost.hosting import ServiceLifetime @@ -19,22 +21,85 @@ _StartFn = Callable[[ServerConfig, RequestHandler], AbstractContextManager[BaseServer]] -def _serve(config: ServerConfig, handler: RequestHandler, start: _StartFn): - def run(lt: ServiceLifetime) -> Awaitable[None]: - def run_server() -> None: - with start(config, handler) as server: - lt.set_started() +def _resolve_ephemeral_port(host: str) -> int: + """Pin an ephemeral port up-front so all selectors agree on it. + + With ``selectors > 1`` and ``config.port == 0`` each selector would + bind to a *different* ephemeral, defeating the shared-port design. + Bind once here, return the assigned port, then close — selectors + rebind via ``SO_REUSEPORT``. Tiny race window vs other processes + grabbing the port; same window the existing test fixture accepts. + """ + with socket.create_server((host, 0)) as probe: + return probe.getsockname()[1] + + +def _serve( + config: ServerConfig, + handler: RequestHandler, + start: _StartFn, + selectors: int, +): + if selectors < 1: + raise ValueError(f"selectors must be >= 1 (got {selectors})") + + if selectors == 1: + def run_single(lt: ServiceLifetime) -> Awaitable[None]: + def run_server() -> None: + with start(config, handler) as server: + lt.set_started() + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + server.run() + + return to_thread.run_sync(run_server) + + return run_single + + # Multi-selector: N independent ``BaseServer`` threads bound to the + # same address via ``SO_REUSEPORT`` (already enabled in + # ``start_http_server_base``). The kernel hashes incoming SYNs across + # the listening sockets; established conns stay with one selector for + # their lifetime. The ``handler`` instance is shared — when wrapped + # with ``thread_pool_handler`` the underlying channel naturally accepts + # multiple producers. + actual_config = ( + dataclasses.replace(config, port=_resolve_ephemeral_port(config.host)) + if config.port == 0 + else config + ) + + async def run_multi(lt: ServiceLifetime) -> None: + started: list[Event] = [Event() for _ in range(selectors)] + + def run_one(idx: int) -> None: + with start(actual_config, handler) as server: + from_thread.run_sync(started[idx].set) while not lt.shutting_down.is_set(): from_thread.check_cancelled() server.run() - return to_thread.run_sync(run_server) + async def wait_started() -> None: + for ev in started: + await ev.wait() + lt.set_started() - return run + async with create_task_group() as tg: + tg.start_soon(wait_started) + for i in range(selectors): + tg.start_soon(to_thread.run_sync, run_one, i) + + return run_multi @hosting.service -def http_server(config: ServerConfig, handler: RequestHandler, /): +def http_server( + config: ServerConfig, + handler: RequestHandler, + /, + *, + selectors: int = 1, +): """Run an HTTP server inside a hosted service. ``handler`` is invoked on the **selector thread** for every accepted @@ -48,26 +113,55 @@ def http_server(config: ServerConfig, handler: RequestHandler, /): and is wired up by :func:`thread_pool_handler` (the only place that needs it — selector-thread handlers run to completion synchronously and have no thread to signal). + + Args: + config: Listening / timeouts / buffer-size config. + handler: Sync request handler. Shared across selector threads when + ``selectors > 1`` — make sure any state it captures is + thread-safe (``thread_pool_handler`` already is). + selectors: Number of selector threads. Default 1. With ``> 1``, + each selector binds its own listening socket on the same + address via ``SO_REUSEPORT``; the kernel distributes incoming + connections. ``config.port == 0`` is resolved to an ephemeral + port once and shared by all selectors. Measured impact on + standard CPython / macOS is flat (GIL holds during parser + callbacks + dispatch + channel handoff; macOS + ``SO_REUSEPORT`` doesn't load-balance like Linux). Useful on + Linux (kernel-level distribution) and on free-threaded + builds; see ``benchmarks/http/PERF_FINDINGS.md`` Phase 7. """ - return _serve(config, handler, start_http_server) + return _serve(config, handler, start_http_server, selectors=selectors) @hosting.service -def httptools_server(config: ServerConfig, handler: RequestHandler, /): +def httptools_server( + config: ServerConfig, + handler: RequestHandler, + /, + *, + selectors: int = 1, +): """Same as :func:`http_server`, but using the httptools backend. Requires the ``[http-fast]`` extra. See :func:`localpost.http.start_httptools_server` for backend differences. + See :func:`http_server` for the ``selectors`` knob. """ # Lazy: importing server_httptools at module top would require httptools to # be installed for users that only need ``http_server`` (h11). The extra # ``[http-fast]`` is opt-in. from localpost.http.server_httptools import start_httptools_server # noqa: PLC0415 - return _serve(config, handler, start_httptools_server) + return _serve(config, handler, start_httptools_server, selectors=selectors) -def wsgi_server(config: ServerConfig, app: WSGIApplication, /): +def wsgi_server( + config: ServerConfig, + app: WSGIApplication, + /, + *, + selectors: int = 1, +): """Same as :func:`http_server`, but for a WSGI application. A WSGI app blocks during request handling (response body iteration is @@ -78,4 +172,4 @@ def wsgi_server(config: ServerConfig, app: WSGIApplication, /): async with http_server(config, h): ... """ - return http_server(config, wrap_wsgi(app)) + return http_server(config, wrap_wsgi(app), selectors=selectors) diff --git a/tests/http/service.py b/tests/http/service.py index 5727867..2055511 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -20,6 +20,7 @@ from localpost.hosting import ServiceLifetimeView, serve from localpost.http import ( HTTPReqCtx, + NativeResponse, RequestCancelled, RequestCtx, Response, @@ -28,7 +29,6 @@ check_cancelled, http_server, thread_pool_handler, - NativeResponse, ) from localpost.http.server import RequestHandler @@ -273,6 +273,75 @@ async def test_invalid_max_concurrency(self): thread_pool_handler(_handler_200(), max_concurrency=0) +class TestMultiSelector: + """``selectors=N > 1`` spawns N independent ``BaseServer`` threads bound + to the same address via ``SO_REUSEPORT``. Tests assert correctness; the + kernel-side distribution of incoming connections across selectors is a + deployment-time concern verified by the bench, not unit tests.""" + + async def test_invalid_selectors(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + with pytest.raises(ValueError, match="selectors"): + http_server(cfg, _handler_200(), selectors=0) + + async def test_serves_requests_inline(self, free_port): + """selectors=2, no thread pool. Each request runs on whichever + selector accepted it; both must serve correctly.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=2)) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(5): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_requests_pooled_concurrent(self, free_port): + """selectors=2 + thread pool. Multiple selectors push onto the + single shared channel; the pool drains across both producers.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with thread_pool_handler(_handler_200(b"hi"), max_concurrency=4) as wrapped: + async with serve(http_server(cfg, wrapped, selectors=2)) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[httpx.Response | None] = [None] * 10 + + async def fire(i: int) -> None: + results[i] = await _get(f"http://127.0.0.1:{free_port}/") + + async with anyio.create_task_group() as tg: + for i in range(10): + tg.start_soon(fire, i) + + for r in results: + assert r is not None + assert r.status_code == 200 + assert r.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_shutdown_stops_all_selectors(self, free_port): + """All N selector threads must exit on shutdown — no leaked threads + and a clean exit code.""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"x"), selectors=3)) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + class TestSelectorThreadFastPath: """When the Router is passed directly to ``http_server`` (no ``thread_pool_handler`` wrapping), every route — including the 404 / 405 paths — runs on the selector From 8e9b8389eb9727fa65444d049f175bc792a917a2 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 18:28:59 +0400 Subject: [PATCH 116/286] feat(http): two-phase handler contract + auto-buffered response (Phase 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructured the HTTP request lifecycle around the JSON-API common case the user actually deploys: - RequestHandler is now Callable[[HTTPReqCtx], BodyHandler | None]. The pre-body handler runs on the selector when headers are parsed; it can reject inline (return None — 404 / 405 / auth fail, no body recv) or return a BodyHandler continuation that the selector invokes after buffering the full body into ctx.body. - Drop HTTP/1.1 pipelining in the httptools backend. Removes the _ready deque + per-request cur-state machinery; one in-flight request per conn. Pipelined clients are served sequentially. - Auto-buffer response writes in HTTPReqCtx (both backends). start_response stashes the serialised status+headers; the next send() (or finish_response() for empty bodies) emits headers + first body chunk in one sendall. Common-case complete() is now 1 syscall, down from 2. - thread_pool_handler adapts: pre-body inner pass-through if it returns None, otherwise the returned BodyHandler is wrapped into a worker-dispatch continuation. Inline 404s never enter the channel; bodies are buffered by the selector, so workers no longer recv body. - Router.as_handler, wrap_wsgi, flask_handler, sentry_router_handler, sentry_flask_handler all updated to the new contract. Old-style (ctx) -> None handlers are forward-compatible. Bench (8s/cell, standard CPython 3.13 / Darwin arm64 — primary target): httptools plaintext: 12,563 -> 15,197 RPS (+21%) p50 5.05 -> 4.13 ms httptools path_param: 12,606 -> 15,312 RPS (+21%) p50 5.04 -> 4.13 ms httptools json_post: 12,359 -> 15,051 RPS (+22%) p50 2.57 -> 2.11 ms h11 plaintext: 9,494 -> 10,012 RPS (+5%) Free-threaded 3.14t: pooled httptools plaintext 34,456 RPS (within +/-5% of Phase 7's 36,208 - bench is client-saturated at c=64 once p50 drops below 2 ms). Inline (no pool) 52,015 RPS. The restructure is neutral under no-GIL - the savings are already absorbed there. API break: handlers reading body via ctx.receive(size) need to migrate to the continuation pattern (read ctx.body inside a BodyHandler). All in-tree adapters and tests updated. README + CHANGELOG + PERF_FINDINGS.md Phase 8 document the design and numbers. 137/137 http tests pass on standard CPython; service+parity tests (48 tests) pass under 3.14t no-GIL on both asyncio and trio. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 29 ++- benchmarks/http/PERF_FINDINGS.md | 98 ++++++++ localpost/http/README.md | 22 ++ localpost/http/__init__.py | 3 +- localpost/http/_base.py | 25 +- localpost/http/_pool.py | 93 +++++--- localpost/http/flask.py | 15 +- localpost/http/flask_sentry.py | 16 +- localpost/http/router.py | 98 +++++--- localpost/http/router_sentry.py | 59 +++-- localpost/http/server.py | 2 + localpost/http/server_h11.py | 135 +++++++++-- localpost/http/server_httptools.py | 365 ++++++++++++++++------------- localpost/http/wsgi.py | 65 +++-- tests/http/backend_parity.py | 28 +-- tests/http/service.py | 30 ++- 16 files changed, 746 insertions(+), 337 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7631345..6b713c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Two-phase HTTP request handler contract.** `RequestHandler` is now + `Callable[[HTTPReqCtx], BodyHandler | None]`. The pre-body handler runs + on the selector thread when headers are parsed and may either complete + inline (returning `None`, e.g. for 404 / 405 / auth fail) or return a + `BodyHandler` continuation. The selector buffers the full request body + into `ctx.body` before invoking the continuation. Old-style + `(ctx) -> None` handlers are forward-compatible. See + `benchmarks/http/PERF_FINDINGS.md` Phase 8 for the design rationale and + bench numbers (+21% RPS on standard CPython 3.13 httptools). +- `localpost.http.BodyHandler` — type alias for the post-body continuation. - **Free-threaded CPython (3.14t) support** — verified end-to-end with the full http test suite passing and the bench delivering a ~3x RPS jump at `selectors=1` (httptools plaintext: 12,563 → 36,208 RPS) just from @@ -34,9 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 fix and is the next planned step. The knob still works on Linux, pending a Linux bench cell to confirm scaling. - `localpost.http.thread_pool_handler` — async context manager that wraps - any `RequestHandler` so it runs on a worker thread. Compose explicitly - with `http_server` when you need a worker pool; immediate handlers - (including a `Router`'s 404/405 path) stay on the selector thread. + a `RequestHandler` so the post-body `BodyHandler` continuation it + returns is offloaded to a worker thread. Pre-body decisions (routing, + auth, 404 / 405) stay on the selector. Compose explicitly with + `http_server` when you want body handlers on a worker pool. - `just deadcode` — vulture-based dead-code finder, configured in `pyproject.toml` (`[tool.vulture]`). - **Optional `httptools` HTTP server backend** under the `[http-fast]` @@ -56,6 +67,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`RequestHandler` return type changed** from `None` to + `BodyHandler | None` (see Added). Old-style `(ctx) -> None` handlers + still work — returning `None` is the inline-completion path. + Handlers that previously read the body via `ctx.receive(size)` need + to migrate to the continuation pattern: return a `BodyHandler` and + read the body from `ctx.body`. The in-tree adapters + (`Router.as_handler`, `wrap_wsgi`, `flask_handler`, + `sentry_router_handler`, `sentry_flask_handler`) are updated. +- **HTTP/1.1 pipelining is no longer supported.** Pipelined clients are + served sequentially — correct, but no parallelism on the same + connection. The httptools backend's `_ready` deque was removed for the + simplification. - **`localpost.http` no longer leaks h11 types into the public API.** `HTTPReqCtx.request` is now `localpost.http.Request` (was `h11.Request`); `HTTPReqCtx.start_response` and `complete` accept diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 2592837..a5c7498 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -23,6 +23,27 @@ These constraints exist because LocalPost is a *library*, not a deployment platform. Process supervision, multi-worker orchestration, and hot reloads belong upstream. +### Workload assumptions (the JSON-API common case) + +These guide which paths get optimised first; we cut corners on shapes +that don't fit: + +- **Reject before body.** No-route / wrong-method / auth failures + complete inline on the selector, with no body recv. +- **Body is buffered, not streamed.** When a handler needs the body, it + needs the whole thing (e.g. to deserialise JSON). The selector + buffers the full body into ``ctx.body`` and then invokes a + :data:`BodyHandler` continuation. There's still a ``ctx.receive`` + streaming API but it isn't the optimised path. +- **Response is one chunk or SSE.** Most responses are one + status-line + headers + body block; SSE generators emit the same + block plus subsequent data chunks. The response writer + auto-buffers headers and flushes them with the first body chunk in a + single ``sendall``. +- **No HTTP/1.1 pipelining.** Pipelined clients are served sequentially + (correct, just no parallelism). Dropping support simplified the + per-conn state machine. + ## Headline numbers | Stack | RPS | p50 (ms) | concurrency | @@ -723,6 +744,83 @@ just doesn't pay. The right framing for users: "use `> 1` only if your kernel distributes (Linux 3.9+) or paired with the upcoming accept-dispatch design." +## Phase 8 (shipped): continuation handler + auto-buffered response (2026-04-29) + +Restructured the request lifecycle around the JSON-API common case: + +1. **Two-phase handler contract.** + `RequestHandler = Callable[[HTTPReqCtx], BodyHandler | None]`. The + pre-body handler runs on the selector when headers are parsed. It + returns either: + - `None` — handler completed inline (e.g. a 404 / 405 / auth fail + reject) or borrowed the conn for a worker. **No body recv. No + worker hop.** + - `BodyHandler` — the selector buffers the full body into + `ctx.body` and then invokes the continuation. +2. **HTTP/1.1 pipelining dropped.** The httptools backend's + `_ready` deque + per-request cur-state machinery is gone; one + in-flight request per connection. Pipelined clients are served + sequentially. ~50 lines deleted. +3. **Auto-buffered response writes.** `start_response` no longer + flushes — it stashes the serialised headers and the first + `send(...)` (or `finish_response()` for empty bodies) emits + headers + body in a single `sendall`. **Common-case `complete()` + path is now 1 syscall, down from 2.** +4. **`thread_pool_handler` adapts.** The pool no longer wraps the + pre-body invocation. It pass-through if `inner` returns `None` + (inline 404s never enter the channel) and wraps the returned + `BodyHandler` into a worker-dispatch continuation otherwise. + +### Bench (8 s/cell, 64/32 clients) + +**Standard CPython 3.13 / Darwin arm64** — primary perf target (≈ +real-world Linux deployment): + +| Scenario | Phase 7 RPS / p50 | Phase 8 RPS / p50 | Δ RPS | +| ----------------- | -----------------: | ---------------------: | -------: | +| `httptools` plaintext | 12,563 / 5.05 ms | **15,197 / 4.13 ms** | **+21%** | +| `httptools` path_param | 12,606 / 5.04 ms | **15,312 / 4.13 ms** | **+21%** | +| `httptools` json_post | 12,359 / 2.57 ms | **15,051 / 2.11 ms** | **+22%** | +| `h11` plaintext | 9,494 / 6.67 ms | **10,012 / 6.28 ms** | +5% | + +Plus +21% RPS on the path real users will see. p50 collapses with +the single-sendall flush. h11 gains are smaller (still parser-bound), +but the simplification is consistent across both backends. + +**Free-threaded CPython 3.14t / Darwin arm64**: pooled httptools +plaintext sits at 34,456 RPS (vs Phase 7's 36,208 — within ±5% +noise; the bench is client-saturated at `c=64` once p50 drops below +2 ms). Inline (no-pool) httptools plaintext: 52,015 RPS. The +restructure is neutral here, as expected — most of the savings are +already absorbed by the no-GIL win. + +### Why this delivers more than syscall coalescing alone + +Pure micro-opts (combine sendalls, header scan) saved a syscall but +left the architectural shape unchanged. The continuation pattern +adds a bigger lever: **fast handlers (no body) and rejections never +wait for the body to arrive at all.** For the common JSON-API mix +(404 / 405 / GET / POST-with-JSON), this: + +- removes a pool-channel round-trip from every body-free request + (the old `thread_pool_handler` always borrowed) +- removes the worker-thread body recv from POSTs (selector buffers + it; worker just runs the JSON parse + response build) + +The single-sendall change rides on top. + +### One-time API break + +`RequestHandler`'s return type changed from `None` to `BodyHandler | +None`. Old-style `(ctx) -> None` handlers are forward-compatible — +returning `None` implicitly is the "complete inline" path. Handlers +that previously read body via `ctx.receive(size)` need to migrate to +the continuation pattern (return a `BodyHandler` and read the body +from `ctx.body`). All in-tree adapters (`Router.as_handler`, +`wrap_wsgi`, `flask_handler`, `sentry_router_handler`, +`sentry_flask_handler`) have been updated; user code that bypassed +those needs the same shape. + ## What's left for future perf work Within the [Optimisation boundaries](#optimisation-boundaries) at the top of diff --git a/localpost/http/README.md b/localpost/http/README.md index 73cb098..6ed133b 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -27,6 +27,28 @@ The server is intentionally bounded: is single-writer-per-selector and uses only thread-safe primitives, but see `benchmarks/http/PERF_FINDINGS.md` for any noted caveats per release. +### Workload shape (the JSON-API common case) + +The hot path is tuned for the JSON web-API common case, and we cut +corners on shapes that don't fit it: + +- **Reject before body.** Handlers that decide based on headers + (no-route, wrong method, auth) return `None` and the body is never + recv'd. No worker hop, no allocation. +- **Body is buffered, not streamed.** When a handler needs the body + (e.g. to deserialise JSON), it needs the *whole* body. The selector + buffers it into `ctx.body` and invokes a `BodyHandler` continuation; + the continuation just reads `ctx.body`. There is still a + `ctx.receive(size)` streaming API but it isn't the optimised path. +- **Response is one chunk or SSE.** Most responses are one + status+headers+body block; SSE generators emit the same opening + block then per-event chunks. The response writer auto-buffers + headers and flushes them with the first body chunk in a single + `sendall` — common-case `complete(...)` is one syscall. +- **No HTTP/1.1 pipelining.** Pipelined clients are served sequentially + (correct, just no parallelism). The simpler per-conn state machine + is the trade we made for it. + ## Install ```bash diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 3400325..0a6eeb8 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -15,7 +15,7 @@ from localpost.http.router import ( RequestHandler as RouterRequestHandler, ) -from localpost.http.server import HTTPReqCtx, RequestHandler, start_http_server +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, start_http_server from localpost.http.wsgi import wrap_wsgi # ``Response`` (the public name) is the high-level router response. @@ -29,6 +29,7 @@ "start_http_server", "HTTPReqCtx", "RequestHandler", + "BodyHandler", # backend selection "httptools_server", # neutral wire types (used directly with HTTPReqCtx) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index ade8753..3b1f156 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -33,6 +33,7 @@ __all__ = [ "BaseServer", "BaseHTTPConn", + "BodyHandler", "HTTPReqCtx", "RequestHandler", "start_http_server_base", @@ -122,9 +123,14 @@ class HTTPReqCtx(Protocol): reaches into them to borrow the connection for a worker. They're declared as read-only properties so covariant subtypes (concrete ``HTTPConnH11`` / ``HTTPConnHttptools``) satisfy the Protocol. + + The ``body`` attribute is empty when a :data:`RequestHandler` runs + (pre-body phase). It is populated by the selector with the fully + buffered request body before invoking a returned :data:`BodyHandler`. """ request: Request + body: bytes response_status: int | None @property @@ -141,7 +147,24 @@ def finish_response(self) -> None: ... def complete(self, response: Response, body: bytes | None = None) -> None: ... -RequestHandler = Callable[[HTTPReqCtx], None] +BodyHandler = Callable[[HTTPReqCtx], None] +"""Continuation invoked by the selector after the full request body has been +buffered into ``ctx.body``. Must complete the response (or borrow the conn).""" + +RequestHandler = Callable[[HTTPReqCtx], BodyHandler | None] +"""Pre-body handler dispatched on ``on_headers_complete``. + +Returns one of: +- ``None`` — handler is done (either it called ``ctx.complete(...)`` to + send a response inline, or it called ``ctx.borrow()`` to hand the conn + off to a worker thread). Body bytes that arrive afterwards are drained + silently for keep-alive. +- :data:`BodyHandler` — selector buffers the full request body into + ``ctx.body`` and then invokes the returned callable. This is the path + for the JSON-API common case (parse JSON, build response). + +Old-style ``Callable[[HTTPReqCtx], None]`` handlers continue to work — +returning ``None`` implicitly is the "done inline" path.""" def emit_handler_error(ctx: HTTPReqCtx) -> None: diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index acc038e..0964e06 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -1,13 +1,14 @@ """Thread-pool wrapper for HTTP request handlers. -Turns any ``RequestHandler`` into one that borrows the connection on the -selector thread, queues it, and runs the original handler on a worker. The -selector stays free to keep accepting / parsing / dispatching the next -request. - -Compose explicitly with :func:`localpost.http.http_server` — handlers that -can answer synchronously (e.g. a :class:`Router`'s 404/405 path) stay on -the selector thread; only the handlers you wrap pay the worker hop. +Wraps a :data:`RequestHandler` so that, when it returns a +:data:`BodyHandler` continuation, the continuation is dispatched on a +worker thread instead of running on the selector. The pre-body phase +(routing, auth, 404/405) stays on the selector, where it's free; only +post-body work that the user wants offloaded pays the worker hop. + +Compose explicitly with :func:`localpost.http.http_server` — handlers +that complete inline (e.g. a :class:`Router`'s 404/405 path or a +body-free GET) never enter the pool. """ from __future__ import annotations @@ -29,7 +30,7 @@ from localpost import threadtools from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request from localpost.http.config import LOGGER_NAME -from localpost.http.server import HTTPReqCtx, RequestHandler, emit_handler_error +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error __all__ = ["thread_pool_handler"] @@ -40,14 +41,23 @@ def thread_pool_handler( *, max_concurrency: int, ) -> AbstractAsyncContextManager[RequestHandler]: - """Async context manager: yields a ``RequestHandler`` that runs ``inner`` on a worker thread. - - On call, the wrapped handler borrows the connection from the selector - (``stop_tracking``) and queues ``(ctx, cancel)`` on a bounded channel - sized to ``max_concurrency``. ``max_concurrency`` worker threads pull - from the channel, run ``inner(ctx)`` under a per-request cancel scope, - and release the connection back to the selector via ``finish_response`` - (or close it on error / cancellation). + """Async context manager: yields a ``RequestHandler`` that offloads + body-handler continuations to a worker thread. + + The wrapper invokes ``inner(ctx)`` on the selector thread (pre-body + phase). If ``inner`` returns: + + - ``None`` — pass-through. ``inner`` already completed inline (e.g. + a 404 or a body-free GET) or borrowed the conn itself. No worker + hop. + - :data:`BodyHandler` — the wrapper returns a *new* continuation + that, when invoked by the selector after the body has been + buffered, ``stop_tracking`` s the conn and queues + ``(ctx, cancel, inner_continuation)`` on a bounded channel sized + to ``max_concurrency``. ``max_concurrency`` workers pull from the + channel, run ``inner_continuation(ctx)`` under a per-request + cancel scope, and release the connection back to the selector via + ``finish_response`` (or close it on error / cancellation). Per-request cancellation surfaces through :func:`localpost.http.check_cancelled`. Two triggers feed it: client @@ -73,6 +83,9 @@ def thread_pool_handler( return _thread_pool_handler(inner, max_concurrency) +_WorkItem = tuple[HTTPReqCtx, RequestCancel, BodyHandler] + + @asynccontextmanager async def _thread_pool_handler( inner: RequestHandler, @@ -80,42 +93,54 @@ async def _thread_pool_handler( ) -> AsyncGenerator[RequestHandler]: logger = logging.getLogger(LOGGER_NAME) - tx, rx = threadtools.Channel[tuple[HTTPReqCtx, RequestCancel]].create(capacity=max_concurrency) + tx, rx = threadtools.Channel[_WorkItem].create(capacity=max_concurrency) shutdown_event = threading.Event() # Dedicated limiter so workers don't consume slots from AnyIO's global # default thread pool. Each worker holds a slot for its full lifetime, # so we need exactly ``max_concurrency`` slots. workers_limiter = CapacityLimiter(max_concurrency) - def submit(ctx: HTTPReqCtx) -> None: - """Selector-thread callback: borrow the conn and queue it for a worker.""" - ctx._server.stop_tracking(ctx._conn) - cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) - try: - tx.put((ctx, cancel)) - except (ClosedResourceError, BrokenResourceError): - with suppress(Exception): - ctx._conn.close() - - def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: + def make_dispatch(body_handler: BodyHandler) -> BodyHandler: + """Return a continuation that, given a ctx with body buffered, + borrows the conn and queues the work for a worker.""" + + def dispatched(ctx: HTTPReqCtx) -> None: + ctx._server.stop_tracking(ctx._conn) + cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) + try: + tx.put((ctx, cancel, body_handler)) + except (ClosedResourceError, BrokenResourceError): + with suppress(Exception): + ctx._conn.close() + + return dispatched + + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + """The pre-body :data:`RequestHandler` exposed to the server.""" + result = inner(ctx) + if result is None: + return None # inner completed inline or borrowed itself + return make_dispatch(result) + + def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: with my_rx: while True: try: - ctx, cancel = my_rx.get() + ctx, cancel, body_handler = my_rx.get() except (EndOfStream, ClosedResourceError): return try: with _enter_request(cancel): try: - inner(ctx) + body_handler(ctx) except RequestCancelled: # Handler bailed cleanly. Connection state is uncertain — close. with suppress(Exception): ctx._conn.close() except Exception: logger.exception( - "Handler raised for %s %r", ctx.request.method, ctx.request.target + "Body handler raised for %s %r", ctx.request.method, ctx.request.target ) emit_handler_error(ctx) finally: @@ -137,7 +162,7 @@ def worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) with suppress(Exception): ctx._conn.close() - async def run_worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, RequestCancel]]) -> None: + async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: await to_thread.run_sync(worker, my_rx, limiter=workers_limiter) async with create_task_group() as tg: @@ -145,7 +170,7 @@ async def run_worker(my_rx: threadtools.ReceiveChannel[tuple[HTTPReqCtx, Request tg.start_soon(run_worker, rx.clone()) rx.close() try: - yield submit + yield wrapped finally: # Signal in-flight handlers (next ``check_cancelled`` raises) and # let workers drain via ``EndOfStream`` from the closed channel. diff --git a/localpost/http/flask.py b/localpost/http/flask.py index 310be62..14a2824 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -23,7 +23,7 @@ from localpost.http._service import http_server from localpost.http._types import Response as _NativeResponse from localpost.http.config import ServerConfig -from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.wsgi import _build_environ __all__ = ["flask_handler", "flask_server"] @@ -32,11 +32,15 @@ def flask_handler(app: Flask) -> RequestHandler: """Wrap a Flask app as a native :class:`RequestHandler`. - See the module docstring for the behavior differences vs. WSGI — notably, + Always returns a :data:`BodyHandler` continuation so the selector + buffers the request body into ``ctx.body`` before the Flask + pipeline runs (Flask reads it via ``request.get_json`` etc.). + + See the module docstring for behaviour differences vs. WSGI — notably, the request context stays active during response body streaming. """ - def handle(http_ctx: HTTPReqCtx) -> None: + def run_flask(http_ctx: HTTPReqCtx) -> None: environ = _build_environ(http_ctx) with app.request_context(environ): try: @@ -51,7 +55,10 @@ def handle(http_ctx: HTTPReqCtx) -> None: finally: response.close() # Fire werkzeug's call_on_close callbacks - return handle + def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: + return run_flask + + return pre_body def _write_response(http_ctx: HTTPReqCtx, response) -> None: diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index de64f04..7e96e2a 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -30,16 +30,21 @@ from flask import request as flask_request from localpost.http.flask import _write_response -from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.wsgi import _build_environ __all__ = ["sentry_flask_handler"] def sentry_flask_handler(app: Flask, *, op: str = "http.server") -> RequestHandler: - """Wrap a Flask app with a Sentry transaction covering the full request, streaming included.""" + """Wrap a Flask app with a Sentry transaction covering the full request, streaming included. - def handle(ctx: HTTPReqCtx) -> None: + Always returns a :data:`BodyHandler` continuation — Flask reads + ``request`` body internally, so the selector must buffer it before + the Flask pipeline runs. + """ + + def run_flask_with_sentry(ctx: HTTPReqCtx) -> None: environ = _build_environ(ctx) method = environ["REQUEST_METHOD"] path = environ["PATH_INFO"] @@ -75,4 +80,7 @@ def handle(ctx: HTTPReqCtx) -> None: finally: response.close() - return handle + def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: + return run_flask_with_sentry + + return pre_body diff --git a/localpost/http/router.py b/localpost/http/router.py index 407bee4..e278086 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -12,7 +12,7 @@ from localpost._utils import NOT_SET from localpost.http._types import Response as _NativeResponse from localpost.http.config import DEFAULT_BUFFER_SIZE -from localpost.http.server import HTTPReqCtx +from localpost.http.server import BodyHandler, HTTPReqCtx from localpost.http.server import RequestHandler as NativeRequestHandler __all__ = [ @@ -304,13 +304,15 @@ def as_handler(self) -> NativeRequestHandler: """Return a :class:`localpost.http.RequestHandler` that dispatches via this router. 404 (no template match) and 405 (template match, method mismatch) are - answered inline on the calling thread — no thread hop, no allocation - beyond the static response payload. A matched route delegates to its - registered handler, which may itself be a synchronous handler or a - :func:`thread_pool_handler`-wrapped one. + answered inline on the selector thread — no body recv, no worker + hop, no allocation beyond the static response payload. A matched + route returns a :data:`BodyHandler` continuation; the selector + buffers the request body into ``ctx.body`` before invoking it, + and the continuation can be wrapped with + :func:`thread_pool_handler` if you want it offloaded to a worker. """ - def handle(http_ctx: HTTPReqCtx) -> None: + def handle(http_ctx: HTTPReqCtx) -> BodyHandler | None: req = http_ctx.request target = req.target.decode("iso-8859-1") if "?" in target: @@ -323,7 +325,7 @@ def handle(http_ctx: HTTPReqCtx) -> None: if isinstance(match, _MatchNotFound): _send_plain(http_ctx, 404, b"Not Found") - return + return None if isinstance(match, _MatchMethodNotAllowed): _send_plain( http_ctx, @@ -331,36 +333,14 @@ def handle(http_ctx: HTTPReqCtx) -> None: b"Method Not Allowed", extra_headers=[(b"Allow", match.allow_header.encode("ascii"))], ) - return + return None + # Matched route. Hand the body-bearing closure back to the + # selector. ``ctx.body`` is populated when the continuation + # runs — for body-less methods (GET / HEAD / DELETE) it's + # simply ``b""``. headers = {name.decode("iso-8859-1").lower(): value.decode("iso-8859-1") for name, value in req.headers} - - with ExitStack() as stack: - ctx = RequestCtx( - exit_stack=stack, - headers=headers, - method=match.method, - matched_template=match.matched_template, - path=path, - query_string=query_string, - query_args=parse_qs(query_string), - path_args=match.path_args, - receive=http_ctx.receive, - ) - response = match.handler(ctx) - - wire_headers = [(k.encode("iso-8859-1"), v.encode("iso-8859-1")) for k, v in response.headers.items()] - http_ctx.start_response( - _NativeResponse( - status_code=response.status_code, - headers=wire_headers, - reason=_http_phrases.get(response.status_code, "Unknown").encode("iso-8859-1"), - ) - ) - for chunk in response.body: - if chunk: - http_ctx.send(chunk) - http_ctx.finish_response() + return _make_body_continuation(match, path, query_string, headers) return handle @@ -431,6 +411,54 @@ def _headers_from_environ(environ: dict) -> dict[str, str]: return headers +def _make_body_continuation( + match: _MatchOk, + path: str, + query_string: str, + headers: Mapping[str, str], +) -> BodyHandler: + """Build the post-body continuation for a matched route. + + Pre-populates the :class:`RequestCtx` body cache with the bytes + buffered by the selector, so ``ctx.body()`` returns immediately + without going through ``receive()``. + """ + + def run(http_ctx: HTTPReqCtx) -> None: + with ExitStack() as stack: + ctx = RequestCtx( + exit_stack=stack, + headers=headers, + method=match.method, + matched_template=match.matched_template, + path=path, + query_string=query_string, + query_args=parse_qs(query_string), + path_args=match.path_args, + # Body is pre-buffered into ``http_ctx.body``. ``receive`` + # is kept for backwards-compat but only fires for handlers + # that explicitly call it after the cache is consumed. + receive=lambda _: b"", + ) + object.__setattr__(ctx, "_req_body", bytearray(http_ctx.body)) + response = match.handler(ctx) + + wire_headers = [(k.encode("iso-8859-1"), v.encode("iso-8859-1")) for k, v in response.headers.items()] + http_ctx.start_response( + _NativeResponse( + status_code=response.status_code, + headers=wire_headers, + reason=_http_phrases.get(response.status_code, "Unknown").encode("iso-8859-1"), + ) + ) + for chunk in response.body: + if chunk: + http_ctx.send(chunk) + http_ctx.finish_response() + + return run + + def _send_plain( ctx: HTTPReqCtx, status_code: int, diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 9eefc30..74fb7e4 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -29,27 +29,33 @@ def get_book(ctx): ... from __future__ import annotations +import sys + import sentry_sdk from localpost.http.router import Router, _MatchOk -from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler __all__ = ["sentry_router_handler"] def sentry_router_handler(router: Router, *, op: str = "http.server") -> RequestHandler: - """Wrap a :class:`Router` with a Sentry transaction per request.""" + """Wrap a :class:`Router` with a Sentry transaction per request. + + The transaction spans the full request, including the post-body + continuation when the router defers to one. 404 / 405 paths complete + inline and end the transaction in the same call. + """ inner = router.as_handler() - def handle(ctx: HTTPReqCtx) -> None: + def handle(ctx: HTTPReqCtx) -> BodyHandler | None: req = ctx.request method = req.method.decode("ascii") target = req.target.decode("iso-8859-1") path = target.split("?", 1)[0] if "?" in target else target - # Pre-match so the transaction name uses the URI template (low cardinality). - # Router's dispatch will match again, but template matching is a single - # combined regex run — cheap. + # Pre-match so the transaction name uses the URI template (low + # cardinality). Router's dispatch will match again — cheap. match = router._match(path, method) if isinstance(match, _MatchOk): tx_name = f"{method} {match.matched_template.template}" @@ -58,16 +64,37 @@ def handle(ctx: HTTPReqCtx) -> None: tx_name = f"{method} {path}" source = "url" - with ( - sentry_sdk.isolation_scope(), - sentry_sdk.start_transaction(op=op, name=tx_name, source=source) as tx, - ): - tx.set_tag("http.method", method) - tx.set_data("http.url", target) + scope_cm = sentry_sdk.isolation_scope() + scope_cm.__enter__() + tx_cm = sentry_sdk.start_transaction(op=op, name=tx_name, source=source) + tx = tx_cm.__enter__() + tx.set_tag("http.method", method) + tx.set_data("http.url", target) + + def finalize(exc_info: tuple = (None, None, None)) -> None: + if ctx.response_status is not None: + tx.set_http_status(ctx.response_status) + tx_cm.__exit__(*exc_info) + scope_cm.__exit__(*exc_info) + + try: + result = inner(ctx) + except BaseException: + finalize(sys.exc_info()) + raise + + if result is None: + finalize() + return None + + def wrapped(req_ctx: HTTPReqCtx) -> None: try: - inner(ctx) - finally: - if ctx.response_status is not None: - tx.set_http_status(ctx.response_status) + result(req_ctx) + except BaseException: + finalize(sys.exc_info()) + raise + finalize() + + return wrapped return handle diff --git a/localpost/http/server.py b/localpost/http/server.py index 2aa28ad..1fad908 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -10,6 +10,7 @@ BaseServer as Server, ) from localpost.http._base import ( + BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error, @@ -27,6 +28,7 @@ "HTTPConn", "HTTPReqCtx", "HTTPReqCtxH11", + "BodyHandler", "RequestHandler", "BodyTooLarge", "emit_handler_error", diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index ba243bc..54a1f3d 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -2,6 +2,16 @@ Pure-Python parser/state-machine. The default backend; readable, no C deps. For the C-based alternative see :mod:`localpost.http.server_httptools`. + +Like the httptools backend, this one: + +- buffers the full request body into ``ctx.body`` before invoking a + returned :data:`BodyHandler` continuation +- auto-buffers the response headers; the next ``send`` (or + ``finish_response`` for empty bodies) emits headers + first body chunk + in a single ``sendall`` +- does not parallelise pipelined requests on the same connection + (sequential serving via h11's own state machine) """ from __future__ import annotations @@ -26,6 +36,7 @@ REQUEST_TIMEOUT_RESPONSE, BaseHTTPConn, BaseServer, + BodyHandler, RequestHandler, emit_handler_error, start_http_server_base, @@ -73,6 +84,16 @@ class HTTPConnH11(BaseHTTPConn): to enforce the upload cap.""" idle: bool = True + # Per-request dispatch state. Set on ``h11.Request``, used while we + # iterate ``h11.Data`` events to accumulate the body, and consumed on + # ``h11.EndOfMessage`` to fire the continuation. ``_ctx`` is the + # current request context shared with the handler; ``_continuation`` + # is the post-body callback (None if the pre-body handler completed + # inline or borrowed). + _ctx: HTTPReqCtxH11 | None = None + _continuation: BodyHandler | None = None + _body_buf: bytearray = field(default_factory=bytearray) + def __post_init__(self) -> None: self.fd = self.sock.fileno() @@ -126,13 +147,27 @@ def _loop(self, h: RequestHandler) -> None: self.body_bytes_received = 0 self.idle = True self.close_at = time.monotonic() + self.server.config.keep_alive_timeout + # Per-request state from previous cycle is no longer relevant. + self._ctx = None + self._continuation = None + self._body_buf = bytearray() event = parser.next_event() if event is h11.NEED_DATA: if parser.they_are_waiting_for_100_continue: - self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) - continue + if self._continuation is not None: + # Body is wanted (handler returned a continuation) — + # tell the client to send it. Fall through to recv. + self.send( + h11.InformationalResponse( + status_code=100, headers=[], reason="Continue" + ) + ) + else: + # No body needed — short-circuit with 417. + self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) + continue try: self.receive() except BlockingIOError: @@ -142,9 +177,30 @@ def _loop(self, h: RequestHandler) -> None: self.body_bytes_received += len(event.data) if self.body_bytes_received > self.server.config.max_body_size: raise BodyTooLarge(self.body_bytes_received) - continue # Drain the request body + if self._continuation is not None: + self._body_buf += event.data + # Else: pre-body handler returned None — drain silently. elif isinstance(event, h11.EndOfMessage): - continue + if self._continuation is not None: + cont = self._continuation + ctx = self._ctx + assert ctx is not None + self._continuation = None + ctx.body = bytes(self._body_buf) + self._body_buf = bytearray() + try: + cont(ctx) + except BodyTooLarge: + raise + except Exception: + self.server.logger.exception( + "Body handler raised for %s %r", ctx.request.method, ctx.request.target + ) + emit_handler_error(ctx) + if ctx.borrowed: + return + if not self.tracked: + return elif isinstance(event, h11.Request): cl = _content_length(event.headers) if cl is not None and cl > self.server.config.max_body_size: @@ -155,15 +211,21 @@ def _loop(self, h: RequestHandler) -> None: headers=[(bytes(n), bytes(v)) for n, v in event.headers], http_version=bytes(event.http_version), ) - req_ctx = HTTPReqCtxH11(self.server, self, req) + ctx = HTTPReqCtxH11(self.server, self, req) + self._ctx = ctx + self._body_buf = bytearray() + self._continuation = None try: - h(req_ctx) + result = h(ctx) except BodyTooLarge: raise except Exception: self.server.logger.exception("Handler raised for %s %r", event.method, event.target) - emit_handler_error(req_ctx) - if req_ctx.borrowed: + emit_handler_error(ctx) + result = None + if result is not None: + self._continuation = result + if ctx.borrowed: return if not self.tracked: return # connection was closed during error recovery @@ -209,18 +271,25 @@ def emit_stale_408(self) -> None: pass -@dataclass(eq=False, frozen=True, slots=True) +@dataclass(eq=False, slots=True) class HTTPReqCtxH11: """Per-request context for the h11 backend. Structurally satisfies :class:`localpost.http._base.HTTPReqCtx`. + + The response-write path auto-buffers: ``start_response`` advances h11 + and stashes the returned bytes; the next ``send`` (or + ``finish_response`` for empty bodies) emits headers + first body + chunk in a single ``sendall``. """ _server: BaseServer _conn: HTTPConnH11 request: Request - response_status: int | None = field(default=None, init=False) + body: bytes = b"" + response_status: int | None = None + _pending_header_bytes: bytes | None = None @property def borrowed(self) -> bool: @@ -247,6 +316,10 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.finish_response() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + """Streaming-read API. Rare under the JSON-API contract — typical + callers receive the buffered body via ``ctx.body``. After the + :data:`BodyHandler` continuation runs, the body has been fully + consumed and the parser's state side reads ``b""``.""" parser = self._conn.parser if parser.their_state is h11.DONE: return b"" @@ -282,16 +355,42 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: def start_response(self, response: Response | InformationalResponse, /) -> None: if isinstance(response, Response): - object.__setattr__(self, "response_status", response.status_code) - self._conn.send(_to_h11_response(response)) + self.response_status = response.status_code + # Drive the h11 state machine, but buffer the bytes for a + # coalesced ``sendall`` with the first body chunk. + payload = self._conn.parser.send(_to_h11_response(response)) + self._pending_header_bytes = bytes(payload) if payload else b"" + else: + # Informational responses (100 Continue, etc.) flush immediately. + self._conn.send(_to_h11_response(response)) def send(self, chunk: Buffer, /) -> None: # h11 wants bytes; widen the public API to any Buffer (memoryview, # bytearray, …) so callers can avoid an explicit copy. - self._conn.send(h11.Data(data=bytes(chunk) if not isinstance(chunk, bytes) else chunk)) + chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk) + payload = self._conn.parser.send(h11.Data(data=chunk_bytes)) + if payload is None: + return + if self._pending_header_bytes is not None: + combined = self._pending_header_bytes + payload + self._pending_header_bytes = None + self._sock_sendall(combined) + elif payload: + self._sock_sendall(payload) def finish_response(self) -> None: - self._conn.send(h11.EndOfMessage()) + # Coalesce: ``EndOfMessage`` payload (chunked terminator or nothing) + # plus any still-pending header bytes (empty-body case) emit in one + # ``sendall``. + eom_payload = self._conn.parser.send(h11.EndOfMessage()) + eom_bytes = bytes(eom_payload) if eom_payload else b"" + if self._pending_header_bytes is not None: + combined = self._pending_header_bytes + eom_bytes + self._pending_header_bytes = None + if combined: + self._sock_sendall(combined) + elif eom_bytes: + self._sock_sendall(eom_bytes) # Drain h11's pending ``EndOfMessage`` for the request side before # giving the conn back. For a no-body request the selector parsed # ``Request`` and stopped — h11 still has the implicit EndOfMessage @@ -309,6 +408,14 @@ def finish_response(self) -> None: return self._maybe_give_back() + def _sock_sendall(self, payload: bytes) -> None: + sock, total_sent, payload_len = self._conn.sock, 0, len(payload) + while total_sent < payload_len: + sent = sock.send(payload[total_sent:]) + if sent == 0: + raise ConnectionAbortedError("socket is broken") + total_sent += sent + def start_http_server(config: ServerConfig, handler: RequestHandler, /): """Open a listening socket and yield a server driving the h11 backend. diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 7752f38..2aa9628 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -4,23 +4,26 @@ backend. httptools is a parse-only C extension — response bytes and keep-alive bookkeeping are handled here. -Initial scope: - -- request body streaming via per-conn body buffer -- ``Expect: 100-continue`` (response written inline on first ``receive()``) -- keep-alive driven by ``parser.should_keep_alive()`` and response headers -- pipelining (multiple requests parsed from one ``feed_data`` call) supported - via a ready-queue -- request bodies serialised by the caller using ``Content-Length`` - (matching what ``Router`` and ``wrap_wsgi`` already produce) - -Out of scope for v1: chunked Transfer-Encoding on the response side, HTTP -upgrade negotiation (WebSockets), HTTP/2. +Scope: + +- request body is buffered in full into ``ctx.body`` before the + :data:`BodyHandler` continuation is invoked (JSON-API common case) +- ``Expect: 100-continue`` is sent inline only when the pre-body + :data:`RequestHandler` returns a continuation (i.e., the body is + actually wanted) +- keep-alive driven by ``parser.should_keep_alive()`` and response + headers +- HTTP/1.1 pipelining is **not supported**: pipelined clients are served + one request at a time on the same connection (no parallelism gained, + no incorrect interleaving emitted) +- response framing: ``Content-Length`` (set by the caller) or auto + ``Transfer-Encoding: chunked`` if neither is present + +Out of scope: HTTP upgrade negotiation (WebSockets), HTTP/2. """ from __future__ import annotations -import collections import socket import time from collections.abc import Buffer, Iterator @@ -44,6 +47,7 @@ REQUEST_TIMEOUT_RESPONSE, BaseHTTPConn, BaseServer, + BodyHandler, RequestHandler, emit_handler_error, start_http_server_base, @@ -93,33 +97,20 @@ def _serialize_response(r: Response | InformationalResponse) -> bytes: return bytes(out) -def _has_connection_close(headers: list[tuple[bytes, bytes]]) -> bool: - return any(name.lower() == b"connection" and b"close" in value.lower() for name, value in headers) +def _scan_response_headers(headers: list[tuple[bytes, bytes]]) -> tuple[bool, bool]: + """One-pass scan: ``(has_connection_close, has_content_length_or_te)``. - -def _has_content_length_or_te(headers: list[tuple[bytes, bytes]]) -> bool: - """True if the user already framed the response (Content-Length or Transfer-Encoding).""" - for name, _ in headers: - n = name.lower() - if n == b"content-length" or n == b"transfer-encoding": - return True - return False - - -@dataclass(eq=False, slots=True) -class _ReadyRequest: - """A request whose headers have been parsed (and possibly more). - - Body chunks accumulate as they arrive from subsequent ``feed_data`` calls; - ``complete`` flips when ``on_message_complete`` fires. + Combined to avoid two walks per response. """ - - request: Request - body: collections.deque[bytes] = field(default_factory=collections.deque) - complete: bool = False - expect_100_continue: bool = False - keep_alive: bool = True - body_received: int = 0 + has_close = False + has_framing = False + for name, value in headers: + n = name.lower() + if n == b"connection" and b"close" in value.lower(): + has_close = True + elif n in {b"content-length", b"transfer-encoding"}: + has_framing = True + return has_close, has_framing @final @@ -134,22 +125,20 @@ class HTTPConnHttptools(BaseHTTPConn): tracked: bool = False idle: bool = True - # Currently-being-parsed request state (between on_message_begin and on_message_complete): + # Per-request scratch state populated by the parser callbacks. _cur_target: bytes = b"" _cur_headers: list[tuple[bytes, bytes]] = field(default_factory=list) _cur_expect_100: bool = False _cur_oversize: bool = False # set by on_headers_complete if Content-Length exceeds cap - # Most recently promoted ready request — body / EOM callbacks update its state. - _last_ready: _ReadyRequest | None = None - - # Completed requests waiting for dispatch (head = next to dispatch). A request is - # appended here on on_headers_complete; its body chunks accumulate via on_body until - # on_message_complete flips ``complete``. - _ready: collections.deque[_ReadyRequest] = field(default_factory=collections.deque) - - # Body cap tracking. Populated either eagerly from Content-Length - # (on_headers_complete) or progressively via on_body. + # Per-conn dispatch state. One in-flight request at a time (no pipelining): + # ``_ctx`` is built in ``on_headers_complete`` and the pre-body handler is + # invoked inline. If it returns a continuation, ``_continuation`` is set + # and ``on_body`` accumulates into ``_body_buf`` until ``on_message_complete``. + _ctx: HTTPReqCtxHttptools | None = None + _continuation: BodyHandler | None = None + _body_buf: bytearray = field(default_factory=bytearray) + _message_complete: bool = False _body_too_large: int | None = None # Set when the response indicates ``Connection: close`` or the request lacked @@ -157,6 +146,9 @@ class HTTPConnHttptools(BaseHTTPConn): _close_after_response: bool = False _response_started: bool = False + # Set by ``__call__`` so parser callbacks can dispatch the pre-body handler. + _handler: RequestHandler | None = None + def __post_init__(self) -> None: self.fd = self.sock.fileno() self.parser = httptools.HttpRequestParser(self) @@ -179,7 +171,7 @@ def on_header(self, name: bytes, value: bytes) -> None: self._cur_expect_100 = True def on_headers_complete(self) -> None: - # Build the neutral Request envelope and promote it to the ready queue. + # Build the neutral Request envelope and dispatch the pre-body handler. method = self.parser.get_method() version = self.parser.get_http_version().encode("ascii") keep_alive = self.parser.should_keep_alive() @@ -204,34 +196,80 @@ def on_headers_complete(self) -> None: headers=self._cur_headers, http_version=version, ) - ready = _ReadyRequest( - request=req, - expect_100_continue=self._cur_expect_100, - keep_alive=keep_alive, + ctx = HTTPReqCtxHttptools( + self.server, + self, + req, + _expect_100_continue=self._cur_expect_100, + _keep_alive=keep_alive, ) - self._ready.append(ready) - self._last_ready = ready + self._ctx = ctx + self._body_buf = bytearray() + self._message_complete = False + self._continuation = None + + # Dispatch the pre-body handler inline. It runs on the selector + # thread; it can call ``ctx.complete(...)`` (response sent inline, + # returns None), ``ctx.borrow()`` (escape to worker, returns None), + # or return a :data:`BodyHandler` continuation to receive the body. + assert self._handler is not None + try: + result = self._handler(ctx) + except BodyTooLarge: + raise + except Exception: + self.server.logger.exception("Handler raised for %s %r", req.method, req.target) + emit_handler_error(ctx) + result = None + + if result is None: + return # done — body bytes (if any) are drained silently below + + # Continuation returned: we'll buffer the body and invoke it on + # ``on_message_complete``. If the client sent ``Expect: 100-continue``, + # tell them to actually send the body now. + self._continuation = result + if self._cur_expect_100 and not ctx._continue_sent: + try: + self.sock.sendall(b"HTTP/1.1 100 Continue\r\n\r\n") + object.__setattr__(ctx, "_continue_sent", True) + except OSError: + # Best-effort; the body recv loop will surface the failure. + pass def on_body(self, data: bytes) -> None: - if self._cur_oversize or self._last_ready is None: - return - new_total = self._last_ready.body_received + len(data) + if self._continuation is None or self._cur_oversize: + return # no body wanted (handler done) or already oversize + new_total = len(self._body_buf) + len(data) if new_total > self.server.config.max_body_size: self._body_too_large = new_total return - self._last_ready.body_received = new_total - self._last_ready.body.append(bytes(data)) + self._body_buf += data def on_message_complete(self) -> None: - if self._last_ready is not None: - self._last_ready.complete = True - self._last_ready = None + if self._continuation is not None: + cont = self._continuation + self._continuation = None + ctx = self._ctx + assert ctx is not None + ctx.body = bytes(self._body_buf) + try: + cont(ctx) + except BodyTooLarge: + raise + except Exception: + self.server.logger.exception( + "Body handler raised for %s %r", ctx.request.method, ctx.request.target + ) + emit_handler_error(ctx) + self._message_complete = True # ----- BaseHTTPConn surface ----- def __call__(self, h: RequestHandler, /) -> None: + self._handler = h try: - self._loop(h) + self._loop() except _ProtocolError as e: self.server.logger.warning("Bad client input from %s: %s", self.addr, e) self._try_send_status(BAD_REQUEST_RESPONSE, BAD_REQUEST_BODY) @@ -255,37 +293,30 @@ def _feed(self, data: bytes) -> None: self._body_too_large = None raise BodyTooLarge(n) - def _loop(self, h: RequestHandler) -> None: + def _loop(self) -> None: config = self.server.config while self.tracked: - # Dispatch any ready request before reading more bytes. We pop - # before dispatching: the request is committed to ``req_ctx`` - # and ownership transfers — otherwise a worker-pool handler - # (which sets ``borrowed=True`` and returns) would leave the - # request in the queue and we'd re-dispatch it on re-track. - if self._ready: - ready = self._ready.popleft() - req_ctx = HTTPReqCtxHttptools(self.server, self, ready) - try: - h(req_ctx) - except BodyTooLarge: - raise - except Exception: - self.server.logger.exception( - "Handler raised for %s %r", ready.request.method, ready.request.target - ) - emit_handler_error(req_ctx) - if req_ctx.borrowed: - return + # Did the previous request just complete? Either roll into the + # next one (keep-alive) or close. + if self._message_complete: + ctx = self._ctx + assert ctx is not None + if ctx.borrowed: + return # worker has the conn now if not self.tracked: - return # connection was closed during error recovery - if self._close_after_response or not ready.keep_alive: + return # closed during error recovery + if self._close_after_response or not ctx._keep_alive: self.close() return self._reset_for_next_request() + # Fall through to read more bytes (or to dispatch a request + # whose headers were already buffered by the parser). continue - # No request ready — pump bytes from the socket. + # Pump bytes from the socket. Parser callbacks fire inline: + # ``on_headers_complete`` dispatches the pre-body handler; + # ``on_message_complete`` invokes the continuation if any and + # sets ``_message_complete``. try: data = self.sock.recv(DEFAULT_BUFFER_SIZE) except BlockingIOError: @@ -298,9 +329,13 @@ def _loop(self, h: RequestHandler) -> None: self._feed(data) def _reset_for_next_request(self) -> None: - # Per-request parsing scratch is cleared on the next on_message_begin. - # Reset our own conn-level fields here. + # Per-request parsing scratch is cleared by ``on_message_begin``. + self._ctx = None + self._continuation = None + self._body_buf = bytearray() + self._message_complete = False self._response_started = False + self._close_after_response = False self.idle = True self.close_at = time.monotonic() + self.server.config.keep_alive_timeout @@ -310,9 +345,7 @@ def _try_send_status(self, response: Response, body: bytes) -> None: return try: self.sock.settimeout(self.server.config.rw_timeout) - self.sock.sendall(_serialize_response(response)) - if body: - self.sock.sendall(body) + self.sock.sendall(_serialize_response(response) + body) except Exception: # noqa: BLE001, S110 — connection is being closed anyway pass @@ -322,9 +355,7 @@ def emit_stale_408(self) -> None: return try: self.sock.settimeout(self.server.config.rw_timeout) - self.sock.sendall(_serialize_response(REQUEST_TIMEOUT_RESPONSE)) - if REQUEST_TIMEOUT_BODY: - self.sock.sendall(REQUEST_TIMEOUT_BODY) + self.sock.sendall(_serialize_response(REQUEST_TIMEOUT_RESPONSE) + REQUEST_TIMEOUT_BODY) except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway pass @@ -333,24 +364,34 @@ class _ProtocolError(Exception): """Translated httptools parser error. Mapped to 400 by the conn loop.""" -@dataclass(eq=False, frozen=True, slots=True) +@dataclass(eq=False, slots=True) class HTTPReqCtxHttptools: - """Per-request context for the httptools backend.""" + """Per-request context for the httptools backend. + + The response-write path auto-buffers: ``start_response`` stores the + serialised status + headers without flushing; the next ``send`` (or + ``finish_response`` for empty bodies) emits a single ``sendall`` with + headers + body framed together. One syscall for the canonical + ``ctx.complete(response, body)`` path; two for SSE (headers + first + chunk together; one per subsequent chunk). + """ _server: BaseServer _conn: HTTPConnHttptools - _ready: _ReadyRequest + request: Request + _expect_100_continue: bool = False + _keep_alive: bool = True - response_status: int | None = field(default=None, init=False) - _continue_sent: bool = field(default=False, init=False) - _chunked: bool = field(default=False, init=False) + body: bytes = b"" + response_status: int | None = None + _continue_sent: bool = False + _chunked: bool = False """``True`` if the backend auto-added ``Transfer-Encoding: chunked`` because the response had neither ``Content-Length`` nor an explicit ``Transfer-Encoding`` — chunks must be framed and a terminator written.""" - - @property - def request(self) -> Request: - return self._ready.request + _pending_header_bytes: bytes | None = None + """Buffered status line + headers, awaiting flush on first body chunk + or ``finish_response``. ``None`` once flushed.""" @property def borrowed(self) -> bool: @@ -376,92 +417,82 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.finish_response() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - # 100-continue: caller is opting to read the body; tell the client - # to actually send it. - if self._ready.expect_100_continue and not self._continue_sent: + """Streaming-read API. Rare under the JSON-API contract — typical + callers receive the buffered body via ``ctx.body``. After the + :data:`BodyHandler` continuation runs, the body has been fully + consumed and this returns ``b""``.""" + # 100-continue: caller is opting to read more bytes; tell the client + # to actually send body if we haven't already. + if self._expect_100_continue and not self._continue_sent: self._send_continue() - # Drain accumulated chunks before pumping more bytes. - if self._ready.body: - chunk = self._ready.body.popleft() - return chunk - if self._ready.complete: - return b"" - # No buffered body, no EOM seen — pump more bytes from the socket. + # The continuation-style flow buffers the body in ``self.body`` before + # dispatch — there's nothing left to stream when this is called from + # a body handler. For non-standard pre-body callers (e.g. handlers + # that called ``borrow()`` and read their own body on a worker), fall + # through to a blocking-with-timeout recv. sock = self._conn.sock rw = self._server.config.rw_timeout - while True: + try: + return sock.recv(size) + except BlockingIOError: + sock.settimeout(rw) try: - data = sock.recv(size) - except BlockingIOError: - # Selector-thread handler on a non-blocking socket: switch - # to blocking-with-timeout for the duration of this receive, - # mirroring the h11 backend. - sock.settimeout(rw) - try: - data = sock.recv(size) - finally: - if self._conn.tracked: - sock.settimeout(0) - if not data: - # Peer closed mid-body — surface as EOF for the handler. - self._ready.complete = True - return b"" - self._conn._feed(data) - if self._ready.body: - return self._ready.body.popleft() - if self._ready.complete: - return b"" + return sock.recv(size) + finally: + if self._conn.tracked: + sock.settimeout(0) def _send_continue(self) -> None: self._conn.sock.sendall(b"HTTP/1.1 100 Continue\r\n\r\n") - object.__setattr__(self, "_continue_sent", True) + self._continue_sent = True def start_response(self, response: Response | InformationalResponse, /) -> None: + # State updates first (response_status, _close_after_response, _chunked + # for the Response case); for Informational responses we just write + # bytes without buffering (rare path). if isinstance(response, Response): - object.__setattr__(self, "response_status", response.status_code) + self.response_status = response.status_code self._conn._response_started = True - if _has_connection_close(response.headers) or not self._ready.keep_alive: + has_close, has_framing = _scan_response_headers(response.headers) + if has_close or not self._keep_alive: self._conn._close_after_response = True - # Auto-frame: no Content-Length / Transfer-Encoding → chunked. - # Without framing, an HTTP/1.1 client would wait for the connection - # to close before considering the response complete. - if not _has_content_length_or_te(response.headers): + if not has_framing: + # Auto-frame: no Content-Length / Transfer-Encoding → chunked. + # Without framing, an HTTP/1.1 client would wait for the + # connection to close before considering the response done. response = Response( status_code=response.status_code, headers=[*response.headers, (b"transfer-encoding", b"chunked")], reason=response.reason, ) - object.__setattr__(self, "_chunked", True) - self._conn.sock.sendall(_serialize_response(response)) + self._chunked = True + self._pending_header_bytes = _serialize_response(response) + else: + # Informational responses (e.g., 100 Continue) flush immediately. + self._conn.sock.sendall(_serialize_response(response)) def send(self, chunk: Buffer, /) -> None: if not isinstance(chunk, bytes): chunk = bytes(chunk) - if not chunk: + if not chunk and self._pending_header_bytes is None: return - if self._chunked: - # RFC 7230 §4.1: CRLF CRLF — concatenated into - # a single ``sendall`` to keep this to one syscall per chunk. - framed = f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n" + framed = (f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n") if self._chunked and chunk else chunk + if self._pending_header_bytes is not None: + # Headers + first body chunk in one syscall. + self._conn.sock.sendall(self._pending_header_bytes + framed) + self._pending_header_bytes = None + elif framed: self._conn.sock.sendall(framed) - else: - self._conn.sock.sendall(chunk) def finish_response(self) -> None: - if self._chunked: - # Terminating chunk: ``0\r\n\r\n``. - self._conn.sock.sendall(b"0\r\n\r\n") - # Drain any remaining request-body bytes the handler skipped — leftover - # on the wire would corrupt the next pipelined request. - if not self._ready.complete: - try: - while not self._ready.complete: - chunk = self.receive(DEFAULT_BUFFER_SIZE) - if not chunk: - break - except (BlockingIOError, OSError, _ProtocolError, BodyTooLarge): - # We can't safely recover — the conn must close. - self._conn._close_after_response = True + # Flush any still-buffered headers (empty-body case) plus the + # chunked terminator, in one sendall. + terminator = b"0\r\n\r\n" if self._chunked else b"" + if self._pending_header_bytes is not None: + self._conn.sock.sendall(self._pending_header_bytes + terminator) + self._pending_header_bytes = None + elif terminator: + self._conn.sock.sendall(terminator) self._maybe_give_back() diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 52968ae..d1e49b8 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -7,19 +7,22 @@ from wsgiref.types import WSGIApplication from localpost.http._types import Response as _NativeResponse -from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler __all__ = ["wrap_wsgi"] @final class _RequestBodyStream(RawIOBase): - """Expose ``HTTPReqCtx.receive`` as a readable file-like object for ``wsgi.input``.""" + """Expose the buffered ``HTTPReqCtx.body`` as ``wsgi.input``. - def __init__(self, ctx: HTTPReqCtx) -> None: - self._ctx = ctx - self._eof = False - self._leftover = b"" + The body has already been read by the selector before the WSGI app + runs, so this is a thin slicing view — no I/O, no ``recv``. + """ + + def __init__(self, body: bytes) -> None: + self._buf = body + self._pos = 0 @override def writable(self) -> bool: @@ -35,43 +38,32 @@ def readable(self) -> bool: @override def readall(self) -> bytes: - buf = bytearray(self._leftover) - self._leftover = b"" - while not self._eof: - chunk = self._ctx.receive() - if not chunk: - self._eof = True - break - buf.extend(chunk) - return bytes(buf) + result = self._buf[self._pos :] + self._pos = len(self._buf) + return result @override def readinto(self, buffer: Buffer, /) -> int: view = memoryview(buffer).cast("B") n = len(view) - if self._leftover: - take = min(n, len(self._leftover)) - view[:take] = self._leftover[:take] - self._leftover = self._leftover[take:] - return take - if self._eof: - return 0 - data = self._ctx.receive(n) - if not data: - self._eof = True + avail = len(self._buf) - self._pos + if avail == 0: return 0 - if len(data) <= n: - view[: len(data)] = data - return len(data) - view[:n] = data[:n] - self._leftover = data[n:] - return n + take = min(n, avail) + view[:take] = self._buf[self._pos : self._pos + take] + self._pos += take + return take def wrap_wsgi(app: WSGIApplication) -> RequestHandler: - """Wrap a WSGI application as a native :class:`RequestHandler`.""" + """Wrap a WSGI application as a native :class:`RequestHandler`. - def handler(ctx: HTTPReqCtx) -> None: + WSGI apps may consume ``wsgi.input`` from any route, so the wrapper + always returns a :data:`BodyHandler` continuation — the selector + buffers the request body into ``ctx.body`` before the WSGI app runs. + """ + + def run_wsgi(ctx: HTTPReqCtx) -> None: environ = _build_environ(ctx) response_state: dict[str, Any] = {} @@ -124,7 +116,10 @@ def start_response( if close is not None: close() - return handler + def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: + return run_wsgi + + return pre_body def _wsgi_write_deprecated(_: bytes) -> None: @@ -151,7 +146,7 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", "wsgi.version": (1, 0), "wsgi.url_scheme": "http", - "wsgi.input": _RequestBodyStream(ctx), + "wsgi.input": _RequestBodyStream(ctx.body), "wsgi.errors": sys.stderr, "wsgi.multithread": True, "wsgi.multiprocess": False, diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index f0f9070..be29b6b 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -96,15 +96,9 @@ def test_simple_get(serve_parity): def test_post_with_body(serve_parity): received: list[bytes] = [] - def handler(ctx: HTTPReqCtx) -> None: - body = b"" - while True: - chunk = ctx.receive(4096) - if not chunk: - break - body += chunk - received.append(body) - out = b"echo:" + body + def body_handler(ctx: HTTPReqCtx) -> None: + received.append(ctx.body) + out = b"echo:" + ctx.body ctx.complete( NativeResponse( status_code=200, @@ -116,6 +110,9 @@ def handler(ctx: HTTPReqCtx) -> None: out, ) + def handler(_ctx: HTTPReqCtx): + return body_handler + with serve_parity(handler) as port: r = httpx.post(f"http://127.0.0.1:{port}/x", content=b"hello world", timeout=2) assert r.status_code == 200 @@ -204,14 +201,8 @@ def handler(ctx: HTTPReqCtx) -> None: # not called def test_expect_100_continue(serve_parity): - def handler(ctx: HTTPReqCtx) -> None: - body = b"" - while True: - chunk = ctx.receive(4096) - if not chunk: - break - body += chunk - out = b"got:" + body + def body_handler(ctx: HTTPReqCtx) -> None: + out = b"got:" + ctx.body ctx.complete( NativeResponse( 200, @@ -220,6 +211,9 @@ def handler(ctx: HTTPReqCtx) -> None: out, ) + def handler(_ctx: HTTPReqCtx): + return body_handler + with serve_parity(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=3) as s: s.sendall( diff --git a/tests/http/service.py b/tests/http/service.py index 2055511..9c044bb 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -119,7 +119,7 @@ async def test_each_request_becomes_a_task(self, free_port): entered = threading.Semaphore(0) # used as a barrier signal release = threading.Event() - def handler(ctx: HTTPReqCtx): + def body_handler(ctx: HTTPReqCtx): with lock: thread_ids.append(threading.get_ident()) entered.release() @@ -130,6 +130,9 @@ def handler(ctx: HTTPReqCtx): b"ok", ) + def handler(_ctx: HTTPReqCtx): + return body_handler + cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=4) as lt: await lt.started @@ -173,7 +176,7 @@ async def test_max_concurrency_one_serializes(self, free_port): peak = 0 lock = threading.Lock() - def handler(ctx: HTTPReqCtx): + def body_handler(ctx: HTTPReqCtx): nonlocal in_flight, peak with lock: in_flight += 1 @@ -183,6 +186,9 @@ def handler(ctx: HTTPReqCtx): in_flight -= 1 ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + def handler(_ctx: HTTPReqCtx): + return body_handler + cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: await lt.started @@ -207,7 +213,7 @@ async def test_shutdown_cancels_inflight(self, free_port): handler_started = threading.Event() handler_cancelled = threading.Event() - def handler(ctx: HTTPReqCtx): + def body_handler(ctx: HTTPReqCtx): handler_started.set() try: for _ in range(100): @@ -218,6 +224,9 @@ def handler(ctx: HTTPReqCtx): raise ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + def handler(_ctx: HTTPReqCtx): + return body_handler + cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: await lt.started @@ -394,7 +403,7 @@ async def test_max_concurrency_caps_parallelism(self, free_port): lock = threading.Lock() gate = threading.Event() - def handler(ctx: HTTPReqCtx): + def body_handler(ctx: HTTPReqCtx): nonlocal in_flight, peak with lock: in_flight += 1 @@ -404,6 +413,9 @@ def handler(ctx: HTTPReqCtx): in_flight -= 1 ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + def handler(_ctx: HTTPReqCtx): + return body_handler + cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=3) as lt: await lt.started @@ -482,13 +494,16 @@ class TestDispatchLoad: async def test_many_requests_served_from_worker_threads(self, free_port): cfg = ServerConfig(host="127.0.0.1", port=free_port) - def handler(ctx: HTTPReqCtx): + def body_handler(ctx: HTTPReqCtx): tid = str(threading.get_ident()).encode() ctx.complete( NativeResponse(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), tid, ) + def handler(_ctx: HTTPReqCtx): + return body_handler + async with _serve_pooled(cfg, handler, max_concurrency=8) as lt: await lt.started await _wait_server_ready(free_port) @@ -524,7 +539,7 @@ async def test_client_disconnect_cancels_handler(self, free_port): handler_started = threading.Event() handler_cancelled = threading.Event() - def handler(ctx: HTTPReqCtx): + def body_handler(ctx: HTTPReqCtx): handler_started.set() try: for _ in range(200): @@ -536,6 +551,9 @@ def handler(ctx: HTTPReqCtx): # Should not be reached ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + def handler(_ctx: HTTPReqCtx): + return body_handler + cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: await lt.started From 8af0dc5badde75c79ca547a3f6d2901ae7a392aa Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 21:55:21 +0400 Subject: [PATCH 117/286] feat(http): middleware foundations (Middleware type, compose, ctx.attrs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive — no existing code paths change. - New ``Middleware = Callable[[RequestHandler], RequestHandler]`` type in ``_base.py``, with a ``compose(*mws)`` helper for outermost-first composition. - New ``HTTPReqCtx.attrs: dict[str, Any]`` mutable per-request state. Both concrete contexts (HTTPReqCtxH11 / HTTPReqCtxHttptools) get the field defaulted to an empty dict. Used by middlewares to thread cross-cutting state (Router will write the matched RouteMatch here; auth / tracing middlewares can attach their own state). - Re-exported ``Middleware`` and ``compose`` from ``localpost.http``. 137/137 http tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/__init__.py | 4 ++- localpost/http/_base.py | 49 +++++++++++++++++++++++++++++- localpost/http/server.py | 4 +++ localpost/http/server_h11.py | 3 +- localpost/http/server_httptools.py | 3 +- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 0a6eeb8..989dc2c 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -15,7 +15,7 @@ from localpost.http.router import ( RequestHandler as RouterRequestHandler, ) -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, start_http_server +from localpost.http.server import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose, start_http_server from localpost.http.wsgi import wrap_wsgi # ``Response`` (the public name) is the high-level router response. @@ -30,6 +30,8 @@ "HTTPReqCtx", "RequestHandler", "BodyHandler", + "Middleware", + "compose", # backend selection "httptools_server", # neutral wire types (used directly with HTTPReqCtx) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 3b1f156..365c8c6 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -22,7 +22,7 @@ from collections.abc import Callable, Iterator from contextlib import AbstractContextManager, closing, contextmanager from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol, final +from typing import TYPE_CHECKING, Any, Protocol, final from localpost.http._types import InformationalResponse, Request, Response from localpost.http.config import LOGGER_NAME, ServerConfig @@ -35,7 +35,9 @@ "BaseHTTPConn", "BodyHandler", "HTTPReqCtx", + "Middleware", "RequestHandler", + "compose", "start_http_server_base", ] @@ -127,11 +129,17 @@ class HTTPReqCtx(Protocol): The ``body`` attribute is empty when a :data:`RequestHandler` runs (pre-body phase). It is populated by the selector with the fully buffered request body before invoking a returned :data:`BodyHandler`. + + The ``attrs`` mapping is per-request mutable state for cross-cutting + concerns to thread information through. :class:`localpost.http.Router` + writes the matched ``RouteMatch`` here; middlewares can attach auth + info, tracing transactions, etc. """ request: Request body: bytes response_status: int | None + attrs: dict[str, Any] @property def _server(self) -> BaseServer: ... @@ -166,6 +174,45 @@ def complete(self, response: Response, body: bytes | None = None) -> None: ... Old-style ``Callable[[HTTPReqCtx], None]`` handlers continue to work — returning ``None`` implicitly is the "done inline" path.""" +Middleware = Callable[[RequestHandler], RequestHandler] +"""HTTP middleware: a function that wraps a :data:`RequestHandler`. + +Plain Python decorator pattern — no special chain object. A middleware +can short-circuit pre-body (call ``ctx.complete(...)`` and return +``None`` without invoking ``inner``), inspect / modify before passing +through (``return inner(ctx)``), and wrap the returned +:data:`BodyHandler` continuation to run code after the response:: + + def with_logging(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx): + start = time.monotonic() + result = inner(ctx) + if result is None: + if not ctx.borrowed: + _log(start, ctx) + return None + def post_body(ctx): + result(ctx) + _log(start, ctx) + return post_body + return wrapped +""" + + +def compose(*middlewares: Middleware) -> Middleware: + """Compose middlewares left-to-right (outermost-first). + + ``compose(a, b, c)(handler)`` is equivalent to ``a(b(c(handler)))`` — + on dispatch, ``a`` runs first and ``c`` is closest to the handler. + """ + + def wrap(handler: RequestHandler) -> RequestHandler: + for mw in reversed(middlewares): + handler = mw(handler) + return handler + + return wrap + def emit_handler_error(ctx: HTTPReqCtx) -> None: """Best-effort recovery when a request handler raises. diff --git a/localpost/http/server.py b/localpost/http/server.py index 1fad908..04d4315 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -12,7 +12,9 @@ from localpost.http._base import ( BodyHandler, HTTPReqCtx, + Middleware, RequestHandler, + compose, emit_handler_error, ) from localpost.http._types import BodyTooLarge @@ -30,6 +32,8 @@ "HTTPReqCtxH11", "BodyHandler", "RequestHandler", + "Middleware", + "compose", "BodyTooLarge", "emit_handler_error", ] diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 54a1f3d..9504944 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -21,7 +21,7 @@ from collections.abc import Buffer, Iterator from contextlib import contextmanager from dataclasses import dataclass, field -from typing import final +from typing import Any, final import h11 @@ -289,6 +289,7 @@ class HTTPReqCtxH11: body: bytes = b"" response_status: int | None = None + attrs: dict[str, Any] = field(default_factory=dict) _pending_header_bytes: bytes | None = None @property diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 2aa9628..4ffe390 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -29,7 +29,7 @@ from collections.abc import Buffer, Iterator from contextlib import contextmanager from dataclasses import dataclass, field -from typing import final +from typing import Any, final try: import httptools @@ -384,6 +384,7 @@ class HTTPReqCtxHttptools: body: bytes = b"" response_status: int | None = None + attrs: dict[str, Any] = field(default_factory=dict) _continue_sent: bool = False _chunked: bool = False """``True`` if the backend auto-added ``Transfer-Encoding: chunked`` because From 9e2ce4e551f3763fc27ce5045e98e356700e996c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 22:06:25 +0400 Subject: [PATCH 118/286] refactor(http): make Router a lean middleware-friendly dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the framework-y RequestCtx / Response / RequestHandler shapes that shipped inside router.py. The lean Router is now just a ``RequestHandler``-producing dispatcher: - ``Router.as_handler()`` matches the URI template, attaches a new ``RouteMatch`` dataclass to ``ctx.attrs["route_match"]``, and delegates to the registered http-level ``RequestHandler``. - 404 / 405 are answered inline with ``ctx.complete(...)``. - ``Routes`` keeps its decorator sugar (``.get``, ``.post``, ...) for ergonomics; they're now thin wrappers over ``.add()`` that register http-level handlers (no framework wrapping). - ``Router.wsgi`` is dropped — Router is no longer a "framework". - New ``RouteMatch`` dataclass + ``route_match(ctx)`` accessor. Pythonic helpers (response converters, param injection, decorator-driven framework UX) move up to ``localpost.http.app.HttpApp`` (next commit). Migrations: - ``tests/http/router.py`` rewritten to focus on lean dispatch (URITemplate, match, RouteMatch attached, 404/405, build invariants). - ``tests/http/service.py``, ``tests/http/router_sentry_handler.py``, ``tests/http/_integration_app.py`` updated to the new handler shape. - Examples ``sentry_router_server.py`` and ``multithread_server.py`` ported. - Bench apps ``localpost_native.py``, ``localpost_httptools.py``, and the diag / inline variants ported (verbose for now — HttpApp will clean these up). - Micro bench ``benchmarks/micro/bench_router.py`` switches from the removed ``Router.wsgi`` to ``Router._match`` (the dispatch heart). - ``router_sentry.py`` adapts to the new ``_MatchOk.match`` shape. ``localpost.experimental.openapi`` (already in-progress per CLAUDE.md) takes a few more import errors from this restructure — to be revived once ``HttpApp`` lands. 145 http tests pass (125 + 20 new lean-Router tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/apps/localpost_httptools.py | 44 +- .../http/apps/localpost_httptools_diag.py | 46 +- .../http/apps/localpost_httptools_inline.py | 44 +- benchmarks/http/apps/localpost_native.py | 44 +- benchmarks/micro/bench_router.py | 48 +- examples/http/multithread_server.py | 35 +- examples/http/sentry_router_server.py | 28 +- localpost/http/__init__.py | 15 +- localpost/http/router.py | 292 ++++------- localpost/http/router_sentry.py | 2 +- tests/http/_integration_app.py | 36 +- tests/http/router.py | 488 +++++------------- tests/http/router_sentry_handler.py | 19 +- tests/http/service.py | 28 +- 14 files changed, 484 insertions(+), 685 deletions(-) diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index 31df69d..ec15987 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -12,32 +12,54 @@ from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( - RequestCtx, - Response, + BodyHandler, + HTTPReqCtx, + NativeResponse, Routes, ServerConfig, httptools_server, + route_match, thread_pool_handler, ) -def _ping(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [PING_BODY]) +def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) -def _hello(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) +def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, PING_BODY) + return None -def _echo(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "application/json"}, [ctx.body()]) +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, hello_body(route_match(ctx).path_args["name"])) + return None -def _profile_update(ctx: RequestCtx) -> Response: - body = profile_update_body(ctx.path_args["user_id"], ctx.body()) +def _echo_post_body(ctx: HTTPReqCtx) -> None: + _emit(ctx, ctx.body, content_type=b"application/json") + + +def _echo(_: HTTPReqCtx) -> BodyHandler: + return _echo_post_body + + +def _profile_update_post_body(ctx: HTTPReqCtx) -> None: + body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return Response(200, {"content-type": "application/json"}, [body]) + _emit(ctx, body, content_type=b"application/json") + + +def _profile_update(_: HTTPReqCtx) -> BodyHandler: + return _profile_update_post_body def main() -> int: diff --git a/benchmarks/http/apps/localpost_httptools_diag.py b/benchmarks/http/apps/localpost_httptools_diag.py index 782f1e2..982eb25 100644 --- a/benchmarks/http/apps/localpost_httptools_diag.py +++ b/benchmarks/http/apps/localpost_httptools_diag.py @@ -29,11 +29,13 @@ from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( - RequestCtx, - Response, + BodyHandler, + HTTPReqCtx, + NativeResponse, Routes, ServerConfig, httptools_server, + route_match, ) _per_selector: Counter[int] = Counter() @@ -46,27 +48,47 @@ def _record() -> None: _per_selector[tid] += 1 -def _ping(_: RequestCtx) -> Response: - _record() - return Response(200, {"content-type": "text/plain"}, [PING_BODY]) +def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) -def _hello(ctx: RequestCtx) -> Response: +def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: _record() - return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) + _emit(ctx, PING_BODY) + return None -def _echo(ctx: RequestCtx) -> Response: +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: _record() - return Response(200, {"content-type": "application/json"}, [ctx.body()]) + _emit(ctx, hello_body(route_match(ctx).path_args["name"])) + return None + +def _echo_post_body(ctx: HTTPReqCtx) -> None: + _emit(ctx, ctx.body, content_type=b"application/json") -def _profile_update(ctx: RequestCtx) -> Response: + +def _echo(_: HTTPReqCtx) -> BodyHandler: _record() - body = profile_update_body(ctx.path_args["user_id"], ctx.body()) + return _echo_post_body + + +def _profile_update_post_body(ctx: HTTPReqCtx) -> None: + body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return Response(200, {"content-type": "application/json"}, [body]) + _emit(ctx, body, content_type=b"application/json") + + +def _profile_update(_: HTTPReqCtx) -> BodyHandler: + _record() + return _profile_update_post_body def _print_distribution() -> None: diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index 21c01d0..42afa72 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -18,31 +18,53 @@ from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( - RequestCtx, - Response, + BodyHandler, + HTTPReqCtx, + NativeResponse, Routes, ServerConfig, httptools_server, + route_match, ) -def _ping(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [PING_BODY]) +def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) -def _hello(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) +def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, PING_BODY) + return None -def _echo(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "application/json"}, [ctx.body()]) +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, hello_body(route_match(ctx).path_args["name"])) + return None -def _profile_update(ctx: RequestCtx) -> Response: - body = profile_update_body(ctx.path_args["user_id"], ctx.body()) +def _echo_post_body(ctx: HTTPReqCtx) -> None: + _emit(ctx, ctx.body, content_type=b"application/json") + + +def _echo(_: HTTPReqCtx) -> BodyHandler: + return _echo_post_body + + +def _profile_update_post_body(ctx: HTTPReqCtx) -> None: + body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return Response(200, {"content-type": "application/json"}, [body]) + _emit(ctx, body, content_type=b"application/json") + + +def _profile_update(_: HTTPReqCtx) -> BodyHandler: + return _profile_update_post_body def main() -> int: diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index 1912d83..f68dddf 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -9,32 +9,54 @@ from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( - RequestCtx, - Response, + BodyHandler, + HTTPReqCtx, + NativeResponse, Routes, ServerConfig, http_server, + route_match, thread_pool_handler, ) -def _ping(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [PING_BODY]) +def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) -def _hello(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [hello_body(ctx.path_args["name"])]) +def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, PING_BODY) + return None -def _echo(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "application/json"}, [ctx.body()]) +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, hello_body(route_match(ctx).path_args["name"])) + return None -def _profile_update(ctx: RequestCtx) -> Response: - body = profile_update_body(ctx.path_args["user_id"], ctx.body()) +def _echo_post_body(ctx: HTTPReqCtx) -> None: + _emit(ctx, ctx.body, content_type=b"application/json") + + +def _echo(_: HTTPReqCtx) -> BodyHandler: + return _echo_post_body + + +def _profile_update_post_body(ctx: HTTPReqCtx) -> None: + body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return Response(200, {"content-type": "application/json"}, [body]) + _emit(ctx, body, content_type=b"application/json") + + +def _profile_update(_: HTTPReqCtx) -> BodyHandler: + return _profile_update_post_body def main() -> int: diff --git a/benchmarks/micro/bench_router.py b/benchmarks/micro/bench_router.py index 0a40039..27b75c8 100644 --- a/benchmarks/micro/bench_router.py +++ b/benchmarks/micro/bench_router.py @@ -7,25 +7,22 @@ pytest benchmarks/micro/bench_router.py --benchmark-only Build cost is measured separately from dispatch. Dispatch is exercised via -the WSGI surface (synthetic ``environ`` dict) — it's the simplest synchronous -entry point that goes through the full match → handler → response pipeline. +the bare ``Router._match`` — the dispatch heart, with no I/O, ctx +construction, or response writing. """ from __future__ import annotations -from io import BytesIO +from localpost.http import HTTPReqCtx, NativeResponse, Routes -from localpost.http.router import RequestCtx, Response, Routes - -def _ok(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"ok"]) +def _ok(ctx: HTTPReqCtx): + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def _build_routes_20() -> Routes: """A mixed set of 20 routes — 10 literal, 10 parameterised.""" routes = Routes() - # Literal routes for path in ( "/", "/health", @@ -39,7 +36,6 @@ def _build_routes_20() -> Routes: "/admin/dashboard", ): routes.get(path)(_ok) - # Parameterised routes for path in ( "/books/{id}", "/users/{id}", @@ -56,54 +52,36 @@ def _build_routes_20() -> Routes: return routes -def _wsgi_environ(method: str, path: str, body: bytes = b"") -> dict: - return { - "REQUEST_METHOD": method, - "PATH_INFO": path, - "QUERY_STRING": "", - "wsgi.input": BytesIO(body), - } - - -def _noop_start_response(*_args, **_kwargs) -> None: - return None - - def bench_routes_build_20(benchmark) -> None: routes = _build_routes_20() benchmark(routes.build) def bench_dispatch_literal_first(benchmark) -> None: - """Dispatch a literal route — should be the cheapest path.""" + """Match a literal route — should be the cheapest path.""" router = _build_routes_20().build() - env = _wsgi_environ("GET", "/health") - benchmark(router.wsgi, env, _noop_start_response) + benchmark(router._match, "/health", "GET") def bench_dispatch_param_shallow(benchmark) -> None: - """Dispatch ``/books/{id}`` — one variable.""" + """Match ``/books/{id}`` — one variable.""" router = _build_routes_20().build() - env = _wsgi_environ("GET", "/books/00a7a2d4-18e4-11f1-899b-d33838f3bef0") - benchmark(router.wsgi, env, _noop_start_response) + benchmark(router._match, "/books/00a7a2d4-18e4-11f1-899b-d33838f3bef0", "GET") def bench_dispatch_param_deep(benchmark) -> None: - """Dispatch ``/orgs/{org}/users/{user}/posts/{post}`` — three variables, longest path.""" + """Match ``/orgs/{org}/users/{user}/posts/{post}`` — three variables, longest path.""" router = _build_routes_20().build() - env = _wsgi_environ("GET", "/orgs/anthropic/users/alex/posts/42") - benchmark(router.wsgi, env, _noop_start_response) + benchmark(router._match, "/orgs/anthropic/users/alex/posts/42", "GET") def bench_dispatch_404(benchmark) -> None: """Path matches no route.""" router = _build_routes_20().build() - env = _wsgi_environ("GET", "/nope/nothing/here") - benchmark(router.wsgi, env, _noop_start_response) + benchmark(router._match, "/nope/nothing/here", "GET") def bench_dispatch_405(benchmark) -> None: """Path matches but method doesn't — exercises the method-not-allowed branch.""" router = _build_routes_20().build() - env = _wsgi_environ("DELETE", "/health") - benchmark(router.wsgi, env, _noop_start_response) + benchmark(router._match, "/health", "DELETE") diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index 4009263..be3bddc 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -24,30 +24,43 @@ from localpost.hosting import run_app, service from localpost.http import ( - RequestCtx, - Response, + BodyHandler, + HTTPReqCtx, + NativeResponse, Router, Routes, ServerConfig, http_server, + route_match, thread_pool_handler, ) -def _root(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"hello from localpost\n"]) +def _emit(ctx: HTTPReqCtx, body: bytes) -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) -def _hello(ctx: RequestCtx) -> Response: - name = ctx.path_args["name"] - body = f"Hello, {name}! (thread={threading.current_thread().name})\n".encode() - return Response(200, {"content-type": "text/plain"}, [body]) +def _root(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, b"hello from localpost\n") + return None -def _slow(_: RequestCtx) -> Response: +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + name = route_match(ctx).path_args["name"] + _emit(ctx, f"Hello, {name}! (thread={threading.current_thread().name})\n".encode()) + return None + + +def _slow(ctx: HTTPReqCtx) -> BodyHandler | None: time.sleep(1.0) # exercises concurrency: several of these run in parallel - body = f"done on thread={threading.current_thread().name}\n".encode() - return Response(200, {"content-type": "text/plain"}, [body]) + _emit(ctx, f"done on thread={threading.current_thread().name}\n".encode()) + return None def build_router() -> Router: diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index 8e1e072..db530fe 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -23,26 +23,40 @@ from localpost.hosting import run_app, service from localpost.http import ( - RequestCtx, - Response, + BodyHandler, + HTTPReqCtx, + NativeResponse, Routes, ServerConfig, http_server, + route_match, thread_pool_handler, ) from localpost.http.router_sentry import sentry_router_handler -def _root(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"hello\n"]) +def _emit(ctx: HTTPReqCtx, body: bytes) -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + + +def _root(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, b"hello\n") + return None -def _get_book(ctx: RequestCtx) -> Response: - book_id = ctx.path_args["id"] +def _get_book(ctx: HTTPReqCtx) -> BodyHandler | None: + book_id = route_match(ctx).path_args["id"] # Spans inside the handler land on the request transaction. with sentry_sdk.start_span(op="db.query", name="select book"): time.sleep(0.01) - return Response(200, {"content-type": "text/plain"}, [f"book={book_id}\n".encode()]) + _emit(ctx, f"book={book_id}\n".encode()) + return None def build_router(): diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 989dc2c..e30a5db 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -5,22 +5,16 @@ from localpost.http._types import Response as NativeResponse from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( - RequestCtx, - Response, Route, + RouteMatch, Router, Routes, URITemplate, -) -from localpost.http.router import ( - RequestHandler as RouterRequestHandler, + route_match, ) from localpost.http.server import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose, start_http_server from localpost.http.wsgi import wrap_wsgi -# ``Response`` (the public name) is the high-level router response. -# ``NativeResponse`` is the low-level wire-format response used directly -# with ``HTTPReqCtx.complete`` / ``start_response``. __all__ = [ # config "ServerConfig", @@ -43,10 +37,9 @@ "Router", "Routes", "Route", + "RouteMatch", "URITemplate", - "RequestCtx", - "Response", - "RouterRequestHandler", + "route_match", # wsgi "wrap_wsgi", # hosting diff --git a/localpost/http/router.py b/localpost/http/router.py index e278086..32df0ab 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -1,28 +1,39 @@ +"""URI-template router for LocalPost HTTP — a thin dispatcher middleware. + +Matches the request URI against a table of compiled :class:`URITemplate` s, +attaches the :class:`RouteMatch` to ``ctx.attrs["route_match"]``, and +delegates to the matched route's :data:`localpost.http.RequestHandler`. + +404 / 405 are answered inline on the selector. The :class:`Router` +itself is just a :data:`localpost.http.RequestHandler` — wrap it with +:func:`localpost.http.thread_pool_handler` if you want matched routes +to run on workers, or compose with middleware via +:func:`localpost.http.compose`. + +For Pythonic helpers (decorators, response conversion, param injection), +see :class:`localpost.http.app.HttpApp`. +""" + from __future__ import annotations import re from collections.abc import Callable, Iterable, Mapping -from contextlib import ExitStack from dataclasses import dataclass, field from http import HTTPMethod from http.client import responses as _http_phrases from typing import Self, final -from urllib.parse import parse_qs -from localpost._utils import NOT_SET from localpost.http._types import Response as _NativeResponse -from localpost.http.config import DEFAULT_BUFFER_SIZE from localpost.http.server import BodyHandler, HTTPReqCtx from localpost.http.server import RequestHandler as NativeRequestHandler __all__ = [ "URITemplate", - "RequestCtx", - "Response", - "RequestHandler", + "RouteMatch", "Route", "Routes", "Router", + "route_match", ] _VAR_PATTERN = re.compile(r"\{([^}]+)\}") @@ -62,46 +73,25 @@ def match(self, uri: str) -> dict[str, str] | None: @final -@dataclass(frozen=True, eq=False, slots=True) -class RequestCtx: - exit_stack: ExitStack +@dataclass(frozen=True, slots=True) +class RouteMatch: + """Attached to ``ctx.attrs["route_match"]`` by :class:`Router` on a successful match. + + Read via :func:`route_match` from inside a route handler or middleware. + """ - headers: Mapping[str, str] method: HTTPMethod matched_template: URITemplate - path: str - query_string: str - query_args: Mapping[str, list[str]] path_args: Mapping[str, str] - receive: Callable[[int], bytes] - - _req_body: bytearray | None | object = field(default=NOT_SET, init=False, repr=False) - - def body(self, cache: bool = False) -> bytes: - if self._req_body is None: - raise RuntimeError("body has been read and not cached") - if isinstance(self._req_body, bytearray): - return bytes(self._req_body) - body = bytearray() - object.__setattr__(self, "_req_body", body if cache else None) - while True: - chunk = self.receive(DEFAULT_BUFFER_SIZE) - if not chunk: - break - body.extend(chunk) - return bytes(body) +def route_match(ctx: HTTPReqCtx) -> RouteMatch: + """Return the :class:`RouteMatch` the :class:`Router` placed on ``ctx``. - -@dataclass(frozen=True, eq=False, slots=True) -class Response: - status_code: int - headers: Mapping[str, str] - body: Iterable[bytes] - - -RequestHandler = Callable[[RequestCtx], Response] + Raises :exc:`KeyError` if called outside a route handler (or before + the Router has run). + """ + return ctx.attrs["route_match"] @final @@ -110,7 +100,7 @@ class Route: """One compiled route inside a :class:`Router`.""" template: URITemplate - methods: Mapping[HTTPMethod, RequestHandler] + methods: Mapping[HTTPMethod, NativeRequestHandler] """Method → handler table for this template.""" allow_header: str @@ -123,47 +113,61 @@ class Route: @final @dataclass(eq=False, slots=True) class Routes: - """Mutable route builder. Accumulate routes via decorators, then ``build()`` a Router. + """Mutable route builder. + + The decorator forms (``.get``, ``.post``, ...) are thin sugar for + :meth:`add` — they register the handler as a plain + :data:`localpost.http.RequestHandler`, no framework wrapping. For + decorator-driven registration with response conversion, param + injection, etc. use :class:`localpost.http.app.HttpApp`. Usage:: routes = Routes() @routes.get("/hello/{name}") - def hello(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"hi"]) + def hello(ctx: HTTPReqCtx) -> BodyHandler | None: + match = route_match(ctx) + ctx.complete(NativeResponse(...), b"hi " + match.path_args["name"].encode()) + return None router = routes.build() - # or equivalently: router = Router.from_routes(routes) """ - paths: dict[URITemplate, dict[HTTPMethod, RequestHandler]] = field(default_factory=dict) + paths: dict[URITemplate, dict[HTTPMethod, NativeRequestHandler]] = field(default_factory=dict) - def add(self, method: HTTPMethod | str, template: str, handler: RequestHandler) -> None: + def add( + self, + method: HTTPMethod | str, + template: str, + handler: NativeRequestHandler, + ) -> None: m = method if isinstance(method, HTTPMethod) else HTTPMethod(method.upper()) key = _find_template(self.paths, template) or URITemplate.parse(template) self.paths.setdefault(key, {})[m] = handler - def _decorator(self, method: HTTPMethod, template: str) -> Callable[[RequestHandler], RequestHandler]: - def deco(handler: RequestHandler) -> RequestHandler: + def _decorator( + self, method: HTTPMethod, template: str + ) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + def deco(handler: NativeRequestHandler) -> NativeRequestHandler: self.add(method, template, handler) return handler return deco - def get(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + def get(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: return self._decorator(HTTPMethod.GET, template) - def post(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + def post(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: return self._decorator(HTTPMethod.POST, template) - def put(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + def put(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: return self._decorator(HTTPMethod.PUT, template) - def delete(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + def delete(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: return self._decorator(HTTPMethod.DELETE, template) - def patch(self, template: str) -> Callable[[RequestHandler], RequestHandler]: + def patch(self, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: return self._decorator(HTTPMethod.PATCH, template) def build(self) -> Router: @@ -175,8 +179,11 @@ def build(self) -> Router: class Router: """Immutable, compiled URI-template dispatcher. - Build via :meth:`from_routes` or :meth:`Routes.build`. The constructor fields are - an implementation detail — treat the class as read-only outside of ``from_routes``. + Build via :meth:`from_routes` or :meth:`Routes.build`. As a + :data:`localpost.http.RequestHandler`-producer, the dispatcher is + just :meth:`as_handler`'s return value — wrap it with middleware + via :func:`localpost.http.compose` and feed it to + :func:`localpost.http.http_server`. """ routes: tuple[Route, ...] @@ -209,8 +216,6 @@ def from_routes(cls, routes: Routes) -> Self: compiled_routes.append( Route( template=tmpl, - # Freeze the handler map at the type level (still a plain dict - # underneath, but exposed as Mapping — external mutation is a type error). methods=dict(method_map), allow_header=allow_header, _group_prefix=prefix, @@ -250,108 +255,61 @@ def _match(self, path: str, method_str: str) -> _MatchResult: return _MatchOk( handler=handler, - method=method, - matched_template=tmpl, - path_args=path_args, + match=RouteMatch( + method=method, + matched_template=tmpl, + path_args=path_args, + ), ) raise AssertionError("unreachable: regex matched but no outer group set") - def wsgi(self, environ: dict, start_response) -> Iterable[bytes]: - """WSGI app, to be used with any WSGI server, e.g. Gunicorn.""" - request_path = environ.get("PATH_INFO", "/") - request_method_str = environ.get("REQUEST_METHOD", "GET").upper() - match = self._match(request_path, request_method_str) - - if isinstance(match, _MatchNotFound): - start_response("404 Not Found", [("Content-Type", "text/plain")]) - return [b"Not Found"] - if isinstance(match, _MatchMethodNotAllowed): - start_response( - "405 Method Not Allowed", - [("Content-Type", "text/plain"), ("Allow", match.allow_header)], - ) - return [b"Method Not Allowed"] - - query_string = environ.get("QUERY_STRING", "") - headers = _headers_from_environ(environ) - wsgi_input = environ.get("wsgi.input") - - def receive(size: int) -> bytes: - if wsgi_input is None: - return b"" - return wsgi_input.read(size) or b"" - - with ExitStack() as stack: - ctx = RequestCtx( - exit_stack=stack, - headers=headers, - method=match.method, - matched_template=match.matched_template, - path=request_path, - query_string=query_string, - query_args=parse_qs(query_string), - path_args=match.path_args, - receive=receive, - ) - response = match.handler(ctx) - - status_line = f"{response.status_code} {_http_phrases.get(response.status_code, 'Unknown')}" - start_response(status_line, [(k, v) for k, v in response.headers.items()]) - return response.body - def as_handler(self) -> NativeRequestHandler: - """Return a :class:`localpost.http.RequestHandler` that dispatches via this router. - - 404 (no template match) and 405 (template match, method mismatch) are - answered inline on the selector thread — no body recv, no worker - hop, no allocation beyond the static response payload. A matched - route returns a :data:`BodyHandler` continuation; the selector - buffers the request body into ``ctx.body`` before invoking it, - and the continuation can be wrapped with - :func:`thread_pool_handler` if you want it offloaded to a worker. + """Return a :data:`localpost.http.RequestHandler` that dispatches via this router. + + On a match, attaches :class:`RouteMatch` to ``ctx.attrs["route_match"]`` + and delegates to the registered per-route handler. 404 / 405 are + answered inline (via ``ctx.complete``); the body bytes (if any) + are silently drained by the http layer. """ - def handle(http_ctx: HTTPReqCtx) -> BodyHandler | None: - req = http_ctx.request + def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: + req = ctx.request target = req.target.decode("iso-8859-1") if "?" in target: - path, query_string = target.split("?", 1) + path, _ = target.split("?", 1) else: - path, query_string = target, "" + path = target method_str = req.method.decode("ascii").upper() match = self._match(path, method_str) if isinstance(match, _MatchNotFound): - _send_plain(http_ctx, 404, b"Not Found") + _send_plain(ctx, 404, b"Not Found") return None if isinstance(match, _MatchMethodNotAllowed): _send_plain( - http_ctx, + ctx, 405, b"Method Not Allowed", extra_headers=[(b"Allow", match.allow_header.encode("ascii"))], ) return None - # Matched route. Hand the body-bearing closure back to the - # selector. ``ctx.body`` is populated when the continuation - # runs — for body-less methods (GET / HEAD / DELETE) it's - # simply ``b""``. - headers = {name.decode("iso-8859-1").lower(): value.decode("iso-8859-1") for name, value in req.headers} - return _make_body_continuation(match, path, query_string, headers) + ctx.attrs["route_match"] = match.match + return match.handler(ctx) + + return dispatch - return handle + +# --- Match result types ------------------------------------------------- @final @dataclass(frozen=True, slots=True) class _MatchOk: - handler: RequestHandler - method: HTTPMethod - matched_template: URITemplate - path_args: Mapping[str, str] + handler: NativeRequestHandler + match: RouteMatch @final @@ -370,6 +328,9 @@ class _MatchMethodNotAllowed: _MATCH_NOT_FOUND = _MatchNotFound() +# --- Internal helpers --------------------------------------------------- + + def _literal_prefix_len(t: URITemplate) -> int: """Length of the literal prefix up to the first ``{var}`` placeholder.""" m = _VAR_PATTERN.search(t.template) @@ -390,7 +351,8 @@ def _reprefixed_regex_source(t: URITemplate, prefix: str) -> str: def _find_template( - paths: Mapping[URITemplate, Mapping[HTTPMethod, RequestHandler]], template_str: str + paths: Mapping[URITemplate, Mapping[HTTPMethod, NativeRequestHandler]], + template_str: str, ) -> URITemplate | None: for t in paths: if t.template == template_str: @@ -398,80 +360,18 @@ def _find_template( return None -def _headers_from_environ(environ: dict) -> dict[str, str]: - headers: dict[str, str] = {} - for key, value in environ.items(): - if key.startswith("HTTP_"): - header_name = key[5:].replace("_", "-").lower() - headers[header_name] = value - if "CONTENT_TYPE" in environ: - headers["content-type"] = environ["CONTENT_TYPE"] - if "CONTENT_LENGTH" in environ: - headers["content-length"] = environ["CONTENT_LENGTH"] - return headers - - -def _make_body_continuation( - match: _MatchOk, - path: str, - query_string: str, - headers: Mapping[str, str], -) -> BodyHandler: - """Build the post-body continuation for a matched route. - - Pre-populates the :class:`RequestCtx` body cache with the bytes - buffered by the selector, so ``ctx.body()`` returns immediately - without going through ``receive()``. - """ - - def run(http_ctx: HTTPReqCtx) -> None: - with ExitStack() as stack: - ctx = RequestCtx( - exit_stack=stack, - headers=headers, - method=match.method, - matched_template=match.matched_template, - path=path, - query_string=query_string, - query_args=parse_qs(query_string), - path_args=match.path_args, - # Body is pre-buffered into ``http_ctx.body``. ``receive`` - # is kept for backwards-compat but only fires for handlers - # that explicitly call it after the cache is consumed. - receive=lambda _: b"", - ) - object.__setattr__(ctx, "_req_body", bytearray(http_ctx.body)) - response = match.handler(ctx) - - wire_headers = [(k.encode("iso-8859-1"), v.encode("iso-8859-1")) for k, v in response.headers.items()] - http_ctx.start_response( - _NativeResponse( - status_code=response.status_code, - headers=wire_headers, - reason=_http_phrases.get(response.status_code, "Unknown").encode("iso-8859-1"), - ) - ) - for chunk in response.body: - if chunk: - http_ctx.send(chunk) - http_ctx.finish_response() - - return run - - def _send_plain( ctx: HTTPReqCtx, status_code: int, body: bytes, *, - extra_headers: list[tuple[bytes, bytes]] | None = None, + extra_headers: Iterable[tuple[bytes, bytes]] = (), ) -> None: headers: list[tuple[bytes, bytes]] = [ (b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, ] - if extra_headers: - headers.extend(extra_headers) ctx.complete( _NativeResponse( status_code=status_code, @@ -480,3 +380,5 @@ def _send_plain( ), body, ) + + diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 74fb7e4..3084f75 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -58,7 +58,7 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: # cardinality). Router's dispatch will match again — cheap. match = router._match(path, method) if isinstance(match, _MatchOk): - tx_name = f"{method} {match.matched_template.template}" + tx_name = f"{method} {match.match.matched_template.template}" source = "route" else: tx_name = f"{method} {path}" diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index 9a2cf70..134344d 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -20,18 +20,38 @@ def _build_handler(): mode = os.environ.get("LP_TEST_MODE", "router") if mode == "router": - from localpost.http import RequestCtx, Response, Routes + from localpost.http import ( + BodyHandler, + HTTPReqCtx, + NativeResponse, + Routes, + route_match, + ) + + def _emit(ctx: HTTPReqCtx, body: bytes) -> None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) - def _ping(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"pong"]) + def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, b"pong") + return None - def _slow(_: RequestCtx) -> Response: + def _slow(ctx: HTTPReqCtx) -> BodyHandler | None: time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) - body = str(threading.get_ident()).encode() - return Response(200, {"content-type": "text/plain"}, [body]) + _emit(ctx, str(threading.get_ident()).encode()) + return None - def _hello(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [f"hi {ctx.path_args['name']}".encode()]) + def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, f"hi {route_match(ctx).path_args['name']}".encode()) + return None routes = Routes() routes.get("/ping")(_ping) diff --git a/tests/http/router.py b/tests/http/router.py index 2a6e18d..e9409fa 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -1,22 +1,50 @@ -"""Tests for localpost.http.router.""" +"""Tests for localpost.http.router — the lean dispatcher. + +The Router attaches a :class:`RouteMatch` to ``ctx.attrs["route_match"]`` +and delegates to the registered :class:`localpost.http.RequestHandler`. +404 / 405 are answered inline. Pythonic helpers (response converters, +param injection, etc.) live in ``HttpApp``; tests for those live elsewhere. +""" from __future__ import annotations from http import HTTPMethod -from io import BytesIO +from unittest.mock import Mock import httpx import pytest from localpost.http import ( - RequestCtx, - Response, - Router, + BodyHandler, + HTTPReqCtx, + NativeResponse, + RouteMatch, Routes, URITemplate, + route_match, ) -# --- URITemplate ---------------------------------------------------------- + +def _ok_handler(body: bytes = b"ok"): + """Build a minimal HTTPReqCtx-shape handler that emits 200 + ``body``.""" + + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.complete( + NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + return None + + return handler + + +# --- URITemplate --------------------------------------------------------- class TestURITemplate: @@ -45,152 +73,13 @@ def test_non_matching_returns_none(self): assert t.match("/goodbye/alice") is None -# --- Router.wsgi ---------------------------------------------------------- - - -def _fake_wsgi(router: Router, method: str, path: str, query: str = "", body: bytes = b""): - """Drive router.wsgi synchronously; returns (status_line, headers, body).""" - captured: dict = {} - - def start_response(status, headers): - captured["status"] = status - captured["headers"] = headers - - environ = { - "REQUEST_METHOD": method, - "PATH_INFO": path, - "QUERY_STRING": query, - "wsgi.input": BytesIO(body), - } - out = router.wsgi(environ, start_response) - return captured["status"], captured["headers"], b"".join(out) - - -class TestRouterWSGI: - def test_matches_and_dispatches(self): - routes = Routes() - - @routes.get("/hello/{name}") - def hello(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [f"hi {ctx.path_args['name']}".encode()]) - - assert hello # keep reference so pyright is happy - router = routes.build() - - status, _, body = _fake_wsgi(router, "GET", "/hello/alice") - assert status.startswith("200") - assert body == b"hi alice" - - def test_404_on_miss(self): - router = Routes().build() - status, _, body = _fake_wsgi(router, "GET", "/nope") - assert status.startswith("404") - assert body == b"Not Found" - - def test_405_with_allow_header(self): - routes = Routes() - - @routes.post("/resource") - def create(_: RequestCtx) -> Response: - return Response(201, {}, []) - - assert create - router = routes.build() - - status, headers, body = _fake_wsgi(router, "GET", "/resource") - assert status.startswith("405") - assert body == b"Method Not Allowed" - header_names = {name.lower(): value for name, value in headers} - assert header_names.get("allow") == "POST" - - def test_query_parsing(self): - routes = Routes() +# --- Router.as_handler --------------------------------------------------- - @routes.get("/search") - def search(ctx: RequestCtx) -> Response: - q = ctx.query_args.get("q", [""])[0] - return Response(200, {"content-type": "text/plain"}, [q.encode()]) - - assert search - router = routes.build() - - status, _, body = _fake_wsgi(router, "GET", "/search", query="q=books&extra=1") - assert status.startswith("200") - assert body == b"books" - - def test_multiple_methods_same_template(self): - routes = Routes() - - @routes.get("/item") - def _get(_: RequestCtx) -> Response: - return Response(200, {}, [b"g"]) - - @routes.post("/item") - def _post(_: RequestCtx) -> Response: - return Response(200, {}, [b"p"]) - - assert _get is not None - assert _post is not None - router = routes.build() - s_g, _, b_g = _fake_wsgi(router, "GET", "/item") - s_p, _, b_p = _fake_wsgi(router, "POST", "/item") - assert s_g.startswith("200") - assert b_g == b"g" - assert s_p.startswith("200") - assert b_p == b"p" - - def test_request_body_read(self): - routes = Routes() - - @routes.post("/echo") - def echo(ctx: RequestCtx) -> Response: - return Response(200, {}, [ctx.body()]) - - assert echo - router = routes.build() - - status, _, body = _fake_wsgi(router, "POST", "/echo", body=b"payload") - assert status.startswith("200") - assert body == b"payload" - - def test_longer_literal_prefix_wins(self): - """Ambiguous templates: /books/new should beat /books/{id}.""" - routes = Routes() - hits: list[str] = [] - - @routes.get("/books/{id}") - def get_book(_: RequestCtx) -> Response: - hits.append("by_id") - return Response(200, {}, [b"id"]) - - @routes.get("/books/new") - def new_book(_: RequestCtx) -> Response: - hits.append("new") - return Response(200, {}, [b"new"]) - - assert get_book is not None - assert new_book is not None - router = routes.build() - - _fake_wsgi(router, "GET", "/books/new") - _fake_wsgi(router, "GET", "/books/42") - - assert hits == ["new", "by_id"] - - -# --- Router.as_handler (native) ------------------------------------------- - - -class TestRouterAsHandler: +class TestRouterDispatch: def test_dispatch_200(self, serve_in_thread): routes = Routes() - - @routes.get("/ping") - def ping(_: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [b"pong"]) - - assert ping + routes.get("/ping")(_ok_handler(b"pong")) router = routes.build() with serve_in_thread(router.as_handler()) as port: @@ -199,289 +88,166 @@ def ping(_: RequestCtx) -> Response: assert resp.status_code == 200 assert resp.text == "pong" - def test_dispatch_path_params(self, serve_in_thread): - routes = Routes() - captured: dict = {} - - @routes.get("/books/{book_id}") - def get_book(ctx: RequestCtx) -> Response: - captured["book_id"] = ctx.path_args["book_id"] - captured["method"] = ctx.method - captured["matched_template"] = ctx.matched_template.template - return Response(200, {"content-type": "text/plain"}, [b"ok"]) - - assert get_book - router = routes.build() - - with serve_in_thread(router.as_handler()) as port: - httpx.get(f"http://127.0.0.1:{port}/books/xyz-123", timeout=5) - - assert captured["book_id"] == "xyz-123" - assert captured["method"] == HTTPMethod.GET - assert captured["matched_template"] == "/books/{book_id}" - def test_404(self, serve_in_thread): router = Routes().build() - with serve_in_thread(router.as_handler()) as port: resp = httpx.get(f"http://127.0.0.1:{port}/does-not-exist", timeout=5) - assert resp.status_code == 404 assert resp.text == "Not Found" def test_405_with_allow_header(self, serve_in_thread): routes = Routes() - - @routes.post("/resource") - def _post(_: RequestCtx) -> Response: - return Response(201, {}, []) - - assert _post + routes.post("/resource")(_ok_handler(b"x")) router = routes.build() - with serve_in_thread(router.as_handler()) as port: resp = httpx.get(f"http://127.0.0.1:{port}/resource", timeout=5) - assert resp.status_code == 405 assert resp.headers.get("allow") == "POST" - def test_query_parsing(self, serve_in_thread): - routes = Routes() - captured: dict = {} - - @routes.get("/search") - def search(ctx: RequestCtx) -> Response: - captured["q"] = ctx.query_args.get("q", [""])[0] - captured["raw"] = ctx.query_string - return Response(200, {"content-type": "text/plain"}, [b"ok"]) - - assert search - router = routes.build() - - with serve_in_thread(router.as_handler()) as port: - httpx.get(f"http://127.0.0.1:{port}/search?q=books", timeout=5) - - assert captured["q"] == "books" - assert captured["raw"] == "q=books" - - def test_streaming_response(self, serve_in_thread): - routes = Routes() - - @routes.get("/stream") - def stream(_: RequestCtx) -> Response: - return Response( - 200, - {"transfer-encoding": "chunked", "content-type": "text/plain"}, - [b"first", b"second", b"third"], - ) - - assert stream - router = routes.build() - - with serve_in_thread(router.as_handler()) as port: - resp = httpx.get(f"http://127.0.0.1:{port}/stream", timeout=5) - assert resp.status_code == 200 - assert resp.content == b"firstsecondthird" - - def test_handler_sees_request_headers(self, serve_in_thread): - routes = Routes() +class TestRouteMatchAttached: + def test_match_in_attrs(self, serve_in_thread): captured: dict = {} - @routes.get("/hdr") - def hdr(ctx: RequestCtx) -> Response: - captured["x_custom"] = ctx.headers.get("x-custom") - return Response(200, {}, [b"ok"]) - - assert hdr - router = routes.build() - - with serve_in_thread(router.as_handler()) as port: - httpx.get(f"http://127.0.0.1:{port}/hdr", headers={"X-Custom": "yo"}, timeout=5) - - assert captured["x_custom"] == "yo" + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + m = route_match(ctx) + assert isinstance(m, RouteMatch) + captured["method"] = m.method + captured["template"] = m.matched_template.template + captured["path_args"] = dict(m.path_args) + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-length", b"2")], + ), + b"ok", + ) + return None - def test_ctx_can_read_body(self, serve_in_thread): routes = Routes() - captured: dict = {} - - @routes.post("/echo") - def echo(ctx: RequestCtx) -> Response: - captured["body"] = ctx.body() - return Response(200, {}, [captured["body"]]) - - assert echo + routes.get("/books/{book_id}")(handler) router = routes.build() with serve_in_thread(router.as_handler()) as port: - resp = httpx.post(f"http://127.0.0.1:{port}/echo", content=b"hello body", timeout=5) - - assert resp.status_code == 200 - assert captured["body"] == b"hello body" + httpx.get(f"http://127.0.0.1:{port}/books/xyz-123", timeout=5) + assert captured["method"] == HTTPMethod.GET + assert captured["template"] == "/books/{book_id}" + assert captured["path_args"] == {"book_id": "xyz-123"} -# --- Routes registration API --------------------------------------------- + def test_route_match_outside_router_raises(self): + # ``route_match`` reads ``ctx.attrs["route_match"]``; outside a router + # the key is missing and we get the natural KeyError. Documented. + ctx = Mock() + ctx.attrs = {} + with pytest.raises(KeyError): + route_match(ctx) class TestRoutesRegistration: def test_explicit_add(self): routes = Routes() - - def handler(_: RequestCtx) -> Response: - return Response(200, {}, [b"x"]) - - routes.add("GET", "/thing", handler) + routes.add("GET", "/thing", _ok_handler()) assert len(routes.paths) == 1 def test_same_template_multiple_methods_stored_once(self): routes = Routes() - - @routes.get("/r") - def g(_: RequestCtx) -> Response: - return Response(200, {}, []) - - @routes.post("/r") - def p(_: RequestCtx) -> Response: - return Response(200, {}, []) - - assert g is not None - assert p is not None - + routes.get("/r")(_ok_handler(b"g")) + routes.post("/r")(_ok_handler(b"p")) assert len(routes.paths) == 1 methods = next(iter(routes.paths.values())) assert set(methods) == {HTTPMethod.GET, HTTPMethod.POST} def test_decorator_returns_original_handler(self): routes = Routes() + h = _ok_handler() + assert routes.get("/x")(h) is h - def handler(_: RequestCtx) -> Response: - return Response(200, {}, []) - - registered = routes.get("/x")(handler) - assert registered is handler - - -# --- Router build-time checks -------------------------------------------- + def test_method_string_normalized(self): + routes = Routes() + routes.add("get", "/a", _ok_handler()) + routes.add("PoSt", "/a", _ok_handler()) + methods = next(iter(routes.paths.values())) + assert set(methods) == {HTTPMethod.GET, HTTPMethod.POST} class TestRouterBuild: - def test_empty_routes_produce_empty_router(self): + def test_empty_routes_produce_empty_router(self, serve_in_thread): """An empty Routes still builds a valid (never-matching) Router.""" router = Routes().build() - status, _, body = _fake_wsgi(router, "GET", "/") - assert status.startswith("404") - assert body == b"Not Found" - - def test_build_is_idempotent_snapshot(self): - """Building twice produces equivalent routers with the same routes order.""" - routes = Routes() - routes.add("GET", "/a", lambda ctx: Response(200, {}, [])) - routes.add("GET", "/b", lambda ctx: Response(200, {}, [])) - r1 = routes.build() - r2 = routes.build() - assert [r.template.template for r in r1.routes] == [r.template.template for r in r2.routes] + with serve_in_thread(router.as_handler()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) + assert resp.status_code == 404 - def test_allow_header_precomputed(self): + def test_longest_literal_prefix_first(self): + """More specific routes (longer literal prefix before the first var) + match before less specific ones.""" routes = Routes() - routes.add("GET", "/x", lambda ctx: Response(200, {}, [])) - routes.add("POST", "/x", lambda ctx: Response(200, {}, [])) + routes.get("/{anything}")(_ok_handler(b"any")) + routes.get("/specific")(_ok_handler(b"specific")) router = routes.build() - # Methods are sorted for a stable header; both methods end up in the header. - assert router.routes[0].allow_header in ("GET, POST", "POST, GET") - assert set(router.routes[0].allow_header.split(", ")) == {"GET", "POST"} + # The first route in dispatch order should be ``/specific`` (longer literal prefix). + assert router.routes[0].template.template == "/specific" + assert router.routes[1].template.template == "/{anything}" - def test_allow_header_sorted_across_many_methods(self): + def test_dispatch_order_specific_before_general(self, serve_in_thread): routes = Routes() - for m in ("PATCH", "GET", "DELETE", "POST", "PUT"): - routes.add(m, "/r", lambda ctx: Response(200, {}, [])) + routes.get("/{anything}")(_ok_handler(b"any")) + routes.get("/specific")(_ok_handler(b"specific")) router = routes.build() - # Allow header: methods sorted lexicographically by value. - assert router.routes[0].allow_header == "DELETE, GET, PATCH, POST, PUT" - - def test_duplicate_template_raises(self): - """Router.from_routes guards against hand-built paths dicts with duplicate templates. - - ``Routes.add`` dedupes by template string, and two ``URITemplate.parse`` results - compare equal (replacing the dict entry on insert), so the only way to hit this - branch is to construct two URITemplate instances directly that share a ``.template`` - but differ in another field. This pins the guard. - """ - import re - - t1 = URITemplate(template="/dup", variable_names=(), _regex=re.compile("^/dup$")) - t2 = URITemplate(template="/dup", variable_names=(), _regex=re.compile("^/dup\\Z")) - routes = Routes() - routes.paths[t1] = {HTTPMethod.GET: lambda ctx: Response(200, {}, [])} - routes.paths[t2] = {HTTPMethod.POST: lambda ctx: Response(200, {}, [])} - assert len(routes.paths) == 2 # different regex objects → different hash → two keys - - with pytest.raises(ValueError, match="duplicate template"): - routes.build() + with serve_in_thread(router.as_handler()) as port: + r_specific = httpx.get(f"http://127.0.0.1:{port}/specific", timeout=5) + r_other = httpx.get(f"http://127.0.0.1:{port}/foo", timeout=5) -# --- Method dispatch quirks -------------------------------------------------- + assert r_specific.text == "specific" + assert r_other.text == "any" class TestMethodDispatch: - def test_options_on_registered_route_is_405(self): - """No automatic OPTIONS handling — unregistered method falls through to 405.""" + def test_get_post_separate_handlers(self, serve_in_thread): routes = Routes() - routes.add("GET", "/r", lambda ctx: Response(200, {}, [])) + routes.get("/item")(_ok_handler(b"got")) + routes.post("/item")(_ok_handler(b"posted")) router = routes.build() - status, headers, body = _fake_wsgi(router, "OPTIONS", "/r") - assert status.startswith("405") - assert body == b"Method Not Allowed" - allow = {n.lower(): v for n, v in headers}["allow"] - assert allow == "GET" + with serve_in_thread(router.as_handler()) as port: + r_get = httpx.get(f"http://127.0.0.1:{port}/item", timeout=5) + r_post = httpx.post(f"http://127.0.0.1:{port}/item", timeout=5) + + assert r_get.text == "got" + assert r_post.text == "posted" - def test_unknown_method_string_is_405(self): - """A method name outside http.HTTPMethod (e.g., 'FAKEVERB') routes to 405.""" + def test_unknown_method_returns_405(self, serve_in_thread): routes = Routes() - routes.add("GET", "/r", lambda ctx: Response(200, {}, [])) + routes.get("/r")(_ok_handler()) router = routes.build() - status, _, body = _fake_wsgi(router, "FAKEVERB", "/r") - assert status.startswith("405") - assert body == b"Method Not Allowed" - - -# --- Path-variable encoding --------------------------------------------------- + with serve_in_thread(router.as_handler()) as port: + resp = httpx.request("PATCH", f"http://127.0.0.1:{port}/r", timeout=5) + assert resp.status_code == 405 class TestPathVariableEncoding: - def test_percent_encoded_value_is_not_decoded(self): - """URITemplate matches against the raw path; %20 stays literal in path_args.""" - routes = Routes() + def test_url_encoded_path_arg_passed_through(self, serve_in_thread): captured: dict = {} - @routes.get("/hello/{name}") - def hello(ctx: RequestCtx) -> Response: - captured["name"] = ctx.path_args["name"] - return Response(200, {}, [b"ok"]) - - assert hello - router = routes.build() - - status, _, _ = _fake_wsgi(router, "GET", "/hello/Bob%20Smith") - assert status.startswith("200") - assert captured["name"] == "Bob%20Smith" + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + m = route_match(ctx) + captured["name"] = m.path_args["name"] + ctx.complete(NativeResponse(200, [(b"content-length", b"2")]), b"ok") + return None - def test_value_with_dots_and_dashes_matches(self): - """Common id formats (uuid, dotted) match without escaping.""" routes = Routes() - captured: dict = {} - - @routes.get("/files/{id}") - def get_file(ctx: RequestCtx) -> Response: - captured["id"] = ctx.path_args["id"] - return Response(200, {}, [b"ok"]) - - assert get_file + routes.get("/u/{name}")(handler) router = routes.build() - for raw_id in ("a-b-c", "1.2.3", "550e8400-e29b-41d4-a716-446655440000"): - captured.clear() - status, _, _ = _fake_wsgi(router, "GET", f"/files/{raw_id}") - assert status.startswith("200") - assert captured["id"] == raw_id + with serve_in_thread(router.as_handler()) as port: + # Two requests: simple value, and url-encoded value. + httpx.get(f"http://127.0.0.1:{port}/u/alice", timeout=5) + assert captured["name"] == "alice" + + httpx.get(f"http://127.0.0.1:{port}/u/al%20ice", timeout=5) + # Router doesn't decode — the handler sees the raw URL-encoded value. + # That's the lean-dispatcher contract; the framework layer can decode. + assert captured["name"] == "al%20ice" diff --git a/tests/http/router_sentry_handler.py b/tests/http/router_sentry_handler.py index c253e46..39c1f1e 100644 --- a/tests/http/router_sentry_handler.py +++ b/tests/http/router_sentry_handler.py @@ -6,7 +6,7 @@ import pytest import sentry_sdk -from localpost.http import RequestCtx, Response, Routes +from localpost.http import BodyHandler, HTTPReqCtx, NativeResponse, Routes, route_match from localpost.http.router_sentry import sentry_router_handler from ._sentry_helpers import CapturingTransport, init_sentry, transactions @@ -24,12 +24,21 @@ def _build_router(): routes = Routes() @routes.get("/books/{id}") - def get_book(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [f"book={ctx.path_args['id']}".encode()]) + def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: + body = f"book={route_match(ctx).path_args['id']}".encode() + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + return None @routes.post("/books") - def create_book(_: RequestCtx) -> Response: - return Response(201, {}, []) + def create_book(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.complete(NativeResponse(status_code=201, headers=[(b"content-length", b"0")]), b"") + return None assert get_book is not None assert create_book is not None diff --git a/tests/http/service.py b/tests/http/service.py index 9c044bb..95ff496 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -19,15 +19,15 @@ from localpost.hosting import ServiceLifetimeView, serve from localpost.http import ( + BodyHandler, HTTPReqCtx, NativeResponse, RequestCancelled, - RequestCtx, - Response, Routes, ServerConfig, check_cancelled, http_server, + route_match, thread_pool_handler, ) from localpost.http.server import RequestHandler @@ -254,8 +254,17 @@ async def test_router_dispatch_via_service(self, free_port): routes = Routes() @routes.get("/books/{id}") - def get_book(ctx: RequestCtx) -> Response: - return Response(200, {"content-type": "text/plain"}, [f"book={ctx.path_args['id']}".encode()]) + def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: + book_id = route_match(ctx).path_args["id"] + body = f"book={book_id}".encode() + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + return None assert get_book is not None router = routes.build() @@ -366,10 +375,17 @@ async def test_router_direct_runs_on_one_thread(self, free_port): routes = Routes() @routes.get("/hit") - def hit(_: RequestCtx) -> Response: + def hit(ctx: HTTPReqCtx) -> BodyHandler | None: with lock: threads_seen.add(threading.get_ident()) - return Response(200, {"content-type": "text/plain"}, [b"ok"]) + ctx.complete( + NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", b"2")], + ), + b"ok", + ) + return None assert hit is not None router = routes.build() From 71a45cbc9b684860f56bfad299b33b399bdedefd Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 22:15:57 +0400 Subject: [PATCH 119/286] =?UTF-8?q?feat(http):=20HttpApp=20framework=20?= =?UTF-8?q?=E2=80=94=20decorators,=20param=20injection,=20response=20conve?= =?UTF-8?q?rsion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ``localpost.http.app.HttpApp``, the user-facing framework layer above the lean Router. ``HttpApp`` provides the JSON-API ergonomics that used to live in ``Router`` (now removed): - ``@app.get(path)`` / ``.post`` / ``.put`` / ``.delete`` / ``.patch`` decorators register handlers under a URI template. - Parameter injection: handler params named to match a path variable receive the matched value as ``str``; params annotated as ``HTTPReqCtx`` receive the request context. Unresolvable params raise at registration time. - Response conversion: ``str`` → ``text/plain``, ``bytes`` → ``application/octet-stream``, ``dict`` / ``list`` → ``application/json``, ``NativeResponse`` → as-is, ``(NativeResponse, bytes)`` → with body, ``None`` → 204. - App-level + per-route middleware composition (``HttpApp(middleware=...)`` and ``@app.get(..., middleware=...)``). - Worker-pool dispatch via the existing ``thread_pool_handler``. ``max_concurrency=0`` disables the pool — handlers run inline on the selector thread (only viable for short non-blocking handlers). - ``app.service(config, *, backend, selectors)`` factory for hosting: composes pool + chosen backend + multi-selector knob. - ``app.as_router_handler()`` returns the bare ``RequestHandler`` for users who want to compose their own pool / lifecycle. Bench apps ``localpost_native.py``, ``localpost_httptools.py``, and the ``localpost_httptools_inline.py`` variant rewritten to use ``HttpApp``. Original lean-Router-handler shape stays available for users who want it. 40 new tests in ``tests/http/app.py`` cover response conversion, param injection (path arg, ctx, mixed), middleware (app-level + per-route + short-circuit + ctx.attrs propagation), 404 / 405, pool dispatch / inline mode, and backend selection. 165/165 http tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/apps/localpost_httptools.py | 120 ++--- .../http/apps/localpost_httptools_inline.py | 107 ++-- benchmarks/http/apps/localpost_native.py | 100 ++-- localpost/http/__init__.py | 3 + localpost/http/app.py | 308 +++++++++++ tests/http/app.py | 491 ++++++++++++++++++ 6 files changed, 933 insertions(+), 196 deletions(-) create mode 100644 localpost/http/app.py create mode 100644 tests/http/app.py diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index ec15987..fd44a03 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -1,6 +1,6 @@ -"""LocalPost native handler — Router + httptools_server (C parser). +"""LocalPost native handler — HttpApp + httptools backend. -Same handler set as ``localpost_native``; only the server backend differs. +Same behaviour as ``localpost_native``; only the server backend differs. """ from __future__ import annotations @@ -10,75 +10,65 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body -from localpost.hosting import run_app, service -from localpost.http import ( - BodyHandler, - HTTPReqCtx, - NativeResponse, - Routes, - ServerConfig, - httptools_server, - route_match, - thread_pool_handler, -) - - -def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: - ctx.complete( - NativeResponse( - status_code=200, - headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], - ), - body, - ) - - -def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: - _emit(ctx, PING_BODY) - return None - - -def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: - _emit(ctx, hello_body(route_match(ctx).path_args["name"])) - return None - - -def _echo_post_body(ctx: HTTPReqCtx) -> None: - _emit(ctx, ctx.body, content_type=b"application/json") - - -def _echo(_: HTTPReqCtx) -> BodyHandler: - return _echo_post_body - - -def _profile_update_post_body(ctx: HTTPReqCtx) -> None: - body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) - for delay_s in PROFILE_WORK_DELAYS_S: - time.sleep(delay_s) - _emit(ctx, body, content_type=b"application/json") - - -def _profile_update(_: HTTPReqCtx) -> BodyHandler: - return _profile_update_post_body +from localpost.hosting import run_app +from localpost.http import HttpApp, HTTPReqCtx, NativeResponse, ServerConfig def main() -> int: args = parse_args() - routes = Routes() - routes.get("/ping")(_ping) - routes.get("/hello/{name}")(_hello) - routes.post("/echo")(_echo) - routes.post("/users/{user_id}/profile")(_profile_update) - handler = routes.build().as_handler() - cfg = ServerConfig(host="127.0.0.1", port=args.port) + app = HttpApp(max_concurrency=32) - @service - async def app(): - async with thread_pool_handler(handler, max_concurrency=32) as wrapped: - async with httptools_server(cfg, wrapped, selectors=args.selectors): - yield + @app.get("/ping") + def ping(): + # Wire-bytes for the tightest plaintext path (skips the str → bytes + # encode and the Content-Type rewrite). + return NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(PING_BODY)).encode("ascii")), + ], + ), PING_BODY + + @app.get("/hello/{name}") + def hello(name: str): + body = hello_body(name) + return NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), body + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(ctx.body)).encode("ascii")), + ], + ), ctx.body + + @app.post("/users/{user_id}/profile") + def profile_update(ctx: HTTPReqCtx, user_id: str): + body = profile_update_body(user_id, ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return NativeResponse( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), body - return run_app(app()) + # Decorators are no-op-returns of the original; suppress unused warnings. + _ = (ping, hello, echo, profile_update) + + cfg = ServerConfig(host="127.0.0.1", port=args.port) + return run_app(app.service(cfg, backend="httptools", selectors=args.selectors)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index 42afa72..20e949e 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -1,12 +1,7 @@ -"""LocalPost httptools backend — inline (no thread pool). +"""LocalPost httptools backend, inline mode (no thread pool). -Runs every request directly on the selector thread; bypasses -``thread_pool_handler`` to isolate selector-level throughput. Used to -characterise whether multi-selector unlocks parallelism when the worker -pool isn't the bottleneck. - -Reads the same ``--port`` / ``--selectors`` flags as the standard -``localpost_httptools`` app. +Bypasses the worker pool — every request runs on the selector thread. +Used to characterise selector-level throughput in isolation. """ from __future__ import annotations @@ -16,73 +11,49 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body -from localpost.hosting import run_app, service -from localpost.http import ( - BodyHandler, - HTTPReqCtx, - NativeResponse, - Routes, - ServerConfig, - httptools_server, - route_match, -) - - -def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: - ctx.complete( - NativeResponse( - status_code=200, - headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], - ), - body, - ) - - -def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: - _emit(ctx, PING_BODY) - return None - - -def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: - _emit(ctx, hello_body(route_match(ctx).path_args["name"])) - return None - - -def _echo_post_body(ctx: HTTPReqCtx) -> None: - _emit(ctx, ctx.body, content_type=b"application/json") - +from localpost.hosting import run_app +from localpost.http import HttpApp, HTTPReqCtx, NativeResponse, ServerConfig -def _echo(_: HTTPReqCtx) -> BodyHandler: - return _echo_post_body +def main() -> int: + args = parse_args() + app = HttpApp(max_concurrency=0) # inline: no pool -def _profile_update_post_body(ctx: HTTPReqCtx) -> None: - body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) - for delay_s in PROFILE_WORK_DELAYS_S: - time.sleep(delay_s) - _emit(ctx, body, content_type=b"application/json") - + @app.get("/ping") + def ping(): + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(PING_BODY)).encode("ascii"))], + ), PING_BODY -def _profile_update(_: HTTPReqCtx) -> BodyHandler: - return _profile_update_post_body + @app.get("/hello/{name}") + def hello(name: str): + body = hello_body(name) + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), body + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(ctx.body)).encode("ascii"))], + ), ctx.body + + @app.post("/users/{user_id}/profile") + def profile_update(ctx: HTTPReqCtx, user_id: str): + body = profile_update_body(user_id, ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))], + ), body -def main() -> int: - args = parse_args() - routes = Routes() - routes.get("/ping")(_ping) - routes.get("/hello/{name}")(_hello) - routes.post("/echo")(_echo) - routes.post("/users/{user_id}/profile")(_profile_update) - handler = routes.build().as_handler() + _ = (ping, hello, echo, profile_update) cfg = ServerConfig(host="127.0.0.1", port=args.port) - - @service - async def app(): - async with httptools_server(cfg, handler, selectors=args.selectors): - yield - - return run_app(app()) + return run_app(app.service(cfg, backend="httptools", selectors=args.selectors)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index f68dddf..4299472 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -1,4 +1,4 @@ -"""LocalPost native handler — Router + http_server, no framework.""" +"""LocalPost native handler — HttpApp + h11 backend.""" from __future__ import annotations @@ -7,75 +7,49 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body -from localpost.hosting import run_app, service -from localpost.http import ( - BodyHandler, - HTTPReqCtx, - NativeResponse, - Routes, - ServerConfig, - http_server, - route_match, - thread_pool_handler, -) +from localpost.hosting import run_app +from localpost.http import HttpApp, HTTPReqCtx, NativeResponse, ServerConfig -def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: - ctx.complete( - NativeResponse( - status_code=200, - headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], - ), - body, - ) - - -def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: - _emit(ctx, PING_BODY) - return None - - -def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: - _emit(ctx, hello_body(route_match(ctx).path_args["name"])) - return None - - -def _echo_post_body(ctx: HTTPReqCtx) -> None: - _emit(ctx, ctx.body, content_type=b"application/json") - - -def _echo(_: HTTPReqCtx) -> BodyHandler: - return _echo_post_body - - -def _profile_update_post_body(ctx: HTTPReqCtx) -> None: - body = profile_update_body(route_match(ctx).path_args["user_id"], ctx.body) - for delay_s in PROFILE_WORK_DELAYS_S: - time.sleep(delay_s) - _emit(ctx, body, content_type=b"application/json") +def main() -> int: + args = parse_args() + app = HttpApp(max_concurrency=32) + @app.get("/ping") + def ping(): + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(PING_BODY)).encode("ascii"))], + ), PING_BODY -def _profile_update(_: HTTPReqCtx) -> BodyHandler: - return _profile_update_post_body + @app.get("/hello/{name}") + def hello(name: str): + body = hello_body(name) + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), body + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(ctx.body)).encode("ascii"))], + ), ctx.body + + @app.post("/users/{user_id}/profile") + def profile_update(ctx: HTTPReqCtx, user_id: str): + body = profile_update_body(user_id, ctx.body) + for delay_s in PROFILE_WORK_DELAYS_S: + time.sleep(delay_s) + return NativeResponse( + status_code=200, + headers=[(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))], + ), body -def main() -> int: - args = parse_args() - routes = Routes() - routes.get("/ping")(_ping) - routes.get("/hello/{name}")(_hello) - routes.post("/echo")(_echo) - routes.post("/users/{user_id}/profile")(_profile_update) - handler = routes.build().as_handler() + _ = (ping, hello, echo, profile_update) cfg = ServerConfig(host="127.0.0.1", port=args.port) - - @service - async def app(): - async with thread_pool_handler(handler, max_concurrency=32) as wrapped: - async with http_server(cfg, wrapped, selectors=args.selectors): - yield - - return run_app(app()) + return run_app(app.service(cfg, backend="h11", selectors=args.selectors)) if __name__ == "__main__": diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index e30a5db..30f172d 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -3,6 +3,7 @@ from localpost.http._service import http_server, httptools_server, wsgi_server from localpost.http._types import BodyTooLarge, InformationalResponse, Request from localpost.http._types import Response as NativeResponse +from localpost.http.app import HttpApp from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( Route, @@ -40,6 +41,8 @@ "RouteMatch", "URITemplate", "route_match", + # framework + "HttpApp", # wsgi "wrap_wsgi", # hosting diff --git a/localpost/http/app.py b/localpost/http/app.py new file mode 100644 index 0000000..f1bacfe --- /dev/null +++ b/localpost/http/app.py @@ -0,0 +1,308 @@ +"""Simple application framework on top of LocalPost HTTP server. + +`HttpApp` is the user-friendly layer above :class:`localpost.http.Router` +and the HTTP server. It provides: + +- Decorators (``.get``, ``.post``, ``.put``, ``.delete``, ``.patch``) + that register handlers under a URI template. +- Automatic parameter injection: handler params named to match path + variables get the matched value as ``str``; params annotated as + :data:`localpost.http.HTTPReqCtx` get the request context. +- Automatic response conversion: ``str`` → ``text/plain``, ``bytes`` → + ``application/octet-stream``, ``dict`` / ``list`` → ``application/json``, + :data:`localpost.http.NativeResponse` → as-is, ``None`` → 204. +- Worker-pool dispatch for matched routes via the existing + :func:`localpost.http.thread_pool_handler` machinery — bodies are + buffered into ``ctx.body`` and the user handler runs on a worker. +- App-level and per-route middleware composition via the standard + :data:`localpost.http.Middleware` decorator pattern. + +For lower-level control, drop down to :class:`localpost.http.Router` + +hand-written :data:`localpost.http.RequestHandler` s. + +Example:: + + app = HttpApp() + + @app.get("/{name}") + def hello(name: str): + return f"Hello, {name}!" + + @app.post("/{name}/profile") + def update_profile(ctx: HTTPReqCtx, name: str): + profile = json.loads(ctx.body) + return {"updated": name, "profile": profile} + + sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) +""" + +from __future__ import annotations + +import inspect +import json +import sys +from collections.abc import Callable, Sequence +from http import HTTPMethod +from typing import Any, Literal, get_type_hints + +from localpost import hosting +from localpost.http._pool import thread_pool_handler +from localpost.http._service import http_server, httptools_server +from localpost.http._types import Response as NativeResponse +from localpost.http.config import ServerConfig +from localpost.http.router import Routes, URITemplate, route_match +from localpost.http.server import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose + +__all__ = ["HttpApp"] + + +_ParamResolver = Callable[[HTTPReqCtx], Any] + + +def _build_resolvers(fn: Callable[..., Any], path: str) -> dict[str, _ParamResolver]: + """Inspect ``fn``'s signature once, build a name → resolver dict. + + Resolution rules (checked in order per parameter): + 1. Param name matches a ``{var}`` in the path template → injected + as the matched ``str``. + 2. Annotated as :data:`HTTPReqCtx` → injected as the request ctx. + + Anything else fails at registration time. + """ + template_vars = set(URITemplate.parse(path).variable_names) + sig = inspect.signature(fn) + try: + hints = get_type_hints(fn) + except Exception: # noqa: BLE001 + hints = {} + fn_name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + + resolvers: dict[str, _ParamResolver] = {} + for name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + raise ValueError( + f"handler {fn_name!r}: *args / **kwargs not supported in handler signatures" + ) + if name in template_vars: + resolvers[name] = _make_path_arg_resolver(name) + continue + ann = hints.get(name) + if ann is HTTPReqCtx: + resolvers[name] = _resolve_ctx + continue + raise ValueError( + f"handler {fn_name!r}: cannot resolve parameter {name!r} " + f"(not a path variable {sorted(template_vars)!r}, not annotated as HTTPReqCtx)" + ) + return resolvers + + +def _resolve_ctx(ctx: HTTPReqCtx) -> HTTPReqCtx: + return ctx + + +def _make_path_arg_resolver(name: str) -> _ParamResolver: + return lambda ctx: route_match(ctx).path_args[name] + + +def _wrap_response(value: Any) -> tuple[NativeResponse, bytes]: + """Convert a handler's return value into ``(NativeResponse, body)``. + + Supported shapes: + + - ``str`` — ``200 text/plain; charset=utf-8`` + - ``bytes`` / ``bytearray`` / ``memoryview`` — ``200 application/octet-stream`` + - ``dict`` / ``list`` — ``200 application/json`` (via ``json.dumps``) + - :class:`NativeResponse` — passed through, empty body (caller is + expected to declare ``Content-Length: 0``) + - ``(NativeResponse, bytes)`` tuple — passed through, with body + - ``None`` — ``204 No Content`` + """ + if isinstance(value, NativeResponse): + return value, b"" + if value is None: + return ( + NativeResponse(status_code=204, headers=[(b"content-length", b"0")]), + b"", + ) + if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], NativeResponse): + response, body = value + return response, body if isinstance(body, bytes) else bytes(body) + if isinstance(value, str): + body = value.encode("utf-8") + return _build_response(200, b"text/plain; charset=utf-8", body), body + if isinstance(value, (bytes, bytearray, memoryview)): + body = bytes(value) + return _build_response(200, b"application/octet-stream", body), body + if isinstance(value, (dict, list)): + body = json.dumps(value, separators=(",", ":")).encode("utf-8") + return _build_response(200, b"application/json", body), body + raise TypeError( + f"unsupported return type {type(value).__name__!r} from handler — " + f"return str, bytes, dict, list, NativeResponse, (NativeResponse, bytes), or None" + ) + + +def _build_response(status: int, content_type: bytes, body: bytes) -> NativeResponse: + return NativeResponse( + status_code=status, + headers=[ + (b"content-type", content_type), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + + +_BACKEND_FACTORIES = {"h11": http_server, "httptools": httptools_server} + + +class HttpApp: + """Decorator-driven HTTP app on top of :class:`Router`. + + Args: + max_concurrency: Worker-pool size for body-handler continuations. + Default 32. Set to ``0`` to disable the pool — handlers then + run inline on the selector thread (only viable when every + handler is short and non-blocking). + middleware: App-level middlewares wrapping the entire dispatcher + (after Router). Outermost-first. + """ + + def __init__( + self, + *, + max_concurrency: int = 32, + middleware: Sequence[Middleware] = (), + ) -> None: + if max_concurrency < 0: + raise ValueError("max_concurrency must be >= 0") + self.max_concurrency = max_concurrency + self._middleware = tuple(middleware) + self._routes = Routes() + + # ----- Decorators ----- + + def get( + self, path: str, *, middleware: Sequence[Middleware] = () + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.GET, path, middleware) + + def post( + self, path: str, *, middleware: Sequence[Middleware] = () + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.POST, path, middleware) + + def put( + self, path: str, *, middleware: Sequence[Middleware] = () + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.PUT, path, middleware) + + def delete( + self, path: str, *, middleware: Sequence[Middleware] = () + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.DELETE, path, middleware) + + def patch( + self, path: str, *, middleware: Sequence[Middleware] = () + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return self._decorator(HTTPMethod.PATCH, path, middleware) + + def _decorator( + self, + method: HTTPMethod, + path: str, + middleware: Sequence[Middleware], + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def deco(fn: Callable[..., Any]) -> Callable[..., Any]: + handler = self._wrap_user_handler(fn, path) + if middleware: + handler = compose(*middleware)(handler) + self._routes.add(method, path, handler) + return fn # return original for tests + + return deco + + def _wrap_user_handler(self, fn: Callable[..., Any], path: str) -> RequestHandler: + """Build a :data:`RequestHandler` that resolves params, calls the + user handler post-body, and converts the return value to a wire + response.""" + resolvers = _build_resolvers(fn, path) + + def post_body(ctx: HTTPReqCtx) -> None: + kwargs = {n: r(ctx) for n, r in resolvers.items()} + result = fn(**kwargs) + response, body = _wrap_response(result) + ctx.complete(response, body) + + def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: + return post_body + + return pre_body + + # ----- Building / hosting ----- + + def as_router_handler(self) -> RequestHandler: + """Build the bare Router-as-handler chain — Router + app-level + middleware, no worker pool. + + Useful when you want to compose your own pool / lifecycle around + the dispatcher. For the default hosted setup, use + :meth:`service`. + """ + handler = self._routes.build().as_handler() + if self._middleware: + handler = compose(*self._middleware)(handler) + return handler + + def service( + self, + config: ServerConfig, + *, + backend: Literal["h11", "httptools"] = "h11", + selectors: int = 1, + ): + """Return a :func:`localpost.hosting.service` that runs the app. + + Composes worker pool + chosen backend's server. Use with + :func:`localpost.hosting.run_app` or :func:`localpost.hosting.serve`. + + ``selectors`` is forwarded to the underlying server — see + :func:`localpost.http.http_server` for the multi-selector contract. + """ + if backend not in _BACKEND_FACTORIES: + raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") + server_fn = _BACKEND_FACTORIES[backend] + inner = self.as_router_handler() + max_concurrency = self.max_concurrency + + @hosting.service + async def _app_service(): + if max_concurrency == 0: + # Pool disabled — handlers run inline on the selector thread. + # Body-handler continuations still fire after the selector + # buffers the body; they just don't hop to a worker. + async with server_fn(config, inner, selectors=selectors): + yield + return + async with thread_pool_handler(inner, max_concurrency=max_concurrency) as wrapped: + async with server_fn(config, wrapped, selectors=selectors): + yield + + return _app_service() + + +# ---- Example usage ---------------------------------------------------- + +if __name__ == "__main__": + app = HttpApp() + + @app.get("/{name}") + def hello(name: str): + return f"Hello, {name}!" + + @app.post("/{name}/profile") + def update_user_profile(ctx: HTTPReqCtx, name: str): + profile = json.loads(ctx.body) + return {"updated_for": name, "profile": profile} + + sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) diff --git a/tests/http/app.py b/tests/http/app.py new file mode 100644 index 0000000..9ecb650 --- /dev/null +++ b/tests/http/app.py @@ -0,0 +1,491 @@ +"""Tests for ``localpost.http.app.HttpApp`` — the framework layer. + +Covers decorator registration, parameter injection, response conversion, +middleware (app-level + per-route), and the hosted-service path. +""" + +from __future__ import annotations + +import contextlib +import json +import socket +import threading +import time +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +import anyio +import httpx +import pytest +from anyio import to_thread + +from localpost.hosting import ServiceLifetimeView, serve +from localpost.http import ( + BodyHandler, + HttpApp, + HTTPReqCtx, + Middleware, + NativeResponse, + RequestHandler, + ServerConfig, +) + +pytestmark = pytest.mark.anyio + + +# --- helpers ------------------------------------------------------------- + + +@asynccontextmanager +async def _serve_app(app: HttpApp, cfg: ServerConfig) -> AsyncGenerator[ServiceLifetimeView]: + async with serve(app.service(cfg)) as lt: + yield lt + + +async def _wait_ready(port: int, deadline: float = 5.0) -> bool: + def probe(): + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + return await to_thread.run_sync(probe) + + +async def _get(url: str, **kw) -> httpx.Response: + return await to_thread.run_sync(lambda: httpx.get(url, **kw)) + + +async def _post(url: str, **kw) -> httpx.Response: + return await to_thread.run_sync(lambda: httpx.post(url, **kw)) + + +# --- response conversion ------------------------------------------------- + + +class TestResponseConversion: + async def test_str_returns_text(self, free_port): + app = HttpApp() + + @app.get("/{name}") + def hello(name: str): + return f"Hello, {name}!" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.status_code == 200 + assert r.text == "Hello, world!" + assert r.headers["content-type"] == "text/plain; charset=utf-8" + lt.shutdown() + await lt.stopped + + async def test_dict_returns_json(self, free_port): + app = HttpApp() + + @app.get("/data") + def data(): + return {"x": 1, "y": [2, 3]} + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/data") + assert r.status_code == 200 + assert r.headers["content-type"] == "application/json" + assert r.json() == {"x": 1, "y": [2, 3]} + lt.shutdown() + await lt.stopped + + async def test_none_returns_204(self, free_port): + app = HttpApp() + + @app.get("/empty") + def empty(): + return None + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/empty") + assert r.status_code == 204 + lt.shutdown() + await lt.stopped + + async def test_bytes_returns_octet_stream(self, free_port): + app = HttpApp() + + @app.get("/bin") + def bin_(): + return b"\x00\x01\x02" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/bin") + assert r.status_code == 200 + assert r.content == b"\x00\x01\x02" + assert r.headers["content-type"] == "application/octet-stream" + lt.shutdown() + await lt.stopped + + async def test_native_response_passes_through(self, free_port): + """Handlers can drop to wire-format with NativeResponse. Body must match + the declared Content-Length (or set 0).""" + app = HttpApp() + + @app.get("/raw") + def raw(): + return NativeResponse( + status_code=418, + headers=[(b"content-type", b"text/plain"), (b"content-length", b"0")], + ) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/raw") + assert r.status_code == 418 + assert r.content == b"" + lt.shutdown() + await lt.stopped + + +# --- param injection ----------------------------------------------------- + + +class TestParamInjection: + async def test_path_arg(self, free_port): + app = HttpApp() + + @app.get("/users/{uid}") + def show(uid: str): + return f"u={uid}" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/users/alice") + assert r.text == "u=alice" + lt.shutdown() + await lt.stopped + + async def test_ctx_inject(self, free_port): + app = HttpApp() + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return ctx.body + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _post(f"http://127.0.0.1:{free_port}/echo", content=b"abcdef") + assert r.status_code == 200 + assert r.content == b"abcdef" + lt.shutdown() + await lt.stopped + + async def test_ctx_and_path_arg(self, free_port): + app = HttpApp() + + @app.post("/{name}/profile") + def update_profile(ctx: HTTPReqCtx, name: str): + payload = json.loads(ctx.body) + return {"name": name, "payload": payload} + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _post( + f"http://127.0.0.1:{free_port}/alice/profile", + json={"theme": "dark"}, + ) + assert r.status_code == 200 + assert r.json() == {"name": "alice", "payload": {"theme": "dark"}} + lt.shutdown() + await lt.stopped + + async def test_unresolvable_param_raises_at_registration(self): + app = HttpApp() + with pytest.raises(ValueError, match="cannot resolve parameter"): + + @app.get("/x") + def bad(unresolved: str): + return "x" + + +# --- middleware --------------------------------------------------------- + + +def _add_marker(value: str) -> Middleware: + """Pre-body middleware: writes a marker into ctx.attrs.""" + + def mw(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.attrs.setdefault("markers", []).append(value) + return inner(ctx) + + return wrapped + + return mw + + +def _short_circuit_with_status(status: int, body: bytes) -> Middleware: + def mw(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.complete( + NativeResponse( + status_code=status, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + ), + body, + ) + return None # do not call inner + + return wrapped + + return mw + + +class TestMiddleware: + async def test_app_level_middleware_runs(self, free_port): + captured: list[str] = [] + + def capturing_mw(inner): + def wrapped(ctx): + captured.append("before") + result = inner(ctx) + if result is None: + captured.append("after-inline") + return None + # wrap continuation + def post(ctx): + result(ctx) + captured.append("after-body") + return post + return wrapped + + app = HttpApp(middleware=[capturing_mw]) + + @app.get("/{name}") + def hello(name: str): + return f"hi {name}" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.text == "hi world" + lt.shutdown() + await lt.stopped + + assert captured == ["before", "after-body"] + + async def test_app_middleware_short_circuits_pre_body(self, free_port): + app = HttpApp(middleware=[_short_circuit_with_status(401, b"unauthorized")]) + + @app.get("/{name}") + def hello(name: str): + return f"hi {name}" # pragma: no cover + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.status_code == 401 + assert r.text == "unauthorized" + lt.shutdown() + await lt.stopped + + async def test_per_route_middleware(self, free_port): + app = HttpApp() + + @app.get("/protected", middleware=[_short_circuit_with_status(403, b"forbidden")]) + def protected(): + return "secret" # pragma: no cover + + @app.get("/public") + def public(): + return "open" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r1 = await _get(f"http://127.0.0.1:{free_port}/protected") + assert r1.status_code == 403 + r2 = await _get(f"http://127.0.0.1:{free_port}/public") + assert r2.status_code == 200 + assert r2.text == "open" + lt.shutdown() + await lt.stopped + + async def test_attrs_propagate_pre_to_post_body(self, free_port): + """Middleware writes to ctx.attrs in pre-body; post-body handler reads it.""" + app = HttpApp(middleware=[_add_marker("auth-ok")]) + + @app.post("/echo") + def echo(ctx: HTTPReqCtx): + return { + "markers": ctx.attrs.get("markers", []), + "body": ctx.body.decode("utf-8"), + } + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _post(f"http://127.0.0.1:{free_port}/echo", content=b"hi") + assert r.json() == {"markers": ["auth-ok"], "body": "hi"} + lt.shutdown() + await lt.stopped + + +# --- 404 / 405 ----------------------------------------------------------- + + +class TestNotFoundAndMethodNotAllowed: + async def test_404_when_no_route(self, free_port): + app = HttpApp() + + @app.get("/known") + def known(): + return "x" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/missing") + assert r.status_code == 404 + lt.shutdown() + await lt.stopped + + async def test_405_with_allow_header(self, free_port): + app = HttpApp() + + @app.post("/r") + def post_only(): + return "ok" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/r") + assert r.status_code == 405 + assert r.headers.get("allow") == "POST" + lt.shutdown() + await lt.stopped + + +# --- pool / concurrency -------------------------------------------------- + + +class TestWorkerPool: + async def test_handlers_run_on_worker_threads(self, free_port): + """Default ``max_concurrency=32`` — handlers run on worker threads, + not the selector thread.""" + seen: list[int] = [] + lock = threading.Lock() + + app = HttpApp() + + @app.get("/tid") + def tid(): + with lock: + seen.append(threading.get_ident()) + return "x" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + + async with anyio.create_task_group() as tg: + for _ in range(8): + tg.start_soon(_get, f"http://127.0.0.1:{free_port}/tid") + + assert len(seen) == 8 + assert len(set(seen)) >= 2 # at least two distinct worker threads + + lt.shutdown() + await lt.stopped + + async def test_max_concurrency_zero_runs_inline(self, free_port): + """When the pool is disabled, handlers run on the selector thread.""" + seen: set[int] = set() + lock = threading.Lock() + + app = HttpApp(max_concurrency=0) + + @app.get("/tid") + def tid(): + with lock: + seen.add(threading.get_ident()) + return "x" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + + for _ in range(5): + await _get(f"http://127.0.0.1:{free_port}/tid") + + assert len(seen) == 1 # all on the selector thread + + lt.shutdown() + await lt.stopped + + async def test_invalid_max_concurrency(self): + with pytest.raises(ValueError, match="max_concurrency"): + HttpApp(max_concurrency=-1) + + +# --- backend selection --------------------------------------------------- + + +class TestBackendSelection: + async def test_httptools_backend(self, free_port): + app = HttpApp() + + @app.get("/{name}") + def hello(name: str): + return f"hi {name}" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(app.service(cfg, backend="httptools")) as lt: + await lt.started + await _wait_ready(free_port) + r = await _get(f"http://127.0.0.1:{free_port}/world") + assert r.status_code == 200 + assert r.text == "hi world" + lt.shutdown() + await lt.stopped + + async def test_invalid_backend(self): + app = HttpApp() + cfg = ServerConfig(host="127.0.0.1", port=0) + with pytest.raises(ValueError, match="unknown backend"): + app.service(cfg, backend="bogus") # type: ignore[arg-type] + + +# Avoid "imported but unused" lints — the helper is part of the public smoke API. +_keep = (contextlib,) From ead8131056bd2530394a15838b7679617c55d336 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 22:37:11 +0400 Subject: [PATCH 120/286] feat(http): streaming_pool_handler + buffer_body=False per route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds streaming-mode dispatch for routes that need raw socket access: the handler runs on a worker thread on a *borrowed* connection, and reads the body itself via ``ctx.receive(...)`` chunk by chunk. The selector never buffers the body. Useful for large uploads. Internal refactor of ``localpost.http._pool``: - Extract a private ``_Pool`` primitive (channel + workers + dispatch helpers) and ``_pool_context`` async CM. Both pool helpers now share the same internal machinery. - ``thread_pool_handler`` (existing public API) becomes a thin wrapper: builds a pool, wires ``_Pool.dispatch_buffered`` into the handler chain. Validation of ``max_concurrency >= 1`` moves to the wrapper so prior tests that asserted "ValueError raised at construction time" stay green. - New ``streaming_pool_handler`` public API: builds a pool and wires ``_Pool.dispatch_streaming`` — borrows the conn pre-body, queues the user handler for a worker. HttpApp gains ``buffer_body=False`` per route: - Default (``buffer_body=True``): existing buffered path. Selector reads body into ``ctx.body``, dispatches the BodyHandler to a worker. - Streaming (``buffer_body=False``): handler runs on a worker on a borrowed conn before the body is read; reads via ``ctx.receive``. Streaming routes share the same worker pool as buffered routes. Implementation: HttpApp now defers handler-tree construction to ``service()`` time so the pool is available when wrapping each route. Routes are stored as registrations; ``_build_router_handler(pool)`` walks them and produces the appropriate buffered- or streaming- dispatch wrapper for each. Streaming routes raise ``RuntimeError`` at build time if ``max_concurrency=0`` (no pool to dispatch to). 168 http tests pass (3 new for streaming routes). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/__init__.py | 3 +- localpost/http/_pool.py | 282 +++++++++++++++++++++++++------------ localpost/http/app.py | 202 ++++++++++++++++++-------- tests/http/app.py | 51 +++++++ 4 files changed, 390 insertions(+), 148 deletions(-) diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 30f172d..a7dc396 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,5 +1,5 @@ from localpost.http._cancel import RequestCancelled, check_cancelled -from localpost.http._pool import thread_pool_handler +from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, httptools_server, wsgi_server from localpost.http._types import BodyTooLarge, InformationalResponse, Request from localpost.http._types import Response as NativeResponse @@ -49,6 +49,7 @@ "http_server", "wsgi_server", "thread_pool_handler", + "streaming_pool_handler", # cancellation "check_cancelled", "RequestCancelled", diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 0964e06..c14381e 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -1,21 +1,27 @@ -"""Thread-pool wrapper for HTTP request handlers. +"""Worker-pool wrappers for HTTP request handlers. -Wraps a :data:`RequestHandler` so that, when it returns a -:data:`BodyHandler` continuation, the continuation is dispatched on a -worker thread instead of running on the selector. The pre-body phase -(routing, auth, 404/405) stays on the selector, where it's free; only -post-body work that the user wants offloaded pays the worker hop. +Two public wrappers, both async context managers built on a shared +internal pool primitive: -Compose explicitly with :func:`localpost.http.http_server` — handlers -that complete inline (e.g. a :class:`Router`'s 404/405 path or a -body-free GET) never enter the pool. +- :func:`thread_pool_handler` — wraps a :data:`RequestHandler` so the + :data:`BodyHandler` continuation it returns runs on a worker thread. + The pre-body phase (routing, auth, 404/405) stays on the selector. + This is the right wrapper for the JSON-API common case: body is + buffered into ``ctx.body`` by the selector, the user handler does + the JSON parse + work on the worker. + +- :func:`streaming_pool_handler` — wraps a "raw" handler that runs on + a worker thread on a *borrowed* connection. The body is **not** + buffered up-front; the handler reads it via ``ctx.receive(...)`` + chunk by chunk. Use for streaming uploads / large bodies where + buffering is undesirable. """ from __future__ import annotations import logging import threading -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress from anyio import ( @@ -32,132 +38,126 @@ from localpost.http.config import LOGGER_NAME from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error -__all__ = ["thread_pool_handler"] - +__all__ = ["thread_pool_handler", "streaming_pool_handler"] -def thread_pool_handler( - inner: RequestHandler, - /, - *, - max_concurrency: int, -) -> AbstractAsyncContextManager[RequestHandler]: - """Async context manager: yields a ``RequestHandler`` that offloads - body-handler continuations to a worker thread. - The wrapper invokes ``inner(ctx)`` on the selector thread (pre-body - phase). If ``inner`` returns: +# --- Internal pool primitive -------------------------------------------- - - ``None`` — pass-through. ``inner`` already completed inline (e.g. - a 404 or a body-free GET) or borrowed the conn itself. No worker - hop. - - :data:`BodyHandler` — the wrapper returns a *new* continuation - that, when invoked by the selector after the body has been - buffered, ``stop_tracking`` s the conn and queues - ``(ctx, cancel, inner_continuation)`` on a bounded channel sized - to ``max_concurrency``. ``max_concurrency`` workers pull from the - channel, run ``inner_continuation(ctx)`` under a per-request - cancel scope, and release the connection back to the selector via - ``finish_response`` (or close it on error / cancellation). - Per-request cancellation surfaces through - :func:`localpost.http.check_cancelled`. Two triggers feed it: client - disconnect (via non-blocking ``MSG_PEEK`` on the socket) and pool - shutdown (a single :class:`threading.Event` ORed into every in-flight - token). +_WorkFn = Callable[[HTTPReqCtx], None] +_WorkItem = tuple[HTTPReqCtx, RequestCancel, _WorkFn] - Lifecycle: - * Enter — spawn ``max_concurrency`` workers in a private task group. - * Exit — set the shutdown event so in-flight handlers see cancellation - on their next ``check_cancelled`` call, close the channel, and wait - for workers to drain. - Example:: +class _Pool: + """Shared channel + workers, plus dispatch helpers. - async with thread_pool_handler(router.as_handler(), max_concurrency=8) as h: - async with http_server(config, h): - await current_service().shutting_down.wait() + Created/torn-down by :func:`_pool_context`. Two ``dispatch_*`` methods + let callers wire either a post-body :data:`BodyHandler` or a + pre-body streaming handler onto the same worker pool. """ - if max_concurrency < 1: - raise ValueError("max_concurrency must be >= 1") - return _thread_pool_handler(inner, max_concurrency) + __slots__ = ("_shutdown_event", "_tx") + def __init__( + self, + tx: threadtools.SendChannel[_WorkItem], + shutdown_event: threading.Event, + ) -> None: + self._tx = tx + self._shutdown_event = shutdown_event -_WorkItem = tuple[HTTPReqCtx, RequestCancel, BodyHandler] + def dispatch_buffered(self, body_handler: BodyHandler) -> BodyHandler: + """Wrap a :data:`BodyHandler` so when it's invoked post-body it + borrows the conn and queues for a worker. The worker runs + ``body_handler(ctx)`` with ``ctx.body`` already populated.""" + return self._make_dispatcher(body_handler) + def dispatch_streaming(self, handler: _WorkFn) -> RequestHandler: + """Wrap a streaming handler so it runs in a worker on a borrowed + conn (body **not** pre-buffered). -@asynccontextmanager -async def _thread_pool_handler( - inner: RequestHandler, - max_concurrency: int, -) -> AsyncGenerator[RequestHandler]: - logger = logging.getLogger(LOGGER_NAME) + The returned :data:`RequestHandler` borrows the conn pre-body + and queues ``handler`` for a worker. Worker runs ``handler(ctx)`` + on a blocking-with-timeout socket; the handler reads the body + via ``ctx.receive(...)`` and completes the response.""" + dispatcher = self._make_dispatcher(handler) - tx, rx = threadtools.Channel[_WorkItem].create(capacity=max_concurrency) - shutdown_event = threading.Event() - # Dedicated limiter so workers don't consume slots from AnyIO's global - # default thread pool. Each worker holds a slot for its full lifetime, - # so we need exactly ``max_concurrency`` slots. - workers_limiter = CapacityLimiter(max_concurrency) + def pre_body(ctx: HTTPReqCtx) -> BodyHandler | None: + dispatcher(ctx) + return None # we borrowed; selector free + + return pre_body - def make_dispatch(body_handler: BodyHandler) -> BodyHandler: - """Return a continuation that, given a ctx with body buffered, - borrows the conn and queues the work for a worker.""" + def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: + tx = self._tx + shutdown_event = self._shutdown_event def dispatched(ctx: HTTPReqCtx) -> None: ctx._server.stop_tracking(ctx._conn) cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) try: - tx.put((ctx, cancel, body_handler)) + tx.put((ctx, cancel, fn)) except (ClosedResourceError, BrokenResourceError): with suppress(Exception): ctx._conn.close() return dispatched - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: - """The pre-body :data:`RequestHandler` exposed to the server.""" - result = inner(ctx) - if result is None: - return None # inner completed inline or borrowed itself - return make_dispatch(result) + +@asynccontextmanager +async def _pool_context(max_concurrency: int) -> AsyncGenerator[_Pool]: + """Open a worker pool for ``max_concurrency`` concurrent requests. + + Yields a :class:`_Pool` whose ``dispatch_*`` helpers can be used to + wire handlers onto the shared channel. On exit, signals in-flight + handlers via the cancel event and waits for workers to drain. + """ + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + + logger = logging.getLogger(LOGGER_NAME) + tx, rx = threadtools.Channel[_WorkItem].create(capacity=max_concurrency) + shutdown_event = threading.Event() + # Dedicated limiter so workers don't consume slots from AnyIO's global + # default thread pool. Each worker holds a slot for its full lifetime. + workers_limiter = CapacityLimiter(max_concurrency) def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: with my_rx: while True: try: - ctx, cancel, body_handler = my_rx.get() + ctx, cancel, fn = my_rx.get() except (EndOfStream, ClosedResourceError): return - try: with _enter_request(cancel): try: - body_handler(ctx) + fn(ctx) except RequestCancelled: # Handler bailed cleanly. Connection state is uncertain — close. with suppress(Exception): ctx._conn.close() except Exception: logger.exception( - "Body handler raised for %s %r", ctx.request.method, ctx.request.target + "Pool handler raised for %s %r", + ctx.request.method, + ctx.request.target, ) emit_handler_error(ctx) finally: # Conn-release policy: # - # On success ``finish_response`` already re-tracked the conn via - # ``_maybe_give_back`` — we MUST NOT touch it here. We can't - # read ``ctx._conn.tracked`` either: that field is shared with - # the next request's dispatcher, which clears it via - # ``stop_tracking`` before this finally runs. + # On the success path the handler's ``finish_response`` + # already re-tracked the conn via ``_maybe_give_back`` — + # we MUST NOT touch it here. We can't read + # ``ctx._conn.tracked`` either: that field is shared + # with the next request's dispatcher, which clears it + # via ``stop_tracking`` before this finally runs. # - # The case left to handle is per-request cancellation: the - # handler caught the signal and returned, but the conn is in - # an uncertain state. ``cancel.fired`` is the cheap (no-syscall) - # check — ``is_cancelled`` would actively re-probe the socket - # which is wasteful here. ``emit_handler_error`` already - # closes on the exception path. + # The only case left to handle here is per-request + # cancellation: the handler caught the signal and + # returned, but the conn is in an uncertain state. + # ``cancel.fired`` is the cheap (no-syscall) check. if cancel.fired: with suppress(Exception): ctx._conn.close() @@ -170,10 +170,108 @@ async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: tg.start_soon(run_worker, rx.clone()) rx.close() try: - yield wrapped + yield _Pool(tx, shutdown_event) finally: - # Signal in-flight handlers (next ``check_cancelled`` raises) and - # let workers drain via ``EndOfStream`` from the closed channel. - # The task group's exit waits for all workers to return. shutdown_event.set() tx.close() + + +# --- Public wrappers ---------------------------------------------------- + + +def thread_pool_handler( + inner: RequestHandler, + /, + *, + max_concurrency: int, +) -> AbstractAsyncContextManager[RequestHandler]: + """Async context manager: yields a ``RequestHandler`` that offloads + body-handler continuations to a worker thread. + + The wrapper invokes ``inner(ctx)`` on the selector thread (pre-body + phase). If ``inner`` returns: + + - ``None`` — pass-through. ``inner`` already completed inline (e.g. + a 404 or a body-free GET) or borrowed the conn itself. No worker + hop. + - :data:`BodyHandler` — the wrapper returns a *new* continuation + that, when invoked by the selector after the body has been + buffered, ``stop_tracking`` s the conn and queues the work for a + worker. ``max_concurrency`` workers pull from the channel and + run the continuation under a per-request cancel scope. + + Per-request cancellation surfaces through + :func:`localpost.http.check_cancelled` (client disconnect via + non-blocking ``MSG_PEEK``; pool shutdown via a single + :class:`threading.Event` shared by every in-flight token). + + Example:: + + async with thread_pool_handler(router.as_handler(), max_concurrency=8) as h: + async with http_server(config, h): + ... + """ + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + return _thread_pool_handler(inner, max_concurrency) + + +@asynccontextmanager +async def _thread_pool_handler( + inner: RequestHandler, max_concurrency: int +) -> AsyncGenerator[RequestHandler]: + async with _pool_context(max_concurrency) as pool: + + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + result = inner(ctx) + if result is None: + return None + return pool.dispatch_buffered(result) + + yield wrapped + + +def streaming_pool_handler( + inner: _WorkFn, + /, + *, + max_concurrency: int, +) -> AbstractAsyncContextManager[RequestHandler]: + """Async context manager: yields a ``RequestHandler`` that runs + ``inner`` on a worker thread with a *borrowed* conn — body **not** + pre-buffered. + + Use for streaming uploads / large bodies. The worker reads via + ``ctx.receive(...)`` on a blocking-with-timeout socket, then emits + a response. + + ``inner`` shape: ``(ctx) -> None``. Must complete the response or + close the conn. + + Per-request cancellation surfaces through + :func:`localpost.http.check_cancelled` — same triggers as + :func:`thread_pool_handler`. + + Example:: + + def upload(ctx: HTTPReqCtx) -> None: + with open("/tmp/u", "wb") as f: + while chunk := ctx.receive(8192): + f.write(chunk) + ctx.complete(NativeResponse(204, [(b"content-length", b"0")]), b"") + + async with streaming_pool_handler(upload, max_concurrency=4) as h: + async with http_server(config, h): + ... + """ + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + return _streaming_pool_handler(inner, max_concurrency) + + +@asynccontextmanager +async def _streaming_pool_handler( + inner: _WorkFn, max_concurrency: int +) -> AsyncGenerator[RequestHandler]: + async with _pool_context(max_concurrency) as pool: + yield pool.dispatch_streaming(inner) diff --git a/localpost/http/app.py b/localpost/http/app.py index f1bacfe..9dadf0f 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -10,12 +10,16 @@ :data:`localpost.http.HTTPReqCtx` get the request context. - Automatic response conversion: ``str`` → ``text/plain``, ``bytes`` → ``application/octet-stream``, ``dict`` / ``list`` → ``application/json``, - :data:`localpost.http.NativeResponse` → as-is, ``None`` → 204. -- Worker-pool dispatch for matched routes via the existing - :func:`localpost.http.thread_pool_handler` machinery — bodies are - buffered into ``ctx.body`` and the user handler runs on a worker. -- App-level and per-route middleware composition via the standard - :data:`localpost.http.Middleware` decorator pattern. + :data:`localpost.http.NativeResponse` → as-is, ``(NativeResponse, bytes)`` + tuple → with body, ``None`` → 204. +- Worker-pool dispatch for matched routes. +- Two body modes per route: + - **Buffered (default):** selector buffers the full body into + ``ctx.body``; the handler runs on a worker with body in hand. + - **Streaming (``buffer_body=False``):** the handler runs on a worker + on a borrowed conn *before* the body is read; reads via + ``ctx.receive(...)``. Useful for large uploads. +- App-level and per-route middleware composition. For lower-level control, drop down to :class:`localpost.http.Router` + hand-written :data:`localpost.http.RequestHandler` s. @@ -33,6 +37,14 @@ def update_profile(ctx: HTTPReqCtx, name: str): profile = json.loads(ctx.body) return {"updated": name, "profile": profile} + @app.post("/{name}/avatar", buffer_body=False) + def upload_avatar(ctx: HTTPReqCtx, name: str): + # Streaming: ctx.body is empty here; read chunks via ctx.receive(...) + with open(f"/tmp/{name}.jpg", "wb") as f: + while chunk := ctx.receive(8192): + f.write(chunk) + return ("uploaded", 204) + sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) """ @@ -42,11 +54,12 @@ def update_profile(ctx: HTTPReqCtx, name: str): import json import sys from collections.abc import Callable, Sequence +from dataclasses import dataclass from http import HTTPMethod from typing import Any, Literal, get_type_hints from localpost import hosting -from localpost.http._pool import thread_pool_handler +from localpost.http._pool import _Pool, _pool_context from localpost.http._service import http_server, httptools_server from localpost.http._types import Response as NativeResponse from localpost.http.config import ServerConfig @@ -113,8 +126,7 @@ def _wrap_response(value: Any) -> tuple[NativeResponse, bytes]: - ``str`` — ``200 text/plain; charset=utf-8`` - ``bytes`` / ``bytearray`` / ``memoryview`` — ``200 application/octet-stream`` - ``dict`` / ``list`` — ``200 application/json`` (via ``json.dumps``) - - :class:`NativeResponse` — passed through, empty body (caller is - expected to declare ``Content-Length: 0``) + - :class:`NativeResponse` — passed through, empty body - ``(NativeResponse, bytes)`` tuple — passed through, with body - ``None`` — ``204 No Content`` """ @@ -156,14 +168,27 @@ def _build_response(status: int, content_type: bytes, body: bytes) -> NativeResp _BACKEND_FACTORIES = {"h11": http_server, "httptools": httptools_server} +@dataclass(slots=True) +class _Route: + """One registered route — the inputs needed to wire it at service time.""" + + method: HTTPMethod + path: str + fn: Callable[..., Any] + middleware: tuple[Middleware, ...] + buffer_body: bool + + class HttpApp: """Decorator-driven HTTP app on top of :class:`Router`. Args: - max_concurrency: Worker-pool size for body-handler continuations. - Default 32. Set to ``0`` to disable the pool — handlers then - run inline on the selector thread (only viable when every - handler is short and non-blocking). + max_concurrency: Worker-pool size. Default 32. Set to ``0`` to + disable the pool — buffered handlers then run inline on the + selector thread (only viable when every handler is short + and non-blocking). Streaming routes (``buffer_body=False``) + require a pool — registering one with ``max_concurrency=0`` + raises at service-startup time. middleware: App-level middlewares wrapping the entire dispatcher (after Router). Outermost-first. """ @@ -178,55 +203,88 @@ def __init__( raise ValueError("max_concurrency must be >= 0") self.max_concurrency = max_concurrency self._middleware = tuple(middleware) - self._routes = Routes() + self._routes: list[_Route] = [] # ----- Decorators ----- def get( - self, path: str, *, middleware: Sequence[Middleware] = () + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.GET, path, middleware) + return self._decorator(HTTPMethod.GET, path, middleware, buffer_body) def post( - self, path: str, *, middleware: Sequence[Middleware] = () + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.POST, path, middleware) + return self._decorator(HTTPMethod.POST, path, middleware, buffer_body) def put( - self, path: str, *, middleware: Sequence[Middleware] = () + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.PUT, path, middleware) + return self._decorator(HTTPMethod.PUT, path, middleware, buffer_body) def delete( - self, path: str, *, middleware: Sequence[Middleware] = () + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.DELETE, path, middleware) + return self._decorator(HTTPMethod.DELETE, path, middleware, buffer_body) def patch( - self, path: str, *, middleware: Sequence[Middleware] = () + self, + path: str, + *, + middleware: Sequence[Middleware] = (), + buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.PATCH, path, middleware) + return self._decorator(HTTPMethod.PATCH, path, middleware, buffer_body) def _decorator( self, method: HTTPMethod, path: str, middleware: Sequence[Middleware], + buffer_body: bool, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: def deco(fn: Callable[..., Any]) -> Callable[..., Any]: - handler = self._wrap_user_handler(fn, path) - if middleware: - handler = compose(*middleware)(handler) - self._routes.add(method, path, handler) + # Validate signature eagerly so registration-time errors fire + # at decoration, not at service() time. + _build_resolvers(fn, path) + self._routes.append( + _Route( + method=method, + path=path, + fn=fn, + middleware=tuple(middleware), + buffer_body=buffer_body, + ) + ) return fn # return original for tests return deco - def _wrap_user_handler(self, fn: Callable[..., Any], path: str) -> RequestHandler: - """Build a :data:`RequestHandler` that resolves params, calls the - user handler post-body, and converts the return value to a wire - response.""" - resolvers = _build_resolvers(fn, path) + # ----- Building ----- + + def _build_buffered_handler( + self, route: _Route, pool: _Pool | None + ) -> RequestHandler: + """Buffered route: pre-body returns BodyHandler that, if pooled, + dispatches to a worker; otherwise runs inline on selector.""" + resolvers = _build_resolvers(route.fn, route.path) + fn = route.fn def post_body(ctx: HTTPReqCtx) -> None: kwargs = {n: r(ctx) for n, r in resolvers.items()} @@ -234,25 +292,64 @@ def post_body(ctx: HTTPReqCtx) -> None: response, body = _wrap_response(result) ctx.complete(response, body) - def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: - return post_body + if pool is not None: + dispatched: BodyHandler = pool.dispatch_buffered(post_body) - return pre_body + def pre_body_pooled(_ctx: HTTPReqCtx) -> BodyHandler: + return dispatched - # ----- Building / hosting ----- + return self._with_route_middleware(pre_body_pooled, route.middleware) - def as_router_handler(self) -> RequestHandler: - """Build the bare Router-as-handler chain — Router + app-level - middleware, no worker pool. + def pre_body_inline(_ctx: HTTPReqCtx) -> BodyHandler: + return post_body - Useful when you want to compose your own pool / lifecycle around - the dispatcher. For the default hosted setup, use - :meth:`service`. + return self._with_route_middleware(pre_body_inline, route.middleware) + + def _build_streaming_handler( + self, route: _Route, pool: _Pool + ) -> RequestHandler: + """Streaming route: pre-body borrows + queues for a worker, + which runs the user fn on a blocking conn (body not buffered). """ - handler = self._routes.build().as_handler() + resolvers = _build_resolvers(route.fn, route.path) + fn = route.fn + + def streaming_inner(ctx: HTTPReqCtx) -> None: + kwargs = {n: r(ctx) for n, r in resolvers.items()} + result = fn(**kwargs) + response, body = _wrap_response(result) + ctx.complete(response, body) + + return self._with_route_middleware( + pool.dispatch_streaming(streaming_inner), route.middleware + ) + + def _with_route_middleware( + self, handler: RequestHandler, middleware: tuple[Middleware, ...] + ) -> RequestHandler: + if not middleware: + return handler + return compose(*middleware)(handler) + + def _build_router_handler(self, pool: _Pool | None) -> RequestHandler: + routes = Routes() + for route in self._routes: + if route.buffer_body: + handler = self._build_buffered_handler(route, pool) + else: + if pool is None: + raise RuntimeError( + f"streaming route {route.method.value} {route.path!r} requires " + f"a worker pool (HttpApp(max_concurrency > 0))" + ) + handler = self._build_streaming_handler(route, pool) + routes.add(route.method, route.path, handler) + router = routes.build().as_handler() if self._middleware: - handler = compose(*self._middleware)(handler) - return handler + router = compose(*self._middleware)(router) + return router + + # ----- Hosting ----- def service( self, @@ -265,27 +362,22 @@ def service( Composes worker pool + chosen backend's server. Use with :func:`localpost.hosting.run_app` or :func:`localpost.hosting.serve`. - - ``selectors`` is forwarded to the underlying server — see - :func:`localpost.http.http_server` for the multi-selector contract. """ if backend not in _BACKEND_FACTORIES: raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") server_fn = _BACKEND_FACTORIES[backend] - inner = self.as_router_handler() max_concurrency = self.max_concurrency @hosting.service async def _app_service(): if max_concurrency == 0: - # Pool disabled — handlers run inline on the selector thread. - # Body-handler continuations still fire after the selector - # buffers the body; they just don't hop to a worker. + inner = self._build_router_handler(None) async with server_fn(config, inner, selectors=selectors): yield return - async with thread_pool_handler(inner, max_concurrency=max_concurrency) as wrapped: - async with server_fn(config, wrapped, selectors=selectors): + async with _pool_context(max_concurrency) as pool: + inner = self._build_router_handler(pool) + async with server_fn(config, inner, selectors=selectors): yield return _app_service() diff --git a/tests/http/app.py b/tests/http/app.py index 9ecb650..9effbac 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -487,5 +487,56 @@ async def test_invalid_backend(self): app.service(cfg, backend="bogus") # type: ignore[arg-type] +class TestStreamingRoutes: + async def test_streaming_route_reads_body_in_handler(self, free_port): + """``buffer_body=False`` — handler runs in a worker on a borrowed + conn and reads body chunks via ``ctx.receive(...)``.""" + captured: dict = {} + + app = HttpApp() + + @app.post("/upload", buffer_body=False) + def upload(ctx: HTTPReqCtx): + chunks: list[bytes] = [] + while True: + chunk = ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + full = b"".join(chunks) + captured["body"] = full + captured["thread"] = threading.get_ident() + return f"got {len(full)} bytes" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_app(app, cfg) as lt: + await lt.started + await _wait_ready(free_port) + payload = b"a" * 1024 + r = await _post(f"http://127.0.0.1:{free_port}/upload", content=payload) + assert r.status_code == 200 + assert r.text == "got 1024 bytes" + lt.shutdown() + await lt.stopped + + assert captured["body"] == b"a" * 1024 + # Streaming handler runs on a worker thread, not the main thread. + assert captured["thread"] != threading.get_ident() + + def test_streaming_route_with_pool_disabled_raises(self): + """Registering a streaming route on an HttpApp with ``max_concurrency=0`` + raises ``RuntimeError`` when the dispatcher is built.""" + app = HttpApp(max_concurrency=0) + + @app.post("/upload", buffer_body=False) + def upload(ctx: HTTPReqCtx): # pragma: no cover + return "x" + + assert upload is not None + # Buffered routes work fine without a pool, but streaming ones don't. + with pytest.raises(RuntimeError, match="streaming route"): + app._build_router_handler(None) + + # Avoid "imported but unused" lints — the helper is part of the public smoke API. _keep = (contextlib,) From 04dda43e7dc215c6f57d7e2fcdec813d1cb509ee Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 29 Apr 2026 22:49:00 +0400 Subject: [PATCH 121/286] =?UTF-8?q?docs(http):=20document=20Phase=209=20?= =?UTF-8?q?=E2=80=94=20middleware=20+=20lean=20Router=20+=20HttpApp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ``benchmarks/http/PERF_FINDINGS.md``: new Phase 9 section. Bench: +37% RPS / -26% p50 on httptools plaintext (15,197 -> 20,868 RPS, 4.13ms -> 3.05ms p50) on standard CPython 3.13 / Darwin arm64. Why it pays: lean Router stops constructing per-request ``RequestCtx`` / ``Response`` framework objects; ``HttpApp`` registers the same resolver chain once at decoration time. - ``localpost/http/README.md``: three-layer story (server / Router / HttpApp) at the top; HttpApp Quick start as the recommended path, raw ``start_http_server`` second. - ``CHANGELOG.md``: Added entries for HttpApp, Middleware, ctx.attrs, RouteMatch, route_match, streaming_pool_handler. Changed entries for Router (lean), Router.wsgi removed, openapi paused. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 33 +++++++++++++++ benchmarks/http/PERF_FINDINGS.md | 73 ++++++++++++++++++++++++++++++++ localpost/http/README.md | 46 +++++++++++++++++--- 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b713c3..28022d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`HttpApp` framework** (`localpost.http.app`). Decorator-driven HTTP + app on top of the lean Router. Decorators (`.get`, `.post`, ...), + parameter injection (`HTTPReqCtx` + path args matched by name), + response conversion (str / bytes / dict / list / `NativeResponse` / + `(NativeResponse, bytes)` / `None`), worker-pool dispatch, and + app-level + per-route middleware. New `app.service(config)` factory + for hosting integration. See `localpost/http/README.md` for usage. +- **HTTP middleware support.** New `localpost.http.Middleware` type + (`Callable[[RequestHandler], RequestHandler]`) and a `compose(*mws)` + helper. Plain Python decorator pattern — wrap pre-body, wrap the + returned `BodyHandler` for post-body work. Used by `HttpApp` for + app-level / per-route composition. +- **`HTTPReqCtx.attrs`** — `dict[str, Any]` mutable per-request state + on the Protocol. Used by `Router` to attach `RouteMatch`, available + for middlewares to thread auth / tracing / rate-limit state. +- **`RouteMatch` dataclass** + **`route_match(ctx)` accessor** — the + matched route info Router writes into `ctx.attrs["route_match"]`. +- **`streaming_pool_handler`** — async CM that runs a handler in a + worker on a borrowed conn (body **not** pre-buffered). Pair with + `HttpApp`'s `buffer_body=False` for streaming uploads. - **Two-phase HTTP request handler contract.** `RequestHandler` is now `Callable[[HTTPReqCtx], BodyHandler | None]`. The pre-body handler runs on the selector thread when headers are parsed and may either complete @@ -79,6 +99,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 served sequentially — correct, but no parallelism on the same connection. The httptools backend's `_ready` deque was removed for the simplification. +- **`Router` is now a lean dispatcher**, not a self-contained framework. + Removed `RequestCtx`, `Response`, `RequestHandler` (the + `(RequestCtx) -> Response` shape) from `localpost.http.router`. + `Router.as_handler()` returns a plain `localpost.http.RequestHandler` + that attaches a `RouteMatch` to `ctx.attrs["route_match"]` and + delegates to the registered http-level handler. **`Router.wsgi` is + removed** — pair with `localpost.http.wrap_wsgi` if you need WSGI + output. Pythonic helpers (decorators, response conversion, param + injection) move to the new `HttpApp`. See `PERF_FINDINGS.md` Phase 9 + — restructure delivers another +35% RPS on the bench's hot path. +- **`localpost.experimental.openapi` is paused.** It was built on the + old Router shape; the new lean dispatcher leaves it broken at + import time. To be revived against `HttpApp` in a follow-up. - **`localpost.http` no longer leaks h11 types into the public API.** `HTTPReqCtx.request` is now `localpost.http.Request` (was `h11.Request`); `HTTPReqCtx.start_response` and `complete` accept diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index a5c7498..271179c 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -821,6 +821,79 @@ from `ctx.body`). All in-tree adapters (`Router.as_handler`, `sentry_flask_handler`) have been updated; user code that bypassed those needs the same shape. +## Phase 9 (shipped): middleware + lean Router + HttpApp framework (2026-04-29) + +Architectural restructure across the http stack: + +1. **Middleware support.** New ``Middleware = Callable[[RequestHandler], + RequestHandler]`` type and a ``compose(*mws)`` helper. Plain Python + decorator pattern — no special chain object. Pre-body short-circuit + and post-body wrapping (via the returned BodyHandler) both work + naturally. +2. **HTTPReqCtx.attrs.** New ``dict[str, Any]`` per-request mutable + state on the Protocol. Used by the Router to attach ``RouteMatch`` + and by middlewares to thread cross-cutting state (auth, tracing). +3. **Router stripped to a lean middleware-friendly dispatcher.** The + framework-y ``RequestCtx`` / ``Response`` / ``RequestHandler`` shapes + that lived inside ``router.py`` are gone. ``Router.as_handler()`` + now matches the URI template, attaches a ``RouteMatch`` to + ``ctx.attrs["route_match"]``, and delegates to a registered + http-level :data:`localpost.http.RequestHandler`. 404 / 405 stay + inline. ``Router.wsgi`` is dropped. +4. **New ``HttpApp`` framework** (``localpost.http.app``). Decorator- + driven, with parameter injection (``HTTPReqCtx`` + path args by + name), automatic response conversion (str / bytes / dict / list / + ``NativeResponse`` / ``(NativeResponse, bytes)`` / ``None``), + app-level + per-route middleware composition, and a ``service()`` + factory that composes the worker pool + chosen backend. +5. **``streaming_pool_handler`` + ``buffer_body=False`` per route.** + For routes that need raw socket access (large uploads), the handler + runs on a worker on a borrowed conn — body **not** pre-buffered. + Reads via ``ctx.receive(...)`` chunk by chunk. Internal pool + primitive (``_Pool``) shared by both buffered and streaming dispatch. + +### Bench (8 s/cell, standard CPython 3.13 / Darwin arm64) + +| Scenario | Phase 8 RPS / p50 | Phase 9 RPS / p50 | Δ RPS | +| ----------------- | ---------------------: | ---------------------: | -------: | +| `httptools` plaintext | 15,197 / 4.13 ms | **20,868 / 3.05 ms** | **+37%** | +| `httptools` path_param | 15,312 / 4.13 ms | **20,667 / 3.08 ms** | **+35%** | +| `httptools` json_post | 15,051 / 2.11 ms | **20,152 / 1.58 ms** | **+34%** | +| `httptools` profile_update | 5,384 / 5.95 ms | 6,286 / 5.09 ms | +17% | + +p50 collapses ~25% across the fast scenarios. ``profile_update`` is +handler-CPU-dominated (4 ms of `time.sleep`), so the framework +overhead saving shows up as a smaller relative gain. + +### Why this delivers another ~35% + +The lean Router stops constructing per-request the framework objects +the old shape needed: ``RequestCtx`` (with its ``ExitStack``, headers +dict, query parse, receive shim, body cache) and ``Response`` plus +the wire-bytes encoding of its headers / iter-body. + +Now ``Router.as_handler`` is essentially: regex match + ``ctx.attrs[] +=`` + delegate. The handler chosen by the route is a regular +``RequestHandler``; if registered through ``HttpApp`` it gets the +auto-buffered Phase 8 response path with one more layer of param +resolution. + +For the bench specifically, the handlers return +``(NativeResponse, bytes)`` tuples — pre-baked wire shapes, so we +skip the str/dict→bytes conversion path. Real apps using ``str`` / +``dict`` returns will sit slightly below this number; the Pythonic +return paths are still leaner than the old Router framework. + +### One-time API breaks + +- ``Router.wsgi`` removed. +- Router-level ``RequestCtx``, ``Response``, ``RequestHandler`` (the + ``(RequestCtx) -> Response`` shape) gone. Migrate to ``HttpApp`` or + use the lean http-level :data:`localpost.http.RequestHandler` shape + directly. +- ``localpost.experimental.openapi`` paused — it was built against + the old Router shape; revive against ``HttpApp`` in a future PR. + ## What's left for future perf work Within the [Optimisation boundaries](#optimisation-boundaries) at the top of diff --git a/localpost/http/README.md b/localpost/http/README.md index 6ed133b..f52312f 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -3,12 +3,20 @@ > **Status:** stable — public API is not expected to break in patch/minor releases. A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), -plus a router for URI-template-based request dispatch, and a WSGI bridge. The -server core is ~540 lines of focused, sync code — easy to read, easy to embed. +plus a URI-template router, a WSGI bridge, and a small framework +(`HttpApp`) on top. Three layers, each usable on its own: -Pair it with `localpost.hosting` for lifecycle management, or run it standalone. -For OpenAPI / content negotiation / validation, see -[`localpost.experimental.openapi`](../experimental/openapi/README.md). +- **Server**: `start_http_server` (h11) / `start_httptools_server` + (httptools) accept connections, parse HTTP, dispatch to a + `RequestHandler`. ~540 lines of sync code. +- **Router**: thin URI-template dispatcher. Matches the request, + attaches a `RouteMatch` to `ctx.attrs["route_match"]`, delegates to + the registered handler. 404 / 405 inline. +- **HttpApp**: decorator-driven framework — parameter injection, + response conversion, worker-pool dispatch, middleware. + +Pair with `localpost.hosting` for lifecycle management, or run any of +the three layers standalone. ## Scope and constraints @@ -58,6 +66,34 @@ pip install localpost[http-server,http-fast] # also adds the httptools backend ## Quick start +The recommended path is `HttpApp`: + +```python +import sys +from localpost.hosting import run_app +from localpost.http import HttpApp, HTTPReqCtx, ServerConfig + + +app = HttpApp() + + +@app.get("/{name}") +def hello(name: str): + return f"Hello, {name}!" + + +@app.post("/{name}/profile") +def update_profile(ctx: HTTPReqCtx, name: str): + import json + profile = json.loads(ctx.body) + return {"updated": name, "profile": profile} + + +sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) +``` + +Or stay close to the wire — `start_http_server` directly: + ```python import h11 from localpost.http.config import ServerConfig From 54658127e1eedbd015ae77e6982f2ebd5ebdfdbb Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 00:45:48 +0400 Subject: [PATCH 122/286] perf(http): drop per-request settimeout, send via non-blocking + fallback (Phase 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections stay non-blocking for their lifetime. The two ``sock.settimeout`` (fcntl) calls per request that the prior borrow / re-track boundary paid are gone: - ``BaseServer.stop_tracking`` no longer flips the socket to blocking-with-timeout when borrowing. - ``BaseServer.track`` no longer flips back to non-blocking when re-registering. - One-time ``setblocking(False)`` after ``accept`` covers macOS / BSD, where accepted sockets don't inherit ``O_NONBLOCK`` from the listener (Linux 2.6.28+ does). - New ``_send_all`` helper in ``_base.py``: tries non-blocking ``send`` first; on ``BlockingIOError`` (kernel buffer full mid-response), transitions to blocking-with-timeout for the remainder, then restores non-blocking. Common-case JSON-API responses fit in the kernel buffer in a single ``send`` — fallback doesn't fire. - Both backends (h11 and httptools) route every send through the helper; the response paths in ``HTTPReqCtx`` for both backends, the cleanup paths (``emit_stale_408``, ``_try_send_status``), and h11's internal ``HTTPConnH11.send`` all consolidated on it. Bench (8 s/cell, standard CPython 3.13 / Darwin arm64): httptools plaintext: 20,868 -> 25,230 RPS (+21%) p50 3.05 -> 2.50 ms httptools path_param: 20,667 -> 25,247 RPS (+22%) p50 3.08 -> 2.51 ms httptools json_post: 20,152 -> 24,712 RPS (+23%) p50 1.58 -> 1.28 ms h11 plaintext: ~10,012 -> 13,225 RPS (+32%) p50 6.28 -> 4.81 ms h11 json_post: ~9,550 -> 12,432 RPS (+30%) p50 3.30 -> 2.55 ms Larger than projected (~3-5%): on Darwin, ``settimeout`` is more expensive than the textbook ~1 us — closer to 5-7 us under realistic load. Two saved per request at 25 k RPS = 250-350 ms/sec of CPU reclaimed. Validates the hypothesis: conn stays non-blocking, ``send`` relies on the kernel buffer absorbing small responses; fallback is rare and only costs the syscalls when we genuinely need them. The "never unregister from selector" variant was considered and dropped — synchronisation against pipelined clients needs *something*, and the kqueue/epoll unregister is the cheapest correct mechanism. 168/168 http tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 ++++ benchmarks/http/PERF_FINDINGS.md | 52 ++++++++++++++++++++++++++ localpost/http/_base.py | 59 ++++++++++++++++++++++++------ localpost/http/server_h11.py | 24 ++++-------- localpost/http/server_httptools.py | 33 ++++++++++++----- 5 files changed, 136 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28022d6..8a1c5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Per-request ``settimeout`` calls dropped from the borrow boundary.** + The conn stays non-blocking for its lifetime; the worker's send path + uses a non-blocking ``send`` with a blocking-with-timeout fallback on + ``BlockingIOError``. New ``_send_all`` helper in + ``localpost.http._base``. Saves two fcntl per request — see + ``benchmarks/http/PERF_FINDINGS.md`` Phase 10 (+21-32% RPS on the + bench's hot path). - **`HttpApp` framework** (`localpost.http.app`). Decorator-driven HTTP app on top of the lean Router. Decorators (`.get`, `.post`, ...), parameter injection (`HTTPReqCtx` + path args matched by name), diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 271179c..1f22c83 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -894,6 +894,58 @@ return paths are still leaner than the old Router framework. - ``localpost.experimental.openapi`` paused — it was built against the old Router shape; revive against ``HttpApp`` in a future PR. +## Phase 10 (shipped): drop per-request settimeout (2026-04-30) + +Removed the two ``sock.settimeout`` (fcntl) calls per request that the +borrow / re-track boundary used to pay: + +- ``BaseServer.stop_tracking`` no longer flips the socket to + blocking-with-timeout. The conn stays non-blocking after the worker + borrows it. +- ``BaseServer.track`` no longer flips back to non-blocking. It's a + no-op on socket flags now; only the kernel-level + ``selector.register/modify`` op fires. +- New ``_send_all`` helper in ``_base.py``: tries non-blocking + ``send`` first; on ``BlockingIOError`` (kernel buffer full + mid-response), transitions to blocking-with-timeout for the + remainder, then restores non-blocking. Common-case JSON-API + responses fit in the kernel buffer in a single ``send`` — no + fallback fires. +- One-time ``setblocking(False)`` after ``accept`` (macOS / BSD don't + inherit ``O_NONBLOCK`` from the listener; Linux 2.6.28+ does). + +### Bench (8 s/cell, standard CPython 3.13 / Darwin arm64) + +| Scenario | Phase 9 RPS / p50 | Phase 10 RPS / p50 | Δ RPS | +| ----------------- | ---------------------: | ---------------------: | -------: | +| `httptools` plaintext | 20,868 / 3.05 ms | **25,230 / 2.50 ms** | **+21%** | +| `httptools` path_param | 20,667 / 3.08 ms | **25,247 / 2.51 ms** | **+22%** | +| `httptools` json_post | 20,152 / 1.58 ms | **24,712 / 1.28 ms** | **+23%** | +| `httptools` profile_update | 6,286 / 5.09 ms | 6,311 / 5.08 ms | +0% | +| `h11` plaintext | ~10,012 / 6.28 ms | **13,225 / 4.81 ms** | **+32%** | +| `h11` json_post | ~9,550 / 3.30 ms | **12,432 / 2.55 ms** | **+30%** | + +Bigger than the 3-5% I'd projected — the per-request cost of +``settimeout`` on Darwin is heavier than expected (each fcntl ~5-7 µs +under realistic load, not the textbook ~1 µs). At 25k RPS, two fcntl +saved per request is ~250-350 ms/sec of selector / worker CPU +reclaimed. + +p50 collapses ~17-19% across the fast scenarios. ``profile_update`` +remains handler-bound (~4 ms of ``time.sleep`` dominates). + +### What this validates + +The user's hypothesis: **conn stays non-blocking; ``send`` relies on +the kernel buffer absorbing small responses, falls back only on +``BlockingIOError``.** The fallback path is rare in JSON-API +workloads and the savings on the common path are real. + +The "never borrow" variant (don't even ``selector.unregister``) was +considered and dropped — synchronisation against pipelined clients +needs *something*, and the unregister is the cheapest correct +mechanism (~1 µs each on macOS kqueue). + ## What's left for future perf work Within the [Optimisation boundaries](#optimisation-boundaries) at the top of diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 365c8c6..201abe4 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -214,6 +214,36 @@ def wrap(handler: RequestHandler) -> RequestHandler: return wrap +def _send_all(sock: socket.socket, payload: bytes | bytearray | memoryview, rw_timeout: float) -> None: + """Send all of ``payload`` to ``sock``. + + Optimised for the JSON-API common case: a small response that fits in + the kernel send buffer. Tries non-blocking ``send`` first; on + :exc:`BlockingIOError` (kernel buffer full mid-response) transitions + to blocking-with-timeout for the remainder, then restores + non-blocking on the way out. Saves the two ``settimeout`` (fcntl) + calls per request that the prior borrow-with-blocking design paid. + """ + view = payload if isinstance(payload, memoryview) else memoryview(payload) + total = len(view) + sent = 0 + while sent < total: + try: + n = sock.send(view[sent:]) + except BlockingIOError: + # Kernel send buffer full; finish the rest in blocking-with-timeout + # mode, then restore non-blocking. + sock.settimeout(rw_timeout) + try: + sock.sendall(view[sent:]) + finally: + sock.settimeout(0) + return + if n == 0: + raise ConnectionAbortedError("socket is broken") + sent += n + + def emit_handler_error(ctx: HTTPReqCtx) -> None: """Best-effort recovery when a request handler raises. @@ -431,16 +461,16 @@ def track(self, conn: BaseHTTPConn) -> None: ``self._ops`` at the top of the next iteration). ``conn.tracked`` is flipped optimistically so concurrent readers see the intended state. + + The socket is kept non-blocking throughout the connection's + lifetime — see :func:`_send_all` for the worker-side send path + that handles ``BlockingIOError`` with a blocking-with-timeout + fallback. This avoids two ``settimeout`` (fcntl) calls per + request on the borrow / re-track boundary. """ - sock = conn.sock - try: - sock.settimeout(0) - except OSError: - conn.tracked = False - return if self.shutting_down: try: - sock.close() + conn.sock.close() except OSError: pass conn.tracked = False @@ -458,18 +488,17 @@ def stop_tracking(self, conn: BaseHTTPConn) -> None: """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. Selector-thread only (called from the dispatcher inside ``BaseServer.run``'s - for-event loop). Switches the socket to blocking-with-timeout - (``rw_timeout``) so the worker can do synchronous send/recv. + for-event loop). Socket stays non-blocking — the worker's send + path (:func:`_send_all`) handles partial writes and falls back + to blocking-with-timeout only when the kernel buffer fills. Client-disconnect detection while the conn is borrowed lives in :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). """ - sock = conn.sock try: - self.selector.unregister(sock) + self.selector.unregister(conn.sock) except (KeyError, ValueError): pass conn.tracked = False - sock.settimeout(self.config.rw_timeout) # ----- Op queue + self-pipe helpers ----- @@ -610,6 +639,12 @@ def run(self, *, timeout: float | None = None) -> None: continue if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() + # Linux 2.6.28+ inherits ``O_NONBLOCK`` from the listening + # socket; macOS / BSD do not. Set explicitly — once per + # connection, never again. The conn stays non-blocking for + # its lifetime; the send path (``_send_all``) handles + # blocking-with-timeout fallback on ``BlockingIOError``. + client_sock.setblocking(False) conn = self._conn_factory(self, client_sock, client_addr) self.track(conn) continue diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 9504944..ee7e5cc 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -38,6 +38,7 @@ BaseServer, BodyHandler, RequestHandler, + _send_all, emit_handler_error, start_http_server_base, ) @@ -109,13 +110,7 @@ def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11. payload = self.parser.send(event) if payload is None: return - payload_len = len(payload) - sock, total_sent = self.sock, 0 - while total_sent < payload_len: - sent = sock.send(payload[total_sent:]) - if sent == 0: - raise ConnectionAbortedError("socket is broken") - total_sent = total_sent + sent + _send_all(self.sock, payload, self.server.config.rw_timeout) def __call__(self, h: RequestHandler, /) -> None: try: @@ -257,16 +252,16 @@ def emit_stale_408(self) -> None: if self.idle or self.parser.our_state is not h11.IDLE: return try: - self.sock.settimeout(self.server.config.rw_timeout) + rw = self.server.config.rw_timeout payload = self.parser.send(_to_h11_response(REQUEST_TIMEOUT_RESPONSE)) if payload: - self.sock.sendall(payload) + _send_all(self.sock, payload, rw) payload = self.parser.send(h11.Data(data=REQUEST_TIMEOUT_BODY)) if payload: - self.sock.sendall(payload) + _send_all(self.sock, payload, rw) payload = self.parser.send(h11.EndOfMessage()) if payload: - self.sock.sendall(payload) + _send_all(self.sock, payload, rw) except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway pass @@ -410,12 +405,7 @@ def finish_response(self) -> None: self._maybe_give_back() def _sock_sendall(self, payload: bytes) -> None: - sock, total_sent, payload_len = self._conn.sock, 0, len(payload) - while total_sent < payload_len: - sent = sock.send(payload[total_sent:]) - if sent == 0: - raise ConnectionAbortedError("socket is broken") - total_sent += sent + _send_all(self._conn.sock, payload, self._server.config.rw_timeout) def start_http_server(config: ServerConfig, handler: RequestHandler, /): diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 4ffe390..0173309 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -49,6 +49,7 @@ BaseServer, BodyHandler, RequestHandler, + _send_all, emit_handler_error, start_http_server_base, ) @@ -344,8 +345,11 @@ def _try_send_status(self, response: Response, body: bytes) -> None: if self._response_started: return try: - self.sock.settimeout(self.server.config.rw_timeout) - self.sock.sendall(_serialize_response(response) + body) + _send_all( + self.sock, + _serialize_response(response) + body, + self.server.config.rw_timeout, + ) except Exception: # noqa: BLE001, S110 — connection is being closed anyway pass @@ -354,8 +358,11 @@ def emit_stale_408(self) -> None: if self.idle or self._response_started: return try: - self.sock.settimeout(self.server.config.rw_timeout) - self.sock.sendall(_serialize_response(REQUEST_TIMEOUT_RESPONSE) + REQUEST_TIMEOUT_BODY) + _send_all( + self.sock, + _serialize_response(REQUEST_TIMEOUT_RESPONSE) + REQUEST_TIMEOUT_BODY, + self.server.config.rw_timeout, + ) except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway pass @@ -444,7 +451,11 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: sock.settimeout(0) def _send_continue(self) -> None: - self._conn.sock.sendall(b"HTTP/1.1 100 Continue\r\n\r\n") + _send_all( + self._conn.sock, + b"HTTP/1.1 100 Continue\r\n\r\n", + self._server.config.rw_timeout, + ) self._continue_sent = True def start_response(self, response: Response | InformationalResponse, /) -> None: @@ -470,7 +481,7 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: self._pending_header_bytes = _serialize_response(response) else: # Informational responses (e.g., 100 Continue) flush immediately. - self._conn.sock.sendall(_serialize_response(response)) + _send_all(self._conn.sock, _serialize_response(response), self._server.config.rw_timeout) def send(self, chunk: Buffer, /) -> None: if not isinstance(chunk, bytes): @@ -478,22 +489,24 @@ def send(self, chunk: Buffer, /) -> None: if not chunk and self._pending_header_bytes is None: return framed = (f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n") if self._chunked and chunk else chunk + rw = self._server.config.rw_timeout if self._pending_header_bytes is not None: # Headers + first body chunk in one syscall. - self._conn.sock.sendall(self._pending_header_bytes + framed) + _send_all(self._conn.sock, self._pending_header_bytes + framed, rw) self._pending_header_bytes = None elif framed: - self._conn.sock.sendall(framed) + _send_all(self._conn.sock, framed, rw) def finish_response(self) -> None: # Flush any still-buffered headers (empty-body case) plus the # chunked terminator, in one sendall. terminator = b"0\r\n\r\n" if self._chunked else b"" + rw = self._server.config.rw_timeout if self._pending_header_bytes is not None: - self._conn.sock.sendall(self._pending_header_bytes + terminator) + _send_all(self._conn.sock, self._pending_header_bytes + terminator, rw) self._pending_header_bytes = None elif terminator: - self._conn.sock.sendall(terminator) + _send_all(self._conn.sock, terminator, rw) self._maybe_give_back() From 7484b842ea206661a360ce26a1abe04ad002b454 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 01:59:16 +0400 Subject: [PATCH 123/286] =?UTF-8?q?fix(http):=20streaming=20routes=20?= =?UTF-8?q?=E2=80=94=20feed=20body=20bytes=20through=20the=20parser=20(htt?= =?UTF-8?q?ptools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings httptools streaming inline with h11's design: the parser is the single source of truth for body bytes. ``on_body`` callbacks (fired from ``parser.feed_data`` regardless of which thread is calling) populate a per-conn buffer; the worker drains it via ``ctx.receive`` and pulls more bytes through the parser on demand. The bug, surfaced by the new ``test_streaming_route_reads_body_httptools`` test: when a client piggybacked body bytes with the request line in a single TCP segment (the common case without ``Expect: 100-continue``), the selector's ``parser.feed_data`` consumed those bytes via ``on_body``, which the prior code dropped because ``_continuation`` was None for streaming-pool dispatch. The worker's ``ctx.receive`` did raw ``sock.recv`` and saw only bytes that arrived after the borrow, losing the body. Fix (``server_httptools.py``): - New per-conn fields ``_streaming_active``, ``_streaming_body_buf``, ``_streaming_eom``. Activated when ``on_headers_complete`` returns None and the conn was borrowed (streaming-pool dispatch happened). - ``on_body`` appends to ``_streaming_body_buf`` when streaming. ``on_message_complete`` flips ``_streaming_eom``. - ``HTTPReqCtxHttptools.receive`` drains the buffer; if empty and not EOM, calls ``sock.recv`` and feeds the bytes through ``parser.feed_data`` so the same ``on_body`` populates the buffer. - ``_reset_for_next_request`` clears the streaming state. No parser replace needed — the parser saw every body byte (some via selector's feed, the rest via worker's), so its state is consistent with the wire when the next request arrives on a keep-alive conn. Plus parser thread-safety documentation (``HTTPConnH11.parser`` and ``HTTPConnHttptools.parser`` field docstrings, plus ``BaseServer.track`` / ``stop_tracking`` cross-references): the parser is shared between selector and worker but **never accessed concurrently** — strict handoff via ``stop_tracking`` (selector → worker) and ``track`` (worker → selector). The op-queue + wakeup-pipe ``os.write`` is a full memory barrier across the handoff. Tests (``tests/http/app.py::TestStreamingRoutes``): - ``test_streaming_route_reads_body_httptools`` — body fits in initial recv. Was xfail; now passes. - ``test_streaming_route_body_across_multiple_recvs_httptools`` — body arrives across multiple TCP writes; verifies the worker pulls more bytes through the parser via ``ctx.receive``. - ``test_streaming_then_keep_alive_request_httptools`` — POST upload followed by GET on the same conn; verifies parser state stays consistent. 174 http tests pass (3 new). h11 untouched — its streaming path was already correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_base.py | 13 +++ localpost/http/server_h11.py | 13 +++ localpost/http/server_httptools.py | 118 ++++++++++++++++++---- tests/http/app.py | 157 +++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 18 deletions(-) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 201abe4..f24b765 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -467,6 +467,13 @@ def track(self, conn: BaseHTTPConn) -> None: that handles ``BlockingIOError`` with a blocking-with-timeout fallback. This avoids two ``settimeout`` (fcntl) calls per request on the borrow / re-track boundary. + + **Synchronisation edge for parser ownership.** This method's + op-queue enqueue + wakeup-pipe ``os.write`` (in :meth:`_wake`) + is a full memory barrier: anything the worker did to the conn's + parser (``parser.send`` for h11; ``parser.feed_data`` callbacks + for httptools streaming) is visible to the selector after this + call. """ if self.shutting_down: try: @@ -493,6 +500,12 @@ def stop_tracking(self, conn: BaseHTTPConn) -> None: to blocking-with-timeout only when the kernel buffer fills. Client-disconnect detection while the conn is borrowed lives in :func:`localpost.http.check_cancelled` (pull-based ``MSG_PEEK``). + + **Synchronisation edge for parser ownership.** Once this returns, + the selector is done with the conn's parser (h11.Connection / + httptools.HttpRequestParser); the worker has exclusive access + until :meth:`track` re-registers. See the parser field's + docstring on each backend for the full invariant. """ try: self.selector.unregister(conn.sock) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index ee7e5cc..93ec623 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -77,6 +77,19 @@ class HTTPConnH11(BaseHTTPConn): ``sock.fileno()`` returns -1).""" recv_closed: bool = False parser: h11.Connection = field(default_factory=lambda: h11.Connection(h11.SERVER)) + """The h11 state machine — used for **both** parsing the request + (``parser.next_event`` / ``parser.receive_data``) and serialising + the response (``parser.send``). + + **Single-thread invariant.** The selector owns the parser from + ``__call__`` entry until ``stop_tracking`` (in the + :data:`BodyHandler` dispatcher); the worker owns it from then until + ``track`` re-registers the conn. The op-queue / wakeup-pipe + handoff in :class:`localpost.http._base.BaseServer` is the + synchronisation edge — `os.write` to the wakeup pipe is a full + memory barrier across threads. The parser is **never** touched + concurrently from two threads. + """ close_at: float | None = None tracked: bool = False body_bytes_received: int = 0 diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 0173309..14469a9 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -122,6 +122,22 @@ class HTTPConnHttptools(BaseHTTPConn): addr: tuple[str, int] fd: int = field(init=False) parser: httptools.HttpRequestParser = field(init=False) + """The httptools (llhttp) parser — parse-only on our side; the + response wire bytes are hand-built via ``_serialize_response`` + plus ``_send_all`` (no parser involved on the response path). + + **Single-thread invariant.** The selector owns the parser during + ``parser.feed_data`` (and the callbacks it fires) for the + request-headers phase; on streaming routes (``buffer_body=False``) + the worker also calls ``parser.feed_data`` from inside + ``ctx.receive`` to drain remaining body bytes. The op-queue / + wakeup-pipe handoff in + :class:`localpost.http._base.BaseServer` is the synchronisation + edge — `os.write` to the wakeup pipe is a full memory barrier. + The parser is **never** touched concurrently from two threads; + ownership is strict (selector → worker on ``stop_tracking``; + worker → selector on ``track``). + """ close_at: float | None = None tracked: bool = False idle: bool = True @@ -142,6 +158,17 @@ class HTTPConnHttptools(BaseHTTPConn): _message_complete: bool = False _body_too_large: int | None = None + # Streaming mode (``buffer_body=False``): the pre-body handler borrowed + # the conn before the body was buffered. Selector + worker both feed + # bytes through ``parser.feed_data``; ``on_body`` populates + # ``_streaming_body_buf`` regardless of which thread is feeding. The + # worker drains the buffer via ``ctx.receive``. Same model as h11 + # (``next_event``-driven), just emulated with httptools' push + # callbacks. + _streaming_active: bool = False + _streaming_body_buf: bytearray = field(default_factory=bytearray) + _streaming_eom: bool = False + # Set when the response indicates ``Connection: close`` or the request lacked # keep-alive support — the conn is closed once finish_response returns. _close_after_response: bool = False @@ -224,7 +251,14 @@ def on_headers_complete(self) -> None: result = None if result is None: - return # done — body bytes (if any) are drained silently below + # Either the handler completed inline OR a streaming + # pool dispatched and borrowed the conn. Detect the latter + # so on_body / on_message_complete know to populate the + # streaming body buffer (drained by the worker via + # ``ctx.receive``) instead of dropping bytes. + if not self.tracked: + self._streaming_active = True + return # Continuation returned: we'll buffer the body and invoke it on # ``on_message_complete``. If the client sent ``Expect: 100-continue``, @@ -239,6 +273,16 @@ def on_headers_complete(self) -> None: pass def on_body(self, data: bytes) -> None: + if self._streaming_active: + # Streaming mode: append to the worker-drained buffer regardless + # of which thread is currently inside ``feed_data``. ``ctx.receive`` + # drains it (and pulls more bytes from the socket if needed). + new_total = len(self._streaming_body_buf) + len(data) + if new_total > self.server.config.max_body_size: + self._body_too_large = new_total + return + self._streaming_body_buf += data + return if self._continuation is None or self._cur_oversize: return # no body wanted (handler done) or already oversize new_total = len(self._body_buf) + len(data) @@ -248,6 +292,10 @@ def on_body(self, data: bytes) -> None: self._body_buf += data def on_message_complete(self) -> None: + if self._streaming_active: + self._streaming_eom = True + self._message_complete = True + return if self._continuation is not None: cont = self._continuation self._continuation = None @@ -337,6 +385,9 @@ def _reset_for_next_request(self) -> None: self._message_complete = False self._response_started = False self._close_after_response = False + self._streaming_active = False + self._streaming_body_buf = bytearray() + self._streaming_eom = False self.idle = True self.close_at = time.monotonic() + self.server.config.keep_alive_timeout @@ -425,30 +476,61 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.finish_response() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - """Streaming-read API. Rare under the JSON-API contract — typical - callers receive the buffered body via ``ctx.body``. After the - :data:`BodyHandler` continuation runs, the body has been fully - consumed and this returns ``b""``.""" - # 100-continue: caller is opting to read more bytes; tell the client - # to actually send body if we haven't already. + """Streaming-read API. + + Two paths: + + - **Streaming route** (``buffer_body=False``, ``conn._streaming_active``): + parser-driven, same shape as h11. ``on_body`` callbacks (fired + from ``parser.feed_data`` calls on either thread) populate + ``conn._streaming_body_buf``; this method drains it. Pulls more + bytes from the socket and feeds them through the parser if the + buffer is empty and EOM hasn't fired. + - **Buffered route**: the body has already been buffered into + ``ctx.body`` before this body handler ran. ``ctx.receive`` is + the legacy fallback path (rare); it just calls ``sock.recv`` + for callers that did a hand-rolled ``borrow()``. + """ if self._expect_100_continue and not self._continue_sent: self._send_continue() - # The continuation-style flow buffers the body in ``self.body`` before - # dispatch — there's nothing left to stream when this is called from - # a body handler. For non-standard pre-body callers (e.g. handlers - # that called ``borrow()`` and read their own body on a worker), fall - # through to a blocking-with-timeout recv. - sock = self._conn.sock + + conn = self._conn rw = self._server.config.rw_timeout + + if conn._streaming_active: + while not conn._streaming_body_buf and not conn._streaming_eom: + try: + data = conn.sock.recv(DEFAULT_BUFFER_SIZE) + except BlockingIOError: + conn.sock.settimeout(rw) + try: + data = conn.sock.recv(DEFAULT_BUFFER_SIZE) + finally: + conn.sock.settimeout(0) + if not data: + break # peer FIN mid-body + # Feed through the parser; ``on_body`` populates the + # streaming buffer, ``on_message_complete`` flips EOM. + # Errors (BodyTooLarge / _ProtocolError) propagate to the + # caller; the worker pool's exception handler emits a 500. + conn._feed(data) + if conn._streaming_body_buf: + n = min(size, len(conn._streaming_body_buf)) + chunk = bytes(conn._streaming_body_buf[:n]) + del conn._streaming_body_buf[:n] + return chunk + return b"" + + # Buffered route fallback (hand-rolled borrow): raw recv. try: - return sock.recv(size) + return conn.sock.recv(size) except BlockingIOError: - sock.settimeout(rw) + conn.sock.settimeout(rw) try: - return sock.recv(size) + return conn.sock.recv(size) finally: - if self._conn.tracked: - sock.settimeout(0) + if conn.tracked: + conn.sock.settimeout(0) def _send_continue(self) -> None: _send_all( diff --git a/tests/http/app.py b/tests/http/app.py index 9effbac..f982d26 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -523,6 +523,163 @@ def upload(ctx: HTTPReqCtx): # Streaming handler runs on a worker thread, not the main thread. assert captured["thread"] != threading.get_ident() + async def test_streaming_route_reads_body_httptools(self, free_port): + """Streaming under httptools: the body reaches the worker even + when the client piggybacks body bytes with the request line in a + single TCP segment (the common case without ``Expect: 100-continue``). + + The parser is the single source of truth: ``on_body`` populates + ``conn._streaming_body_buf``, which the worker drains via + ``ctx.receive``.""" + captured: dict = {} + + app = HttpApp() + + @app.post("/upload", buffer_body=False) + def upload(ctx: HTTPReqCtx): + chunks: list[bytes] = [] + while True: + chunk = ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + captured["body"] = b"".join(chunks) + return f"got {len(captured['body'])} bytes" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(app.service(cfg, backend="httptools")) as lt: + await lt.started + await _wait_ready(free_port) + payload = b"a" * 1024 + r = await _post(f"http://127.0.0.1:{free_port}/upload", content=payload, timeout=3.0) + assert r.status_code == 200 + assert r.text == f"got {len(payload)} bytes" + lt.shutdown() + await lt.stopped + + assert captured["body"] == payload + + async def test_streaming_route_body_across_multiple_recvs_httptools(self, free_port): + """The client sends headers in one TCP write, body in subsequent + writes (so the parser only sees headers in the selector's first + feed). The worker must pull the rest through the parser via + ``ctx.receive`` → ``sock.recv`` → ``feed_data`` → ``on_body``.""" + captured: dict = {} + + app = HttpApp() + + @app.post("/upload", buffer_body=False) + def upload(ctx: HTTPReqCtx): + chunks: list[bytes] = [] + while True: + chunk = ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + captured["body"] = b"".join(chunks) + return f"got {len(captured['body'])} bytes" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(app.service(cfg, backend="httptools")) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + payload = b"a" * 4096 + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: + s.sendall( + b"POST /upload HTTP/1.1\r\n" + b"Host: x\r\n" + b"Content-Length: 4096\r\n" + b"\r\n" + ) + # Send body in three smaller writes with brief pauses; + # the parser will see only headers in its first feed. + for i in (0, 1024, 2048): + time.sleep(0.05) + s.sendall(payload[i : i + 1024]) + time.sleep(0.05) + s.sendall(payload[3072:4096]) + buf = b"" + while b"got 4096 bytes" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + return buf + + response = await to_thread.run_sync(hit) + assert b"HTTP/1.1 200" in response + assert b"got 4096 bytes" in response + lt.shutdown() + await lt.stopped + + assert captured["body"] == b"a" * 4096 + + async def test_streaming_then_keep_alive_request_httptools(self, free_port): + """After a streaming POST, the same connection serves a regular + GET. Verifies parser state is consistent after streaming — the + next request parses cleanly without any reset / replace.""" + captured: list[str] = [] + + app = HttpApp() + + @app.post("/upload", buffer_body=False) + def upload(ctx: HTTPReqCtx): + total = 0 + while True: + chunk = ctx.receive(8192) + if not chunk: + break + total += len(chunk) + captured.append(f"upload:{total}") + return f"got {total} bytes" + + @app.get("/ping") + def ping(): + captured.append("ping") + return "pong" + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(app.service(cfg, backend="httptools")) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: + # Streaming POST. + s.sendall( + b"POST /upload HTTP/1.1\r\n" + b"Host: x\r\n" + b"Content-Length: 1024\r\n" + b"\r\n" + + b"a" * 1024 + ) + buf1 = b"" + while b"got 1024 bytes" not in buf1: + chunk = s.recv(4096) + if not chunk: + break + buf1 += chunk + # Now reuse the same conn for a regular GET — would fail + # if streaming had left the parser in a stale state. + s.sendall(b"GET /ping HTTP/1.1\r\nHost: x\r\n\r\n") + buf2 = b"" + while b"pong" not in buf2: + chunk = s.recv(4096) + if not chunk: + break + buf2 += chunk + return buf1 + buf2 + + response = await to_thread.run_sync(hit) + assert b"got 1024 bytes" in response + assert b"pong" in response + lt.shutdown() + await lt.stopped + + assert captured == ["upload:1024", "ping"] + def test_streaming_route_with_pool_disabled_raises(self): """Registering a streaming route on an HttpApp with ``max_concurrency=0`` raises ``RuntimeError`` when the dispatcher is built.""" From d29d852356fceb895c0d6b972b14917f3c05f65c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 02:54:50 +0400 Subject: [PATCH 124/286] test(http): parameterize backend parity checks --- tests/http/_helpers.py | 55 ++++++++ tests/http/app.py | 78 +++-------- tests/http/backend_parity.py | 258 ++++++++++++++--------------------- tests/http/conftest.py | 59 +++++++- tests/http/server.py | 65 ++------- 5 files changed, 248 insertions(+), 267 deletions(-) create mode 100644 tests/http/_helpers.py diff --git a/tests/http/_helpers.py b/tests/http/_helpers.py new file mode 100644 index 0000000..7302f41 --- /dev/null +++ b/tests/http/_helpers.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import socket + + +def drain_socket(sock: socket.socket, deadline: float = 2.0) -> bytes: + """Read until the peer closes or no bytes arrive before ``deadline``.""" + sock.settimeout(deadline) + out = bytearray() + while True: + try: + chunk = sock.recv(4096) + except TimeoutError: + break + if not chunk: + break + out.extend(chunk) + return bytes(out) + + +def read_until(sock: socket.socket, marker: bytes, deadline: float = 2.0) -> bytes: + """Read until ``marker`` appears in the accumulated bytes.""" + sock.settimeout(deadline) + out = bytearray() + while marker not in out: + chunk = sock.recv(4096) + if not chunk: + break + out.extend(chunk) + return bytes(out) + + +def read_http_response(sock: socket.socket, deadline: float = 2.0) -> bytes: + """Read one HTTP/1.1 response with Content-Length framing.""" + data = read_until(sock, b"\r\n\r\n", deadline=deadline) + header_end = data.find(b"\r\n\r\n") + if header_end == -1: + return data + + content_length = 0 + for line in data[:header_end].split(b"\r\n")[1:]: + name, sep, value = line.partition(b":") + if sep and name.lower() == b"content-length": + content_length = int(value.strip()) + break + + body_start = header_end + 4 + remaining = content_length - (len(data) - body_start) + while remaining > 0: + chunk = sock.recv(remaining) + if not chunk: + break + data += chunk + remaining -= len(chunk) + return data diff --git a/tests/http/app.py b/tests/http/app.py index f982d26..9eb0d54 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -13,6 +13,7 @@ import time from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from typing import Any, Literal, cast import anyio import httpx @@ -37,11 +38,20 @@ @asynccontextmanager -async def _serve_app(app: HttpApp, cfg: ServerConfig) -> AsyncGenerator[ServiceLifetimeView]: - async with serve(app.service(cfg)) as lt: +async def _serve_app( + app: HttpApp, + cfg: ServerConfig, + *, + backend: Literal["h11", "httptools"] = "h11", +) -> AsyncGenerator[ServiceLifetimeView]: + async with serve(app.service(cfg, backend=backend)) as lt: yield lt +def _backend_name(http_backend) -> Literal["h11", "httptools"]: + return cast(Literal["h11", "httptools"], http_backend.name) + + async def _wait_ready(port: int, deadline: float = 5.0) -> bool: def probe(): end = time.monotonic() + deadline @@ -272,11 +282,14 @@ def wrapped(ctx): if result is None: captured.append("after-inline") return None + # wrap continuation def post(ctx): result(ctx) captured.append("after-body") + return post + return wrapped app = HttpApp(middleware=[capturing_mw]) @@ -463,7 +476,7 @@ async def test_invalid_max_concurrency(self): class TestBackendSelection: - async def test_httptools_backend(self, free_port): + async def test_explicit_backend_basic_response(self, free_port, http_backend): app = HttpApp() @app.get("/{name}") @@ -471,7 +484,7 @@ def hello(name: str): return f"hi {name}" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(app.service(cfg, backend="httptools")) as lt: + async with _serve_app(app, cfg, backend=_backend_name(http_backend)) as lt: await lt.started await _wait_ready(free_port) r = await _get(f"http://127.0.0.1:{free_port}/world") @@ -484,11 +497,11 @@ async def test_invalid_backend(self): app = HttpApp() cfg = ServerConfig(host="127.0.0.1", port=0) with pytest.raises(ValueError, match="unknown backend"): - app.service(cfg, backend="bogus") # type: ignore[arg-type] + app.service(cfg, backend=cast(Any, "bogus")) class TestStreamingRoutes: - async def test_streaming_route_reads_body_in_handler(self, free_port): + async def test_streaming_route_reads_body_in_handler(self, free_port, http_backend): """``buffer_body=False`` — handler runs in a worker on a borrowed conn and reads body chunks via ``ctx.receive(...)``.""" captured: dict = {} @@ -509,7 +522,7 @@ def upload(ctx: HTTPReqCtx): return f"got {len(full)} bytes" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_app(app, cfg) as lt: + async with _serve_app(app, cfg, backend=_backend_name(http_backend)) as lt: await lt.started await _wait_ready(free_port) payload = b"a" * 1024 @@ -523,42 +536,6 @@ def upload(ctx: HTTPReqCtx): # Streaming handler runs on a worker thread, not the main thread. assert captured["thread"] != threading.get_ident() - async def test_streaming_route_reads_body_httptools(self, free_port): - """Streaming under httptools: the body reaches the worker even - when the client piggybacks body bytes with the request line in a - single TCP segment (the common case without ``Expect: 100-continue``). - - The parser is the single source of truth: ``on_body`` populates - ``conn._streaming_body_buf``, which the worker drains via - ``ctx.receive``.""" - captured: dict = {} - - app = HttpApp() - - @app.post("/upload", buffer_body=False) - def upload(ctx: HTTPReqCtx): - chunks: list[bytes] = [] - while True: - chunk = ctx.receive(8192) - if not chunk: - break - chunks.append(chunk) - captured["body"] = b"".join(chunks) - return f"got {len(captured['body'])} bytes" - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(app.service(cfg, backend="httptools")) as lt: - await lt.started - await _wait_ready(free_port) - payload = b"a" * 1024 - r = await _post(f"http://127.0.0.1:{free_port}/upload", content=payload, timeout=3.0) - assert r.status_code == 200 - assert r.text == f"got {len(payload)} bytes" - lt.shutdown() - await lt.stopped - - assert captured["body"] == payload - async def test_streaming_route_body_across_multiple_recvs_httptools(self, free_port): """The client sends headers in one TCP write, body in subsequent writes (so the parser only sees headers in the selector's first @@ -587,12 +564,7 @@ def upload(ctx: HTTPReqCtx): def hit() -> bytes: payload = b"a" * 4096 with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: - s.sendall( - b"POST /upload HTTP/1.1\r\n" - b"Host: x\r\n" - b"Content-Length: 4096\r\n" - b"\r\n" - ) + s.sendall(b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 4096\r\n\r\n") # Send body in three smaller writes with brief pauses; # the parser will see only headers in its first feed. for i in (0, 1024, 2048): @@ -648,13 +620,7 @@ def ping(): def hit() -> bytes: with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: # Streaming POST. - s.sendall( - b"POST /upload HTTP/1.1\r\n" - b"Host: x\r\n" - b"Content-Length: 1024\r\n" - b"\r\n" - + b"a" * 1024 - ) + s.sendall(b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 1024\r\n\r\n" + b"a" * 1024) buf1 = b"" while b"got 1024 bytes" not in buf1: chunk = s.recv(4096) diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index be29b6b..dd3e441 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -1,73 +1,13 @@ -"""Parity tests across the h11 and httptools server backends. - -Same handler, same canonical request set, asserted against both backends. -""" +"""Shared HTTP behavior contract across the h11 and httptools backends.""" from __future__ import annotations import socket -import threading -from collections.abc import Callable -from contextlib import AbstractContextManager import httpx -import pytest - -from localpost.http import ( - HTTPReqCtx, - NativeResponse, - RequestHandler, - ServerConfig, - start_http_server, -) -from localpost.http.server_httptools import start_httptools_server - -_StartFn = Callable[[ServerConfig, RequestHandler], AbstractContextManager] - -BACKENDS: list[tuple[str, _StartFn]] = [ - ("h11", start_http_server), - ("httptools", start_httptools_server), -] - - -def _serve(start_fn: _StartFn, config: ServerConfig, handler: RequestHandler): - """Background-thread server harness — yields the bound port.""" - - cm = start_fn(config, handler) - stop = threading.Event() - - class _Ctx: - def __enter__(self) -> int: - self._cm_state = cm.__enter__() - server = self._cm_state - - def loop() -> None: - while not stop.is_set(): - try: - server.run(timeout=0.05) - except OSError: - return - - self._t = threading.Thread(target=loop, daemon=True) - self._t.start() - return server.port - - def __exit__(self, *exc): - stop.set() - self._t.join(timeout=5) - cm.__exit__(*exc) - - return _Ctx() - - -@pytest.fixture(params=BACKENDS, ids=[b[0] for b in BACKENDS]) -def serve_parity(request) -> Callable[[RequestHandler], AbstractContextManager]: - _name, start_fn = request.param - def make(handler: RequestHandler): - return _serve(start_fn, ServerConfig(host="127.0.0.1", port=0), handler) - - return make +from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig +from tests.http._helpers import drain_socket, read_http_response, read_until def _ok(body: bytes = b"hello"): @@ -86,14 +26,14 @@ def handler(ctx: HTTPReqCtx) -> None: return handler -def test_simple_get(serve_parity): - with serve_parity(_ok(b"hi")) as port: +def test_simple_get(serve_backend_in_thread): + with serve_backend_in_thread(_ok(b"hi")) as port: r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) assert r.status_code == 200 assert r.text == "hi" -def test_post_with_body(serve_parity): +def test_buffered_post_body_handler(serve_backend_in_thread): received: list[bytes] = [] def body_handler(ctx: HTTPReqCtx) -> None: @@ -113,100 +53,67 @@ def body_handler(ctx: HTTPReqCtx) -> None: def handler(_ctx: HTTPReqCtx): return body_handler - with serve_parity(handler) as port: + with serve_backend_in_thread(handler) as port: r = httpx.post(f"http://127.0.0.1:{port}/x", content=b"hello world", timeout=2) assert r.status_code == 200 assert r.text == "echo:hello world" assert received == [b"hello world"] -@pytest.mark.parametrize("start_fn", [b for _, b in BACKENDS], ids=[n for n, _ in BACKENDS]) -def test_oversize_body_returns_413(start_fn): - """Content-Length-flagged oversize body → 413 from the parser layer.""" - - def handler(ctx: HTTPReqCtx) -> None: # never called - ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") - - body = b"x" * 1024 - cm = _serve(start_fn, ServerConfig(host="127.0.0.1", port=0, max_body_size=100), handler) - with cm as port: - r = httpx.post(f"http://127.0.0.1:{port}/", content=body, timeout=2) - assert r.status_code == 413 - - -def test_keep_alive_two_requests(serve_parity): - counter = {"n": 0} +def test_keep_alive_sequential_requests(serve_backend_in_thread): + counter = 0 def handler(ctx: HTTPReqCtx) -> None: - counter["n"] += 1 - body = str(counter["n"]).encode("ascii") + nonlocal counter + counter += 1 + body = str(counter).encode("ascii") ctx.complete( NativeResponse( 200, - [(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], + [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], ), body, ) - with serve_parity(handler) as port: - with socket.create_connection(("127.0.0.1", port), timeout=2) as s: - s.sendall(b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n") - data1 = b"" - while b"\r\n\r\n" not in data1: - chunk = s.recv(4096) - if not chunk: - break - data1 += chunk - # consume body (Content-Length: 1) - while not data1.endswith(b"1"): - chunk = s.recv(4096) - if not chunk: - break - data1 += chunk - - s.sendall(b"GET /b HTTP/1.1\r\nHost: x\r\n\r\n") - data2 = b"" - while b"\r\n\r\n" not in data2: - chunk = s.recv(4096) - if not chunk: - break - data2 += chunk - while not data2.endswith(b"2"): - chunk = s.recv(4096) - if not chunk: - break - data2 += chunk + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n") + data1 = read_http_response(sock) + + sock.sendall(b"GET /b HTTP/1.1\r\nHost: x\r\n\r\n") + data2 = read_http_response(sock) assert b"HTTP/1.1 200" in data1 assert data1.endswith(b"1") assert b"HTTP/1.1 200" in data2 assert data2.endswith(b"2") - assert counter["n"] == 2 + assert counter == 2 -def test_malformed_request_returns_400(serve_parity): - def handler(ctx: HTTPReqCtx) -> None: # not called +def test_malformed_request_returns_400(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") - with serve_parity(handler) as port: - with socket.create_connection(("127.0.0.1", port), timeout=2) as s: - s.sendall(b"NOT-AN-HTTP-REQUEST\r\n\r\n") - data = b"" - while True: - chunk = s.recv(4096) - if not chunk: - break - data += chunk - assert b"400" in data, data + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"NOT-AN-HTTP-REQUEST\r\n\r\n") + data = drain_socket(sock, deadline=1.0) + assert b"HTTP/1.1 400" in data, data -def test_expect_100_continue(serve_parity): +def test_expect_100_continue(serve_backend_in_thread): def body_handler(ctx: HTTPReqCtx) -> None: out = b"got:" + ctx.body ctx.complete( NativeResponse( 200, - [(b"content-type", b"text/plain"), (b"content-length", str(len(out)).encode("ascii"))], + [ + (b"content-type", b"text/plain"), + (b"content-length", str(len(out)).encode("ascii")), + ], ), out, ) @@ -214,30 +121,71 @@ def body_handler(ctx: HTTPReqCtx) -> None: def handler(_ctx: HTTPReqCtx): return body_handler - with serve_parity(handler) as port: - with socket.create_connection(("127.0.0.1", port), timeout=3) as s: - s.sendall( - b"POST /x HTTP/1.1\r\n" - b"Host: x\r\n" - b"Content-Length: 5\r\n" - b"Expect: 100-continue\r\n" - b"\r\n" + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: + sock.sendall(b"POST /x HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") + pre = read_until(sock, b"\r\n\r\n") + assert b"100 Continue" in pre, pre + + sock.sendall(b"hello") + final = read_until(sock, b"got:hello") + + assert b"HTTP/1.1 200" in final + assert b"got:hello" in final + + +def test_oversize_content_length_returns_413(serve_backend_in_thread): + called = False + + def handler(ctx: HTTPReqCtx) -> None: + nonlocal called + called = True + ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + + config = ServerConfig(host="127.0.0.1", port=0, max_body_size=100) + with serve_backend_in_thread(handler, config) as port: + r = httpx.post(f"http://127.0.0.1:{port}/", content=b"x" * 1024, timeout=2) + + assert r.status_code == 413 + assert r.text == "Payload Too Large" + assert called is False + + +def test_handler_exception_returns_500_and_server_survives(serve_backend_in_thread): + calls = 0 + + def handler(ctx: HTTPReqCtx) -> None: + nonlocal calls + calls += 1 + if ctx.request.target == b"/boom": + raise RuntimeError("handler crashed") + ctx.complete(NativeResponse(200, [(b"content-length", b"2")]), b"ok") + + with serve_backend_in_thread(handler) as port: + r1 = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=2) + r2 = httpx.get(f"http://127.0.0.1:{port}/ok", timeout=2) + + assert r1.status_code == 500 + assert r1.text == "Internal Server Error" + assert r2.status_code == 200 + assert r2.text == "ok" + assert calls == 2 + + +def test_streaming_response_chunks(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.start_response( + NativeResponse( + status_code=200, + headers=[], ) - # Read intermediate 100 Continue - buf = b"" - s.settimeout(2) - while b"\r\n\r\n" not in buf: - chunk = s.recv(4096) - if not chunk: - break - buf += chunk - assert b"100" in buf, buf - # Send body now - s.sendall(b"hello") - # Read final response - while b"got:hello" not in buf: - chunk = s.recv(4096) - if not chunk: - break - buf += chunk - assert b"got:hello" in buf + ) + ctx.send(b"chunk1") + ctx.send(b"chunk2") + ctx.finish_response() + + with serve_backend_in_thread(handler) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert r.status_code == 200 + assert r.content == b"chunk1chunk2" diff --git a/tests/http/conftest.py b/tests/http/conftest.py index b09daff..0a52d5b 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -3,10 +3,41 @@ import socket import threading from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import Protocol import pytest from localpost.http import RequestHandler, ServerConfig, start_http_server +from localpost.http._base import BaseServer + +StartServer = Callable[[ServerConfig, RequestHandler], AbstractContextManager[BaseServer]] +ServeInThread = Callable[[RequestHandler], "_ServerCM"] + + +class ServeBackendInThread(Protocol): + def __call__(self, handler: RequestHandler, config: ServerConfig | None = None) -> _ServerCM: ... + + +@dataclass(frozen=True, slots=True) +class HTTPBackend: + name: str + start_server: StartServer + + +@pytest.fixture(params=("h11", "httptools")) +def http_backend(request) -> HTTPBackend: + name = request.param + if name == "h11": + return HTTPBackend(name="h11", start_server=start_http_server) + + try: + from localpost.http.server_httptools import start_httptools_server # noqa: PLC0415 + except ImportError as e: + pytest.skip(str(e)) + + return HTTPBackend(name="httptools", start_server=start_httptools_server) @pytest.fixture @@ -27,9 +58,6 @@ def server_config() -> ServerConfig: return ServerConfig(host="127.0.0.1", port=0) -ServeInThread = Callable[[RequestHandler], "_ServerCM"] - - class _ServerCM: """Returned by ``serve_in_thread(handler)``; use as a context manager. @@ -39,12 +67,12 @@ class _ServerCM: sets ``expect_loop_error = True`` (used to characterize crash paths). """ - def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: + def __init__(self, config: ServerConfig, handler: RequestHandler, start_server: StartServer) -> None: self._config = config self._handler = handler self._stop = threading.Event() self._thread: threading.Thread | None = None - self._cm = start_http_server(config, handler) + self._cm = start_server(config, handler) self.loop_error: BaseException | None = None self.expect_loop_error: bool = False @@ -93,6 +121,7 @@ def serve_in_thread(server_config: ServerConfig) -> Iterator[ServeInThread]: def test_thing(serve_in_thread): def handler(ctx): ... + with serve_in_thread(handler) as port: resp = httpx.get(f"http://127.0.0.1:{port}/") assert resp.status_code == 200 @@ -103,7 +132,7 @@ def handler(ctx): ... active: list[_ServerCM] = [] def make(handler: RequestHandler) -> _ServerCM: - cm = _ServerCM(server_config, handler) + cm = _ServerCM(server_config, handler, start_http_server) active.append(cm) return cm @@ -115,3 +144,21 @@ def make(handler: RequestHandler) -> _ServerCM: if t is not None and t.is_alive(): cm._stop.set() t.join(timeout=5) + + +@pytest.fixture +def serve_backend_in_thread(server_config: ServerConfig, http_backend: HTTPBackend) -> Iterator[ServeBackendInThread]: + active: list[_ServerCM] = [] + + def make(handler: RequestHandler, config: ServerConfig | None = None) -> _ServerCM: + cm = _ServerCM(config or server_config, handler, http_backend.start_server) + active.append(cm) + return cm + + yield make + + for cm in active: + t = cm._thread + if t is not None and t.is_alive(): + cm._stop.set() + t.join(timeout=5) diff --git a/tests/http/server.py b/tests/http/server.py index 2c5028d..d220724 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -10,22 +10,7 @@ import pytest from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig, start_http_server - - -def _drain(sock: socket.socket, deadline: float = 2.0) -> bytes: - """Read until the peer closes or the deadline elapses.""" - sock.settimeout(deadline) - out = bytearray() - while True: - try: - chunk = sock.recv(4096) - except (TimeoutError, socket.timeout): - break - if not chunk: - break - out.extend(chunk) - return bytes(out) - +from tests.http._helpers import drain_socket # --- Listening-socket lifecycle (no requests) --------------------------------- @@ -116,8 +101,7 @@ def test_handler_sees_headers(self, serve_in_thread): captured_headers: dict[bytes, bytes] = {} def handler(ctx: HTTPReqCtx): - for name, value in ctx.request.headers: - captured_headers[name] = value + captured_headers.update(ctx.request.headers) ctx.complete(NativeResponse(status_code=200, headers=[]), b"") with serve_in_thread(handler) as port: @@ -219,11 +203,9 @@ def handler(ctx: HTTPReqCtx): with serve_in_thread(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: - sock.sendall( - b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" - ) + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") # Read response. - data = _drain(sock, deadline=1.0) + data = drain_socket(sock, deadline=1.0) assert b"HTTP/1.1 200" in data # Now expect a clean EOF (FIN), not a reset. sock.settimeout(2.0) @@ -255,7 +237,7 @@ def test_malformed_request_returns_400_and_keeps_loop_alive(self, serve_in_threa with serve_in_thread(_ok_handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: sock.sendall(b"NOT_A_VALID_HTTP_REQUEST\r\n\r\n") - data = _drain(sock, deadline=1.0) + data = drain_socket(sock, deadline=1.0) assert b"HTTP/1.1 400" in data, f"expected 400 in response, got: {data!r}" # Sanity: a follow-up valid request is still served. @@ -327,15 +309,9 @@ def test_client_half_close_after_partial_body_keeps_loop_alive(self, serve_in_th """ with serve_in_thread(_ok_handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: - sock.sendall( - b"POST / HTTP/1.1\r\n" - b"Host: x\r\n" - b"Content-Length: 100\r\n" - b"\r\n" - b"hello" - ) + sock.sendall(b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nhello") sock.shutdown(socket.SHUT_WR) - _drain(sock, deadline=1.0) + drain_socket(sock, deadline=1.0) # Server still serves a follow-up valid request. resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) @@ -366,13 +342,7 @@ def handler(ctx: HTTPReqCtx) -> None: with serve_in_thread(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: - sock.sendall( - b"POST / HTTP/1.1\r\n" - b"Host: x\r\n" - b"Content-Length: 5\r\n" - b"Expect: 100-continue\r\n" - b"\r\n" - ) + sock.sendall(b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") # Read the 100 Continue intermediate response. sock.settimeout(2) pre = b"" @@ -383,7 +353,7 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"100 Continue" in pre, f"expected 100 Continue, got: {pre!r}" sock.sendall(b"hello") - final = _drain(sock, deadline=2.0) + final = drain_socket(sock, deadline=2.0) assert b"HTTP/1.1 200" in final assert b"\r\n\r\nok" in final @@ -434,10 +404,7 @@ def handler(ctx: HTTPReqCtx) -> None: with serve_in_thread(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: - sock.sendall( - b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n" - b"GET /b HTTP/1.1\r\nHost: x\r\n\r\n" - ) + sock.sendall(b"GET /a HTTP/1.1\r\nHost: x\r\n\r\nGET /b HTTP/1.1\r\nHost: x\r\n\r\n") # Read until both bodies have arrived. sock.settimeout(2) data = b"" @@ -460,9 +427,7 @@ def test_send_accepts_memoryview(self, serve_in_thread): """``ctx.send`` accepts any Buffer (memoryview, bytearray, …).""" def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response( - NativeResponse(status_code=200, headers=[(b"content-length", b"5")]) - ) + ctx.start_response(NativeResponse(status_code=200, headers=[(b"content-length", b"5")])) payload = bytearray(b"hello") ctx.send(memoryview(payload)[:5]) ctx.finish_response() @@ -496,7 +461,7 @@ def handler(ctx: HTTPReqCtx) -> None: with socket.create_connection(("127.0.0.1", server.port), timeout=2) as sock: # First request — server then keeps the connection alive. sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") - _drain(sock, deadline=0.3) + drain_socket(sock, deadline=0.3) # Now sit idle. After keep_alive_timeout (0.1s) + a few iterations, # _cleanup_stale should silently close the conn. sock.settimeout(2.0) @@ -523,7 +488,7 @@ def handler(ctx: HTTPReqCtx) -> None: # Send only part of a request and then sit idle. sock.sendall(b"GET /slow HTTP/1.1\r\nHost: x\r\n") sock.settimeout(2.0) - data = _drain(sock, deadline=1.5) + data = drain_socket(sock, deadline=1.5) finally: stop.set() t.join(timeout=2) @@ -589,7 +554,7 @@ def handler(ctx: HTTPReqCtx) -> None: b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" b"10\r\n" + (b"x" * 16) + b"\r\n0\r\n\r\n" ) - data = _drain(sock, deadline=2.0) + data = drain_socket(sock, deadline=2.0) finally: stop.set() t.join(timeout=2) @@ -632,7 +597,7 @@ def handler(ctx: HTTPReqCtx) -> None: try: # Send a request and read its response — connection is kept alive. sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") - _drain(sock, deadline=0.5) + drain_socket(sock, deadline=0.5) finally: # Pull socket reference outside the with block so we can recv after server exit. pass From c81afb7c3a02e83d1805c56eb9e52cd6a77fe2f9 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 02:56:12 +0400 Subject: [PATCH 125/286] test(http): run slow integration route on workers --- tests/http/_integration_app.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index 134344d..a51e833 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -20,7 +20,7 @@ def _build_handler(): mode = os.environ.get("LP_TEST_MODE", "router") if mode == "router": - from localpost.http import ( + from localpost.http import ( # noqa: PLC0415 BodyHandler, HTTPReqCtx, NativeResponse, @@ -45,9 +45,11 @@ def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: return None def _slow(ctx: HTTPReqCtx) -> BodyHandler | None: - time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) - _emit(ctx, str(threading.get_ident()).encode()) - return None + def post_body(ctx: HTTPReqCtx) -> None: + time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) + _emit(ctx, str(threading.get_ident()).encode()) + + return post_body def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: _emit(ctx, f"hi {route_match(ctx).path_args['name']}".encode()) @@ -60,7 +62,7 @@ def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: return routes.build().as_handler() if mode == "wsgi": - from localpost.http import wrap_wsgi + from localpost.http import wrap_wsgi # noqa: PLC0415 def wsgi_app(environ, start_response): path = environ.get("PATH_INFO", "/") @@ -74,9 +76,9 @@ def wsgi_app(environ, start_response): return wrap_wsgi(wsgi_app) if mode == "flask": - from flask import Flask + from flask import Flask # noqa: PLC0415 - from localpost.http.flask import flask_handler + from localpost.http.flask import flask_handler # noqa: PLC0415 flask_app = Flask(__name__) @@ -98,9 +100,9 @@ def hello(name: str): def _main() -> int: logging.basicConfig(level=logging.INFO) - from localpost.hosting import run, service - from localpost.hosting.middleware import shutdown_on_signal - from localpost.http import ServerConfig, http_server, thread_pool_handler + from localpost.hosting import run, service # noqa: PLC0415 + from localpost.hosting.middleware import shutdown_on_signal # noqa: PLC0415 + from localpost.http import ServerConfig, http_server, thread_pool_handler # noqa: PLC0415 # Honor LP_TEST_BACKEND so tests can pin asyncio vs trio. backend = os.environ.get("LP_TEST_BACKEND", "asyncio") From d6a54fbfd0755c7cfaf7c77fe40a8eed7aacfdc6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 11:06:58 +0400 Subject: [PATCH 126/286] perf(http): make settimeout fallback sticky per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a borrowed conn, leave the socket at rw_timeout once a BlockingIOError fallback fires so subsequent send/recv calls in the same request skip the fcntl dance. ``_maybe_give_back`` does a lazy ``gettimeout`` check (no syscall) and restores non-blocking only if needed. Eliminates the ``2 × N`` settimeout cost for streaming receives in the httptools backend and for multi-chunk responses; the JSON-API common case still pays zero fcntl per request. ``_send_all`` now takes the conn instead of (sock, payload, rw_timeout) to read ``conn.tracked`` and gate the inline restore — selector-thread paths still restore immediately (the loop assumes non-blocking I/O). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_base.py | 51 +++++++++++++++++---------- localpost/http/server_h11.py | 40 ++++++++++++++------- localpost/http/server_httptools.py | 56 +++++++++++++++++------------- 3 files changed, 90 insertions(+), 57 deletions(-) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index f24b765..8edd2bc 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -214,16 +214,19 @@ def wrap(handler: RequestHandler) -> RequestHandler: return wrap -def _send_all(sock: socket.socket, payload: bytes | bytearray | memoryview, rw_timeout: float) -> None: - """Send all of ``payload`` to ``sock``. +def _send_all(conn: BaseHTTPConn, payload: bytes | bytearray | memoryview) -> None: + """Send all of ``payload`` to ``conn.sock``. Optimised for the JSON-API common case: a small response that fits in the kernel send buffer. Tries non-blocking ``send`` first; on - :exc:`BlockingIOError` (kernel buffer full mid-response) transitions - to blocking-with-timeout for the remainder, then restores - non-blocking on the way out. Saves the two ``settimeout`` (fcntl) - calls per request that the prior borrow-with-blocking design paid. + :exc:`BlockingIOError` (kernel buffer full mid-response) flips to + blocking-with-timeout and drains via ``sendall``. The blocking-mode + fallback **stays sticky** for the rest of the request when the conn + is borrowed — :meth:`HTTPReqCtx._maybe_give_back` resets the socket + to non-blocking on hand-back. On the selector thread the socket is + restored inline; the selector loop assumes non-blocking I/O. """ + sock = conn.sock view = payload if isinstance(payload, memoryview) else memoryview(payload) total = len(view) sent = 0 @@ -231,13 +234,18 @@ def _send_all(sock: socket.socket, payload: bytes | bytearray | memoryview, rw_t try: n = sock.send(view[sent:]) except BlockingIOError: - # Kernel send buffer full; finish the rest in blocking-with-timeout - # mode, then restore non-blocking. - sock.settimeout(rw_timeout) + # Kernel send buffer full; drain the rest with a blocking + # sendall under ``rw_timeout``. On a borrowed conn we leave + # the socket blocking-with-timeout so subsequent send/recv + # calls in the same request skip the BlockingIOError dance; + # the give-back path resets it. On the selector thread we + # must restore non-blocking before returning. + sock.settimeout(conn.server.config.rw_timeout) try: sock.sendall(view[sent:]) finally: - sock.settimeout(0) + if conn.tracked: + sock.settimeout(0) return if n == 0: raise ConnectionAbortedError("socket is broken") @@ -462,11 +470,14 @@ def track(self, conn: BaseHTTPConn) -> None: is flipped optimistically so concurrent readers see the intended state. - The socket is kept non-blocking throughout the connection's - lifetime — see :func:`_send_all` for the worker-side send path - that handles ``BlockingIOError`` with a blocking-with-timeout - fallback. This avoids two ``settimeout`` (fcntl) calls per - request on the borrow / re-track boundary. + The socket is non-blocking whenever a conn is tracked. The send / + recv paths (see :func:`_send_all` and the receive helpers in each + backend) lazily flip the socket to blocking-with-timeout on the + first ``BlockingIOError`` and leave it sticky for the rest of + the request on a borrowed conn; ``_maybe_give_back`` resets the + socket to non-blocking before re-tracking. The common case + (response fits in the kernel buffer) pays zero ``settimeout`` + calls per request. **Synchronisation edge for parser ownership.** This method's op-queue enqueue + wakeup-pipe ``os.write`` (in :meth:`_wake`) @@ -653,10 +664,12 @@ def run(self, *, timeout: float | None = None) -> None: if key.fileobj is server_sock: client_sock, client_addr = server_sock.accept() # Linux 2.6.28+ inherits ``O_NONBLOCK`` from the listening - # socket; macOS / BSD do not. Set explicitly — once per - # connection, never again. The conn stays non-blocking for - # its lifetime; the send path (``_send_all``) handles - # blocking-with-timeout fallback on ``BlockingIOError``. + # socket; macOS / BSD do not. Set explicitly. While the + # conn is tracked the socket is non-blocking; the send / + # recv paths may flip it to blocking-with-timeout on a + # borrowed conn (``_send_all`` and the per-backend + # receive helpers) and the give-back path resets it + # before re-tracking. client_sock.setblocking(False) conn = self._conn_factory(self, client_sock, client_addr) self.track(conn) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 93ec623..c087a52 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -123,7 +123,7 @@ def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11. payload = self.parser.send(event) if payload is None: return - _send_all(self.sock, payload, self.server.config.rw_timeout) + _send_all(self, payload) def __call__(self, h: RequestHandler, /) -> None: try: @@ -265,16 +265,15 @@ def emit_stale_408(self) -> None: if self.idle or self.parser.our_state is not h11.IDLE: return try: - rw = self.server.config.rw_timeout payload = self.parser.send(_to_h11_response(REQUEST_TIMEOUT_RESPONSE)) if payload: - _send_all(self.sock, payload, rw) + _send_all(self, payload) payload = self.parser.send(h11.Data(data=REQUEST_TIMEOUT_BODY)) if payload: - _send_all(self.sock, payload, rw) + _send_all(self, payload) payload = self.parser.send(h11.EndOfMessage()) if payload: - _send_all(self.sock, payload, rw) + _send_all(self, payload) except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway pass @@ -315,8 +314,22 @@ def borrow(self) -> Iterator[HTTPReqCtxH11]: self._maybe_give_back() def _maybe_give_back(self) -> None: - if self.borrowed: - self._server.track(self._conn) + if not self.borrowed: + return + # If a fallback path inside this request flipped the socket to + # blocking-with-timeout, reset to non-blocking before handing the + # conn back to the selector — the selector loop assumes a + # non-blocking socket and a stray BlockingIOError is its only + # "no more data" signal. ``gettimeout`` reads cached state on the + # socket object (no syscall), so the no-fallback common case + # pays nothing. + sock = self._conn.sock + if sock.gettimeout() != 0: + try: + sock.settimeout(0) + except OSError: + pass + self._server.track(self._conn) def complete(self, response: Response, body: bytes | None = None) -> None: self.start_response(response) @@ -338,15 +351,16 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: event = parser.next_event() if event is h11.NEED_DATA: # Sync handlers can't tolerate BlockingIOError on a non-blocking - # socket: switch to a brief blocking read bounded by rw_timeout, - # then restore. Borrowed connections are already blocking, so - # the settimeout calls are no-ops on the rw_timeout value there. + # socket: switch to a blocking read bounded by ``rw_timeout``. + # On a borrowed conn we leave the socket blocking-with-timeout + # so the next iteration's ``recv`` skips the BlockingIOError + # path entirely; the give-back path resets it on hand-back. + # On the selector thread we restore non-blocking inline. sock = self._conn.sock - rw = self._server.config.rw_timeout try: self._conn.receive(size) except BlockingIOError: - sock.settimeout(rw) + sock.settimeout(self._server.config.rw_timeout) try: self._conn.receive(size) finally: @@ -418,7 +432,7 @@ def finish_response(self) -> None: self._maybe_give_back() def _sock_sendall(self, payload: bytes) -> None: - _send_all(self._conn.sock, payload, self._server.config.rw_timeout) + _send_all(self._conn, payload) def start_http_server(config: ServerConfig, handler: RequestHandler, /): diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 14469a9..8079f09 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -396,11 +396,7 @@ def _try_send_status(self, response: Response, body: bytes) -> None: if self._response_started: return try: - _send_all( - self.sock, - _serialize_response(response) + body, - self.server.config.rw_timeout, - ) + _send_all(self, _serialize_response(response) + body) except Exception: # noqa: BLE001, S110 — connection is being closed anyway pass @@ -409,11 +405,7 @@ def emit_stale_408(self) -> None: if self.idle or self._response_started: return try: - _send_all( - self.sock, - _serialize_response(REQUEST_TIMEOUT_RESPONSE) + REQUEST_TIMEOUT_BODY, - self.server.config.rw_timeout, - ) + _send_all(self, _serialize_response(REQUEST_TIMEOUT_RESPONSE) + REQUEST_TIMEOUT_BODY) except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway pass @@ -466,8 +458,21 @@ def borrow(self) -> Iterator[HTTPReqCtxHttptools]: self._maybe_give_back() def _maybe_give_back(self) -> None: - if self.borrowed: - self._server.track(self._conn) + if not self.borrowed: + return + # If a fallback path inside this request flipped the socket to + # blocking-with-timeout, reset to non-blocking before handing the + # conn back to the selector — the selector loop assumes a + # non-blocking socket. ``gettimeout`` reads cached state on the + # socket object (no syscall), so the no-fallback common case + # pays nothing. + sock = self._conn.sock + if sock.gettimeout() != 0: + try: + sock.settimeout(0) + except OSError: + pass + self._server.track(self._conn) def complete(self, response: Response, body: bytes | None = None) -> None: self.start_response(response) @@ -506,7 +511,14 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: try: data = conn.sock.recv(DEFAULT_BUFFER_SIZE) finally: - conn.sock.settimeout(0) + # On a borrowed conn keep the socket blocking-with-timeout + # for the rest of the request; subsequent ``recv`` calls + # in this loop (and in later sends) skip the + # BlockingIOError path entirely. The give-back path + # resets the socket on hand-back. On the selector thread + # we restore inline. + if conn.tracked: + conn.sock.settimeout(0) if not data: break # peer FIN mid-body # Feed through the parser; ``on_body`` populates the @@ -533,11 +545,7 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: conn.sock.settimeout(0) def _send_continue(self) -> None: - _send_all( - self._conn.sock, - b"HTTP/1.1 100 Continue\r\n\r\n", - self._server.config.rw_timeout, - ) + _send_all(self._conn, b"HTTP/1.1 100 Continue\r\n\r\n") self._continue_sent = True def start_response(self, response: Response | InformationalResponse, /) -> None: @@ -563,7 +571,7 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: self._pending_header_bytes = _serialize_response(response) else: # Informational responses (e.g., 100 Continue) flush immediately. - _send_all(self._conn.sock, _serialize_response(response), self._server.config.rw_timeout) + _send_all(self._conn, _serialize_response(response)) def send(self, chunk: Buffer, /) -> None: if not isinstance(chunk, bytes): @@ -571,24 +579,22 @@ def send(self, chunk: Buffer, /) -> None: if not chunk and self._pending_header_bytes is None: return framed = (f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n") if self._chunked and chunk else chunk - rw = self._server.config.rw_timeout if self._pending_header_bytes is not None: # Headers + first body chunk in one syscall. - _send_all(self._conn.sock, self._pending_header_bytes + framed, rw) + _send_all(self._conn, self._pending_header_bytes + framed) self._pending_header_bytes = None elif framed: - _send_all(self._conn.sock, framed, rw) + _send_all(self._conn, framed) def finish_response(self) -> None: # Flush any still-buffered headers (empty-body case) plus the # chunked terminator, in one sendall. terminator = b"0\r\n\r\n" if self._chunked else b"" - rw = self._server.config.rw_timeout if self._pending_header_bytes is not None: - _send_all(self._conn.sock, self._pending_header_bytes + terminator, rw) + _send_all(self._conn, self._pending_header_bytes + terminator) self._pending_header_bytes = None elif terminator: - _send_all(self._conn.sock, terminator, rw) + _send_all(self._conn, terminator) self._maybe_give_back() From 4c4e96a486c2d20532fe481e1b06870dd5b5285e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 11:34:28 +0400 Subject: [PATCH 127/286] fix(http): reject worker pool overload --- localpost/http/_base.py | 10 ++++++ localpost/http/_pool.py | 29 ++++++++++++++--- tests/http/service.py | 69 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 8edd2bc..b68c42b 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -115,6 +115,16 @@ class _OpClose: ], ) +SERVICE_UNAVAILABLE_BODY = b"Service Unavailable" +SERVICE_UNAVAILABLE_RESPONSE = Response( + status_code=503, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(SERVICE_UNAVAILABLE_BODY)).encode("ascii")), + (b"connection", b"close"), + ], +) + class HTTPReqCtx(Protocol): """Per-request context handed to a :data:`RequestHandler`. diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index c14381e..69e5048 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -29,11 +29,13 @@ CapacityLimiter, ClosedResourceError, EndOfStream, + WouldBlock, create_task_group, to_thread, ) from localpost import threadtools +from localpost.http._base import SERVICE_UNAVAILABLE_BODY, SERVICE_UNAVAILABLE_RESPONSE from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request from localpost.http.config import LOGGER_NAME from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error @@ -53,7 +55,9 @@ class _Pool: Created/torn-down by :func:`_pool_context`. Two ``dispatch_*`` methods let callers wire either a post-body :data:`BodyHandler` or a - pre-body streaming handler onto the same worker pool. + pre-body streaming handler onto the same worker pool. Admission is + non-blocking from the selector thread: if the bounded pending queue is + full, the request receives 503 and the connection is closed. """ __slots__ = ("_shutdown_event", "_tx") @@ -96,7 +100,9 @@ def dispatched(ctx: HTTPReqCtx) -> None: ctx._server.stop_tracking(ctx._conn) cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) try: - tx.put((ctx, cancel, fn)) + tx.put_nowait((ctx, cancel, fn)) + except WouldBlock: + _reject_overloaded(ctx) except (ClosedResourceError, BrokenResourceError): with suppress(Exception): ctx._conn.close() @@ -104,6 +110,19 @@ def dispatched(ctx: HTTPReqCtx) -> None: return dispatched +def _reject_overloaded(ctx: HTTPReqCtx) -> None: + """Fail fast when the worker queue is full. + + This runs on the selector thread after the conn has been unregistered. + Keep the response tiny and close afterwards so unread request-body bytes + cannot poison keep-alive framing. + """ + with suppress(Exception): + ctx.complete(SERVICE_UNAVAILABLE_RESPONSE, SERVICE_UNAVAILABLE_BODY) + with suppress(Exception): + ctx._conn.close() + + @asynccontextmanager async def _pool_context(max_concurrency: int) -> AsyncGenerator[_Pool]: """Open a worker pool for ``max_concurrency`` concurrent requests. @@ -197,8 +216,10 @@ def thread_pool_handler( - :data:`BodyHandler` — the wrapper returns a *new* continuation that, when invoked by the selector after the body has been buffered, ``stop_tracking`` s the conn and queues the work for a - worker. ``max_concurrency`` workers pull from the channel and - run the continuation under a per-request cancel scope. + worker. If all workers and the bounded pending queue are full, the + request is rejected with 503 instead of blocking the selector. + ``max_concurrency`` workers pull from the channel and run the + continuation under a per-request cancel scope. Per-request cancellation surfaces through :func:`localpost.http.check_cancelled` (client disconnect via diff --git a/tests/http/service.py b/tests/http/service.py index 95ff496..9695ad7 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -31,6 +31,7 @@ thread_pool_handler, ) from localpost.http.server import RequestHandler +from tests.http._helpers import read_http_response pytestmark = pytest.mark.anyio @@ -459,6 +460,74 @@ async def wait_for_peak() -> None: lt.shutdown() await lt.stopped + async def test_pool_overload_rejects_without_blocking_selector(self, free_port): + """A full worker pool + full queue returns 503 instead of blocking the selector.""" + entered = threading.Event() + release = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + entered.set() + release.wait(timeout=5.0) + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + routes = Routes() + + @routes.get("/slow") + def slow(_ctx: HTTPReqCtx) -> BodyHandler: + return body_handler + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, routes.build().as_handler(), max_concurrency=1) as lt: + await lt.started + await _wait_server_ready(free_port) + + sockets: list[socket.socket] = [] + + def open_slow() -> socket.socket: + sock = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) + sock.sendall(b"GET /slow HTTP/1.1\r\nHost: x\r\n\r\n") + return sock + + def open_slow_and_try_read() -> tuple[socket.socket, bytes]: + sock = open_slow() + try: + return sock, read_http_response(sock, deadline=0.5) + except TimeoutError: + return sock, b"" + + try: + sockets.append(await to_thread.run_sync(open_slow)) + assert await to_thread.run_sync(lambda: entered.wait(2.0)) + + # This request occupies the single pending queue slot while + # the first request keeps the only worker busy. + sockets.append(await to_thread.run_sync(open_slow)) + await anyio.sleep(0.1) + + overload_response = b"" + for _ in range(4): + sock, response = await to_thread.run_sync(open_slow_and_try_read) + sockets.append(sock) + if b"HTTP/1.1 503" in response: + overload_response = response + break + + assert b"HTTP/1.1 503" in overload_response, overload_response + assert b"Service Unavailable" in overload_response + + # The selector must still be responsive while the worker and + # pending queue are saturated. A miss is answered inline. + r = await _get(f"http://127.0.0.1:{free_port}/missing", timeout=1.0) + assert r.status_code == 404 + finally: + release.set() + for sock in sockets: + with contextlib.suppress(OSError): + sock.close() + + lt.shutdown() + await lt.stopped + async def test_handler_exception_returns_500_and_service_stays_up(self, free_port): """A handler exception is caught at the connection level and returned as 500. From 72cab0202fd2f2b1c7b3ec2c201053cfa4a2b7aa Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 11:51:03 +0400 Subject: [PATCH 128/286] fix(http): defer httptools streaming handoff --- localpost/http/_pool.py | 6 ++- localpost/http/server_httptools.py | 24 +++++++++++- tests/http/app.py | 54 +++++++++++++++++++++++++++ tests/http/service.py | 59 +++++++++++++++++++++++++++++- 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 69e5048..32e1fc8 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -87,7 +87,11 @@ def dispatch_streaming(self, handler: _WorkFn) -> RequestHandler: dispatcher = self._make_dispatcher(handler) def pre_body(ctx: HTTPReqCtx) -> BodyHandler | None: - dispatcher(ctx) + defer = getattr(ctx, "_defer_streaming_dispatch", None) + if defer is not None: + defer(dispatcher) + else: + dispatcher(ctx) return None # we borrowed; selector free return pre_body diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 8079f09..cf7dc32 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -26,7 +26,7 @@ import socket import time -from collections.abc import Buffer, Iterator +from collections.abc import Buffer, Callable, Iterator from contextlib import contextmanager from dataclasses import dataclass, field from typing import Any, final @@ -168,6 +168,7 @@ class HTTPConnHttptools(BaseHTTPConn): _streaming_active: bool = False _streaming_body_buf: bytearray = field(default_factory=bytearray) _streaming_eom: bool = False + _deferred_streaming_dispatch: Callable[[], None] | None = None # Set when the response indicates ``Connection: close`` or the request lacked # keep-alive support — the conn is closed once finish_response returns. @@ -334,13 +335,20 @@ def _feed(self, data: bytes) -> None: try: self.parser.feed_data(data) except httptools.HttpParserUpgrade as e: + self._deferred_streaming_dispatch = None raise _ProtocolError(f"HTTP upgrade not supported: {e}") from e except httptools.HttpParserError as e: + self._deferred_streaming_dispatch = None raise _ProtocolError(str(e)) from e if self._body_too_large is not None: n = self._body_too_large self._body_too_large = None + self._deferred_streaming_dispatch = None raise BodyTooLarge(n) + if self._deferred_streaming_dispatch is not None: + dispatch = self._deferred_streaming_dispatch + self._deferred_streaming_dispatch = None + dispatch() def _loop(self) -> None: config = self.server.config @@ -388,6 +396,7 @@ def _reset_for_next_request(self) -> None: self._streaming_active = False self._streaming_body_buf = bytearray() self._streaming_eom = False + self._deferred_streaming_dispatch = None self.idle = True self.close_at = time.monotonic() + self.server.config.keep_alive_timeout @@ -457,6 +466,19 @@ def borrow(self) -> Iterator[HTTPReqCtxHttptools]: finally: self._maybe_give_back() + def _defer_streaming_dispatch(self, dispatcher: Callable[[HTTPReqCtxHttptools], None]) -> None: + """Start a streaming worker after the current parser feed returns. + + httptools fires callbacks from inside ``parser.feed_data``. Starting + the worker directly from ``on_headers_complete`` would let it call + ``ctx.receive`` and re-enter the parser while the selector thread is + still inside the same feed. Instead, mark streaming active now so any + body bytes from the current packet are buffered by ``on_body``, then + let ``_feed`` run the handoff once callbacks are done. + """ + self._conn._streaming_active = True + self._conn._deferred_streaming_dispatch = lambda: dispatcher(self) + def _maybe_give_back(self) -> None: if not self.borrowed: return diff --git a/tests/http/app.py b/tests/http/app.py index 9eb0d54..782ebf4 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -29,6 +29,7 @@ NativeResponse, RequestHandler, ServerConfig, + httptools_server, ) pytestmark = pytest.mark.anyio @@ -588,6 +589,59 @@ def hit() -> bytes: assert captured["body"] == b"a" * 4096 + async def test_httptools_deferred_streaming_dispatch_sees_current_feed_body(self, free_port): + """httptools starts streaming work after callbacks from the current feed finish.""" + captured: dict = {} + + def handler(ctx: HTTPReqCtx): + def dispatch(req_ctx: HTTPReqCtx) -> None: + req_ctx._server.stop_tracking(req_ctx._conn) + conn = cast(Any, req_ctx._conn) + captured["buffer_before_receive"] = bytes(conn._streaming_body_buf) + captured["eom_before_receive"] = conn._streaming_eom + chunks: list[bytes] = [] + while True: + chunk = req_ctx.receive(8192) + if not chunk: + break + chunks.append(chunk) + captured["body"] = b"".join(chunks) + req_ctx.complete(NativeResponse(200, [(b"content-length", b"2")]), b"ok") + + cast(Any, ctx)._defer_streaming_dispatch(dispatch) + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(httptools_server(cfg, handler)) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + payload = b"a" * 1024 + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: + s.sendall( + b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 1024\r\n\r\n" + + payload + ) + buf = b"" + while b"\r\n\r\nok" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + return buf + + response = await to_thread.run_sync(hit) + assert b"HTTP/1.1 200" in response + assert b"\r\n\r\nok" in response + lt.shutdown() + await lt.stopped + + assert captured == { + "buffer_before_receive": b"a" * 1024, + "eom_before_receive": True, + "body": b"a" * 1024, + } + async def test_streaming_then_keep_alive_request_httptools(self, free_port): """After a streaming POST, the same connection serves a regular GET. Verifies parser state is consistent after streaming — the diff --git a/tests/http/service.py b/tests/http/service.py index 9695ad7..b8e6d6b 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -9,8 +9,9 @@ import threading import time from collections import Counter -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager +from typing import cast import anyio import httpx @@ -22,12 +23,14 @@ BodyHandler, HTTPReqCtx, NativeResponse, + Request, RequestCancelled, Routes, ServerConfig, check_cancelled, http_server, route_match, + streaming_pool_handler, thread_pool_handler, ) from localpost.http.server import RequestHandler @@ -412,7 +415,61 @@ def hit(ctx: HTTPReqCtx) -> BodyHandler | None: await lt.stopped +class _FakeConn: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.tracked = True + + +class _FakeServer: + def __init__(self) -> None: + self.stopped = False + + def stop_tracking(self, conn: _FakeConn) -> None: + self.stopped = True + conn.tracked = False + + +class _DeferringCtx: + def __init__(self, server: _FakeServer, conn: _FakeConn) -> None: + self._server = server + self._conn = conn + self.request = Request(method=b"POST", target=b"/upload", headers=[]) + self.body = b"" + self.response_status = None + self.attrs = {} + self.deferred: Callable[[], None] | None = None + + def _defer_streaming_dispatch(self, dispatcher: Callable[[HTTPReqCtx], None]) -> None: + self.deferred = lambda: dispatcher(cast(HTTPReqCtx, self)) + + class TestServiceRobustness: + async def test_streaming_pool_defers_dispatch_when_context_requests_it(self): + """Backends with parser callbacks can delay worker start until parsing unwinds.""" + server = _FakeServer() + sock, peer = socket.socketpair() + ctx = _DeferringCtx(server, _FakeConn(sock)) + ran = threading.Event() + + def work(_ctx: HTTPReqCtx) -> None: + ran.set() + + try: + async with streaming_pool_handler(work, max_concurrency=1) as wrapped: + assert wrapped(cast(HTTPReqCtx, ctx)) is None + assert ctx.deferred is not None + assert server.stopped is False + assert ran.wait(0.05) is False + + ctx.deferred() + assert await to_thread.run_sync(lambda: ran.wait(2.0)) + assert server.stopped is True + assert ctx._conn.tracked is False + finally: + sock.close() + peer.close() + async def test_max_concurrency_caps_parallelism(self, free_port): """N+1 requests against max_concurrency=N: peak in-flight is exactly N.""" in_flight = 0 From 1fb2dbe1ea194d57e97eb8dc70499c313d449a54 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 12:07:19 +0400 Subject: [PATCH 129/286] fix(http): handle streaming limits and path decoding --- localpost/http/_pool.py | 20 +++++++++++++++++++- localpost/http/app.py | 2 +- localpost/http/wsgi.py | 10 ++++++---- tests/http/app.py | 31 +++++++++++++++++++++++++++++++ tests/http/flask_server.py | 15 +++++++++++++++ tests/http/wsgi.py | 14 ++++++++++++++ 6 files changed, 86 insertions(+), 6 deletions(-) diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 32e1fc8..eca7b11 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -35,8 +35,14 @@ ) from localpost import threadtools -from localpost.http._base import SERVICE_UNAVAILABLE_BODY, SERVICE_UNAVAILABLE_RESPONSE +from localpost.http._base import ( + PAYLOAD_TOO_LARGE_BODY, + PAYLOAD_TOO_LARGE_RESPONSE, + SERVICE_UNAVAILABLE_BODY, + SERVICE_UNAVAILABLE_RESPONSE, +) from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request +from localpost.http._types import BodyTooLarge from localpost.http.config import LOGGER_NAME from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error @@ -160,6 +166,8 @@ def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: # Handler bailed cleanly. Connection state is uncertain — close. with suppress(Exception): ctx._conn.close() + except BodyTooLarge: + _emit_body_too_large(ctx) except Exception: logger.exception( "Pool handler raised for %s %r", @@ -199,6 +207,16 @@ async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: tx.close() +def _emit_body_too_large(ctx: HTTPReqCtx) -> None: + """Map worker-side body-limit failures to 413 instead of generic 500.""" + if ctx.response_status is None: + with suppress(Exception): + ctx.complete(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) + return + with suppress(Exception): + ctx._conn.close() + + # --- Public wrappers ---------------------------------------------------- diff --git a/localpost/http/app.py b/localpost/http/app.py index 9dadf0f..5de8b5a 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -43,7 +43,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): with open(f"/tmp/{name}.jpg", "wb") as f: while chunk := ctx.receive(8192): f.write(chunk) - return ("uploaded", 204) + return NativeResponse(status_code=204, headers=[(b"content-length", b"0")]) sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) """ diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index d1e49b8..b81cdb5 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -4,6 +4,7 @@ from collections.abc import Buffer, Callable from io import RawIOBase from typing import Any, final, override +from urllib.parse import unquote_to_bytes from wsgiref.types import WSGIApplication from localpost.http._types import Response as _NativeResponse @@ -128,11 +129,12 @@ def _wsgi_write_deprecated(_: bytes) -> None: def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: request = ctx.request - target = request.target.decode("iso-8859-1") - if "?" in target: - path, query_string = target.split("?", 1) + if b"?" in request.target: + raw_path, raw_query_string = request.target.split(b"?", 1) else: - path, query_string = target, "" + raw_path, raw_query_string = request.target, b"" + path = unquote_to_bytes(raw_path).decode("iso-8859-1") + query_string = raw_query_string.decode("iso-8859-1") environ: dict[str, Any] = { "REQUEST_METHOD": request.method.decode("ascii"), diff --git a/tests/http/app.py b/tests/http/app.py index 782ebf4..0abad03 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -31,6 +31,7 @@ ServerConfig, httptools_server, ) +from tests.http._helpers import drain_socket pytestmark = pytest.mark.anyio @@ -537,6 +538,36 @@ def upload(ctx: HTTPReqCtx): # Streaming handler runs on a worker thread, not the main thread. assert captured["thread"] != threading.get_ident() + async def test_streaming_route_oversized_chunked_body_returns_413(self, free_port, http_backend): + app = HttpApp() + + @app.post("/upload", buffer_body=False) + def upload(ctx: HTTPReqCtx): + while ctx.receive(8192): + pass + return "ok" + + cfg = ServerConfig(host="127.0.0.1", port=free_port, max_body_size=8) + async with _serve_app(app, cfg, backend=_backend_name(http_backend)) as lt: + await lt.started + await _wait_ready(free_port) + + def hit() -> bytes: + with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as sock: + sock.sendall( + b"POST /upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" + b"10\r\n" + + (b"x" * 16) + + b"\r\n0\r\n\r\n" + ) + return drain_socket(sock, deadline=2.0) + + response = await to_thread.run_sync(hit) + assert b"HTTP/1.1 413" in response + assert b"Payload Too Large" in response + lt.shutdown() + await lt.stopped + async def test_streaming_route_body_across_multiple_recvs_httptools(self, free_port): """The client sends headers in one TCP write, body in subsequent writes (so the parser only sees headers in the selector's first diff --git a/tests/http/flask_server.py b/tests/http/flask_server.py index f71ec88..e3b6445 100644 --- a/tests/http/flask_server.py +++ b/tests/http/flask_server.py @@ -42,6 +42,21 @@ def hello(name: str): assert resp.status_code == 200 assert resp.text == "hi alice" + def test_percent_encoded_path_parameters(self, serve_in_thread): + app = Flask(__name__) + + @app.route("/hello/") + def hello(name: str): + return f"hi {name}" + + assert hello + + with serve_in_thread(flask_handler(app)) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/al%20ice", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "hi al ice" + def test_post_body(self, serve_in_thread): app = Flask(__name__) captured: dict = {} diff --git a/tests/http/wsgi.py b/tests/http/wsgi.py index a987d72..bfff6db 100644 --- a/tests/http/wsgi.py +++ b/tests/http/wsgi.py @@ -69,6 +69,20 @@ def app(environ, start_response): assert seen["query"] == "q=1" assert seen["content_type"] == "application/json" + def test_environ_path_info_is_percent_decoded(self, serve_in_thread): + seen = {} + + def app(environ, start_response): + seen["path"] = environ["PATH_INFO"] + seen["query"] = environ["QUERY_STRING"] + start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", "2")]) + return [b"ok"] + + with serve_in_thread(wrap_wsgi(app)) as port: + httpx.get(f"http://127.0.0.1:{port}/users/al%20ice?q=a%20b", timeout=5) + + assert seen == {"path": "/users/al ice", "query": "q=a%20b"} + def test_request_body_streaming(self, serve_in_thread): received = bytearray() From adcadf90bfb7784abd74670f7976dc4d61b47718 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 12:52:22 +0400 Subject: [PATCH 130/286] perf(http): drop redundant bytes() copies in parser callbacks httptools and h11 hand back real bytes objects, so the bytes() wraps in on_headers_complete and the h11 _loop's request build were no-ops. Replace the per-tuple list comprehension on the h11 side with list(event.headers) so we keep a defensive shallow copy without rebuilding every (name, value) tuple. Lazy-merge the multi-fragment URL path through a bytearray to avoid the quadratic bytes concat on the rare fragmented case. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server_h11.py | 14 ++++++++++---- localpost/http/server_httptools.py | 27 ++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index c087a52..29d978a 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -213,11 +213,17 @@ def _loop(self, h: RequestHandler) -> None: cl = _content_length(event.headers) if cl is not None and cl > self.server.config.max_body_size: raise BodyTooLarge(cl) + # h11 hands us ``bytes`` for method/target/version and a list + # of ``(bytes, bytes)`` tuples for headers. ``bytes(b)`` for an + # already-``bytes`` argument returns the same object, so the + # wraps were no-ops; the per-tuple comprehension was the only + # real cost. ``list(event.headers)`` is a shallow copy that + # insulates Request from h11's per-event Headers subclass. req = Request( - method=bytes(event.method), - target=bytes(event.target), - headers=[(bytes(n), bytes(v)) for n, v in event.headers], - http_version=bytes(event.http_version), + method=event.method, + target=event.target, + headers=list(event.headers), + http_version=event.http_version, ) ctx = HTTPReqCtxH11(self.server, self, req) self._ctx = ctx diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index cf7dc32..fb7bffd 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -144,6 +144,12 @@ class HTTPConnHttptools(BaseHTTPConn): # Per-request scratch state populated by the parser callbacks. _cur_target: bytes = b"" + """Request target. Most requests fit in one ``on_url`` call; for the rare + fragmented case we accumulate via ``_cur_target_buf`` and flatten in + ``on_headers_complete``.""" + _cur_target_buf: bytearray | None = None + """Allocated lazily on the second ``on_url`` callback to merge fragments + without creating a new ``bytes`` per fragment.""" _cur_headers: list[tuple[bytes, bytes]] = field(default_factory=list) _cur_expect_100: bool = False _cur_oversize: bool = False # set by on_headers_complete if Content-Length exceeds cap @@ -186,12 +192,20 @@ def __post_init__(self) -> None: def on_message_begin(self) -> None: self._cur_target = b"" + self._cur_target_buf = None self._cur_headers = [] self._cur_expect_100 = False self._cur_oversize = False def on_url(self, url: bytes) -> None: - self._cur_target = self._cur_target + url if self._cur_target else url + if not self._cur_target: + self._cur_target = url + return + # Second+ fragment: keep a bytearray so we don't allocate a new bytes + # per fragment. Common case (single fragment) never enters this branch. + if self._cur_target_buf is None: + self._cur_target_buf = bytearray(self._cur_target) + self._cur_target_buf += url def on_header(self, name: bytes, value: bytes) -> None: n = name.lower() @@ -205,6 +219,11 @@ def on_headers_complete(self) -> None: version = self.parser.get_http_version().encode("ascii") keep_alive = self.parser.should_keep_alive() + # Flatten the multi-fragment target if the rare path was hit. + if self._cur_target_buf is not None: + self._cur_target = bytes(self._cur_target_buf) + self._cur_target_buf = None + # Eager content-length cap check. cl: int | None = None for n, v in self._cur_headers: @@ -219,9 +238,11 @@ def on_headers_complete(self) -> None: self._cur_oversize = True return + # ``method`` and ``self._cur_target`` are already ``bytes`` (httptools + # callbacks deliver real ``bytes``, not memoryview/bytearray); no copy. req = Request( - method=bytes(method), - target=bytes(self._cur_target), + method=method, + target=self._cur_target, headers=self._cur_headers, http_version=version, ) From ce93e6266401793cb0395bbcb482d132d2124b47 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 12:55:06 +0400 Subject: [PATCH 131/286] perf(http): pre-serialize canned protocol-error responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each canned response (400 / 408 / 413 / 500 / 503) now also exports a ``*_WIRE: bytes`` pre-built at module import time — status line + headers + body, ready for ``_send_all``. The httptools backend's ``_try_send_status`` and ``emit_stale_408`` use the pre-built bytes directly, skipping ``_serialize_response`` on the error path. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_base.py | 91 ++++++++++++++++-------------- localpost/http/server_httptools.py | 26 +++++---- 2 files changed, 64 insertions(+), 53 deletions(-) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index b68c42b..83033b2 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -73,57 +73,66 @@ class _OpClose: # Canned protocol-error responses, expressed as neutral types. Each backend # serialises them with its own writer (h11.Connection.send for the h11 impl, -# the hand-written serialiser for the httptools impl). +# the hand-written serialiser for the httptools impl). The httptools backend +# also has access to a fully pre-serialised wire form per canned response — +# see ``_PRESERIALIZED`` below — so error paths skip ``_serialize_response`` +# entirely. + +# Reason phrases used by the pre-serialised wire form below. Kept here (not +# in the httptools backend) so it stays parser-agnostic and the constants +# below resolve at module import time even when httptools isn't installed. +_CANNED_REASONS: dict[int, bytes] = { + 400: b"Bad Request", + 408: b"Request Timeout", + 413: b"Payload Too Large", + 500: b"Internal Server Error", + 503: b"Service Unavailable", +} + + +def _build_canned(status_code: int, body: bytes) -> tuple[Response, bytes]: + """Build a canned ``(Response, wire_bytes)`` pair. + + ``wire_bytes`` is the full status-line + headers + body, ready to send + via :func:`_send_all`. Used by the httptools backend for the protocol + error paths to skip per-error serialisation. + """ + response = Response( + status_code=status_code, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + (b"connection", b"close"), + ], + ) + prelude = bytearray(b"HTTP/1.1 ") + prelude += str(status_code).encode("ascii") + prelude += b" " + prelude += _CANNED_REASONS[status_code] + prelude += b"\r\n" + for name, value in response.headers: + prelude += name + prelude += b": " + prelude += value + prelude += b"\r\n" + prelude += b"\r\n" + return response, bytes(prelude + body) + INTERNAL_ERROR_BODY = b"Internal Server Error" -INTERNAL_ERROR_RESPONSE = Response( - status_code=500, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(INTERNAL_ERROR_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) +INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_WIRE = _build_canned(500, INTERNAL_ERROR_BODY) BAD_REQUEST_BODY = b"Bad Request" -BAD_REQUEST_RESPONSE = Response( - status_code=400, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(BAD_REQUEST_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) +BAD_REQUEST_RESPONSE, BAD_REQUEST_WIRE = _build_canned(400, BAD_REQUEST_BODY) PAYLOAD_TOO_LARGE_BODY = b"Payload Too Large" -PAYLOAD_TOO_LARGE_RESPONSE = Response( - status_code=413, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(PAYLOAD_TOO_LARGE_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) +PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_WIRE = _build_canned(413, PAYLOAD_TOO_LARGE_BODY) REQUEST_TIMEOUT_BODY = b"Request Timeout" -REQUEST_TIMEOUT_RESPONSE = Response( - status_code=408, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(REQUEST_TIMEOUT_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) +REQUEST_TIMEOUT_RESPONSE, REQUEST_TIMEOUT_WIRE = _build_canned(408, REQUEST_TIMEOUT_BODY) SERVICE_UNAVAILABLE_BODY = b"Service Unavailable" -SERVICE_UNAVAILABLE_RESPONSE = Response( - status_code=503, - headers=[ - (b"content-type", b"text/plain; charset=utf-8"), - (b"content-length", str(len(SERVICE_UNAVAILABLE_BODY)).encode("ascii")), - (b"connection", b"close"), - ], -) +SERVICE_UNAVAILABLE_RESPONSE, SERVICE_UNAVAILABLE_WIRE = _build_canned(503, SERVICE_UNAVAILABLE_BODY) class HTTPReqCtx(Protocol): diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index fb7bffd..d7a199d 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -39,12 +39,9 @@ ) from _e from localpost.http._base import ( - BAD_REQUEST_BODY, - BAD_REQUEST_RESPONSE, - PAYLOAD_TOO_LARGE_BODY, - PAYLOAD_TOO_LARGE_RESPONSE, - REQUEST_TIMEOUT_BODY, - REQUEST_TIMEOUT_RESPONSE, + BAD_REQUEST_WIRE, + PAYLOAD_TOO_LARGE_WIRE, + REQUEST_TIMEOUT_WIRE, BaseHTTPConn, BaseServer, BodyHandler, @@ -343,13 +340,13 @@ def __call__(self, h: RequestHandler, /) -> None: self._loop() except _ProtocolError as e: self.server.logger.warning("Bad client input from %s: %s", self.addr, e) - self._try_send_status(BAD_REQUEST_RESPONSE, BAD_REQUEST_BODY) + self._try_send_wire(BAD_REQUEST_WIRE) self.close() except BodyTooLarge: self.server.logger.warning( "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size ) - self._try_send_status(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) + self._try_send_wire(PAYLOAD_TOO_LARGE_WIRE) self.close() def _feed(self, data: bytes) -> None: @@ -421,12 +418,17 @@ def _reset_for_next_request(self) -> None: self.idle = True self.close_at = time.monotonic() + self.server.config.keep_alive_timeout - def _try_send_status(self, response: Response, body: bytes) -> None: - """Best-effort: write a status line + body if we haven't started a response yet.""" + def _try_send_wire(self, wire: bytes) -> None: + """Best-effort: write a pre-serialised status+headers+body block. + + Used for canned protocol-error responses (400 / 408 / 413). The wire + bytes are pre-built at module import time (see ``_base.py``) so this + path skips ``_serialize_response`` entirely. + """ if self._response_started: return try: - _send_all(self, _serialize_response(response) + body) + _send_all(self, wire) except Exception: # noqa: BLE001, S110 — connection is being closed anyway pass @@ -435,7 +437,7 @@ def emit_stale_408(self) -> None: if self.idle or self._response_started: return try: - _send_all(self, _serialize_response(REQUEST_TIMEOUT_RESPONSE) + REQUEST_TIMEOUT_BODY) + _send_all(self, REQUEST_TIMEOUT_WIRE) except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway pass From 1309f445713af577d7f77a79a21f51d6b7a0ff16 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 12:57:55 +0400 Subject: [PATCH 132/286] perf(http): cache 404/405 responses in Router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-build the 404 NativeResponse + body once at module import time, and the per-route 405 (Response, body) pair at Routes.build() time. Dispatch swaps the inline _send_plain helper for ctx.complete(*cached) — no per-miss list allocation or content-length ASCII encode. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/router.py | 71 ++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/localpost/http/router.py b/localpost/http/router.py index 32df0ab..f0402ac 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -17,7 +17,7 @@ from __future__ import annotations import re -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from http import HTTPMethod from http.client import responses as _http_phrases @@ -106,6 +106,11 @@ class Route: allow_header: str """Pre-rendered ``Allow`` header value (e.g. ``"GET, POST"``).""" + method_not_allowed: tuple[_NativeResponse, bytes] + """Pre-built ``(Response, body)`` for the 405 path on this route. Avoids + rebuilding the response (status, headers list, ``content-length`` ASCII + encode) on every method-mismatch.""" + _group_prefix: str """Internal: the named-group prefix this route uses inside the combined regex.""" @@ -218,6 +223,7 @@ def from_routes(cls, routes: Routes) -> Self: template=tmpl, methods=dict(method_map), allow_header=allow_header, + method_not_allowed=_build_method_not_allowed(allow_header), _group_prefix=prefix, ) ) @@ -247,11 +253,11 @@ def _match(self, path: str, method_str: str) -> _MatchResult: try: method = HTTPMethod(method_str) except ValueError: - return _MatchMethodNotAllowed(allow_header=route.allow_header) + return _MatchMethodNotAllowed(route=route) handler = method_map.get(method) if handler is None: - return _MatchMethodNotAllowed(allow_header=route.allow_header) + return _MatchMethodNotAllowed(route=route) return _MatchOk( handler=handler, @@ -269,8 +275,8 @@ def as_handler(self) -> NativeRequestHandler: On a match, attaches :class:`RouteMatch` to ``ctx.attrs["route_match"]`` and delegates to the registered per-route handler. 404 / 405 are - answered inline (via ``ctx.complete``); the body bytes (if any) - are silently drained by the http layer. + answered inline (via ``ctx.complete``) using pre-built responses; + the body bytes (if any) are silently drained by the http layer. """ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: @@ -285,15 +291,11 @@ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: match = self._match(path, method_str) if isinstance(match, _MatchNotFound): - _send_plain(ctx, 404, b"Not Found") + ctx.complete(_NOT_FOUND_RESPONSE, _NOT_FOUND_BODY) return None if isinstance(match, _MatchMethodNotAllowed): - _send_plain( - ctx, - 405, - b"Method Not Allowed", - extra_headers=[(b"Allow", match.allow_header.encode("ascii"))], - ) + response, body = match.route.method_not_allowed + ctx.complete(response, body) return None ctx.attrs["route_match"] = match.match @@ -321,7 +323,7 @@ class _MatchNotFound: @final @dataclass(frozen=True, slots=True) class _MatchMethodNotAllowed: - allow_header: str + route: Route _MatchResult = _MatchOk | _MatchNotFound | _MatchMethodNotAllowed @@ -360,25 +362,36 @@ def _find_template( return None -def _send_plain( - ctx: HTTPReqCtx, +def _build_plain_response( status_code: int, body: bytes, *, - extra_headers: Iterable[tuple[bytes, bytes]] = (), -) -> None: - headers: list[tuple[bytes, bytes]] = [ - (b"content-type", b"text/plain"), - (b"content-length", str(len(body)).encode("ascii")), - *extra_headers, - ] - ctx.complete( - _NativeResponse( - status_code=status_code, - headers=headers, - reason=_http_phrases.get(status_code, "Unknown").encode("iso-8859-1"), - ), - body, + extra_headers: tuple[tuple[bytes, bytes], ...] = (), +) -> _NativeResponse: + return _NativeResponse( + status_code=status_code, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, + ], + reason=_http_phrases.get(status_code, "Unknown").encode("iso-8859-1"), + ) + + +_NOT_FOUND_BODY = b"Not Found" +_NOT_FOUND_RESPONSE = _build_plain_response(404, _NOT_FOUND_BODY) +_METHOD_NOT_ALLOWED_BODY = b"Method Not Allowed" + + +def _build_method_not_allowed(allow_header: str) -> tuple[_NativeResponse, bytes]: + """Pre-build the 405 response for a route. Called once at ``Routes.build()`` + time so the dispatch hot path skips the list / encode / Response build.""" + response = _build_plain_response( + 405, + _METHOD_NOT_ALLOWED_BODY, + extra_headers=((b"Allow", allow_header.encode("ascii")),), ) + return response, _METHOD_NOT_ALLOWED_BODY From 3634dede5f774fc048785ddbc583bcfaed4c5ac1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 13:03:14 +0400 Subject: [PATCH 133/286] feat(http): pre-split path/query_string on Request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Request.path and Request.query_string, populated by the backend at parse time. The httptools backend uses httptools.parse_url (C-level URL parser); the h11 backend does the same split in Python. The h11 backend now also normalises method to uppercase (h11 is lenient on method case; httptools already rejects lowercase). Router and wsgi._build_environ use the pre-split fields directly — the per-dispatch decode + ``b'?' in target`` + split is gone. router_sentry switches to ``req.path`` for the Sentry transaction name. ``Request.target`` is preserved for raw-URI consumers (logging, low-level adapters). API break: code constructing Request() by hand needs the new ``path`` / ``query_string`` fields. In-tree backends and the one test fixture that built a Request directly are updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_types.py | 12 +++++++++++- localpost/http/router.py | 11 +++++------ localpost/http/router_sentry.py | 2 +- localpost/http/server_h11.py | 20 ++++++++++++++++++-- localpost/http/server_httptools.py | 21 +++++++++++++++++++++ localpost/http/wsgi.py | 10 ++++------ tests/http/service.py | 4 +++- 7 files changed, 63 insertions(+), 17 deletions(-) diff --git a/localpost/http/_types.py b/localpost/http/_types.py index 8d81738..a746812 100644 --- a/localpost/http/_types.py +++ b/localpost/http/_types.py @@ -28,9 +28,19 @@ class Request: """Parsed HTTP request line + headers. Body is streamed via :meth:`HTTPReqCtx.receive`.""" method: bytes - """e.g. ``b"GET"`` — uppercased ASCII as sent by the client.""" + """Uppercased ASCII method (e.g. ``b"GET"``). Both backends normalise here + so consumers can compare against ``b"GET"`` / ``b"POST"`` / ... without + case-folding per request.""" target: bytes """Raw request-URI from the request line, including query string.""" + path: bytes + """Path component of ``target`` (everything before ``?``). Pre-split by the + backend so consumers don't redo the split on every request — the httptools + backend uses ``httptools.parse_url`` (C-level), the h11 backend a manual + ``split(b'?', 1)``.""" + query_string: bytes + """Query string of ``target`` (everything after the first ``?``), or ``b""`` + if absent. Pre-split alongside :attr:`path`.""" headers: list[tuple[bytes, bytes]] """Header pairs in arrival order. Names are lowercased; values are as-sent.""" http_version: bytes = b"1.1" diff --git a/localpost/http/router.py b/localpost/http/router.py index f0402ac..12d193e 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -281,12 +281,11 @@ def as_handler(self) -> NativeRequestHandler: def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: req = ctx.request - target = req.target.decode("iso-8859-1") - if "?" in target: - path, _ = target.split("?", 1) - else: - path = target - method_str = req.method.decode("ascii").upper() + # ``req.path`` and ``req.method`` are pre-split / pre-uppercased + # by the backend (httptools.parse_url for httptools, manual + # split for h11) — no per-dispatch decode + split. + path = req.path.decode("iso-8859-1") + method_str = req.method.decode("ascii") match = self._match(path, method_str) diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 3084f75..58ef714 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -52,7 +52,7 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: req = ctx.request method = req.method.decode("ascii") target = req.target.decode("iso-8859-1") - path = target.split("?", 1)[0] if "?" in target else target + path = req.path.decode("iso-8859-1") # Pre-match so the transaction name uses the URI template (low # cardinality). Router's dispatch will match again — cheap. diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 29d978a..a2ecac1 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -219,9 +219,25 @@ def _loop(self, h: RequestHandler) -> None: # wraps were no-ops; the per-tuple comprehension was the only # real cost. ``list(event.headers)`` is a shallow copy that # insulates Request from h11's per-event Headers subclass. + # h11 is lenient on method case (per RFC the method is + # case-sensitive but most clients send uppercase); normalise + # here so consumers can rely on it. + target = event.target + qix = target.find(b"?") + if qix >= 0: + path = target[:qix] + query_string = target[qix + 1 :] + else: + path = target + query_string = b"" + method = event.method + if not method.isupper(): + method = method.upper() req = Request( - method=event.method, - target=event.target, + method=method, + target=target, + path=path, + query_string=query_string, headers=list(event.headers), http_version=event.http_version, ) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index d7a199d..a9c25d8 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -235,11 +235,32 @@ def on_headers_complete(self) -> None: self._cur_oversize = True return + # Pre-split the URL once — C-level parser, beats Python ``find`` / + # ``split``. ``parse_url`` returns ``None`` components for absent + # parts; default each to ``b""``. Malformed targets that fail + # parsing fall back to a manual split. + try: + url = httptools.parse_url(self._cur_target) + path = url.path or b"" + query_string = url.query or b"" + except httptools.HttpParserInvalidURLError: + qix = self._cur_target.find(b"?") + if qix >= 0: + path = self._cur_target[:qix] + query_string = self._cur_target[qix + 1 :] + else: + path = self._cur_target + query_string = b"" + # ``method`` and ``self._cur_target`` are already ``bytes`` (httptools # callbacks deliver real ``bytes``, not memoryview/bytearray); no copy. + # httptools rejects lowercase methods at parse time, so ``method`` is + # already uppercase ASCII. req = Request( method=method, target=self._cur_target, + path=path, + query_string=query_string, headers=self._cur_headers, http_version=version, ) diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index b81cdb5..80ef535 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -129,12 +129,10 @@ def _wsgi_write_deprecated(_: bytes) -> None: def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: request = ctx.request - if b"?" in request.target: - raw_path, raw_query_string = request.target.split(b"?", 1) - else: - raw_path, raw_query_string = request.target, b"" - path = unquote_to_bytes(raw_path).decode("iso-8859-1") - query_string = raw_query_string.decode("iso-8859-1") + # ``request.path`` / ``request.query_string`` are pre-split by the + # backend; only the percent-decode + ISO-8859-1 decode happens here. + path = unquote_to_bytes(request.path).decode("iso-8859-1") + query_string = request.query_string.decode("iso-8859-1") environ: dict[str, Any] = { "REQUEST_METHOD": request.method.decode("ascii"), diff --git a/tests/http/service.py b/tests/http/service.py index b8e6d6b..14b5091 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -434,7 +434,9 @@ class _DeferringCtx: def __init__(self, server: _FakeServer, conn: _FakeConn) -> None: self._server = server self._conn = conn - self.request = Request(method=b"POST", target=b"/upload", headers=[]) + self.request = Request( + method=b"POST", target=b"/upload", path=b"/upload", query_string=b"", headers=[] + ) self.body = b"" self.response_status = None self.attrs = {} From ae63fa7a9a5a9e4f3819f51391a94460700add7e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 13:10:54 +0400 Subject: [PATCH 134/286] perf(http): use manual target split instead of httptools.parse_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench showed plaintext regressed ~8% on httptools after wiring parse_url into on_headers_complete. Micro-bench confirms the C-level parse_url is ~2x slower than the Python find/slice for typical short JSON-API targets — parse_url builds a URL object with multiple bytes attributes, paying Python object-construction overhead per parse. The find/slice path is one C call to ``bytes.find`` plus two slices. Same Request shape (path / query_string fields), faster implementation. Re-bench: httptools plaintext 25,438 RPS / 2.49 ms p50 (back to and slightly above the Phase-10 baseline of 25,230 / 2.50). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server_httptools.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index a9c25d8..3aa9d7c 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -235,22 +235,18 @@ def on_headers_complete(self) -> None: self._cur_oversize = True return - # Pre-split the URL once — C-level parser, beats Python ``find`` / - # ``split``. ``parse_url`` returns ``None`` components for absent - # parts; default each to ``b""``. Malformed targets that fail - # parsing fall back to a manual split. - try: - url = httptools.parse_url(self._cur_target) - path = url.path or b"" - query_string = url.query or b"" - except httptools.HttpParserInvalidURLError: - qix = self._cur_target.find(b"?") - if qix >= 0: - path = self._cur_target[:qix] - query_string = self._cur_target[qix + 1 :] - else: - path = self._cur_target - query_string = b"" + # Pre-split the URL once. Manually find/slice — measured ~2x faster + # than ``httptools.parse_url`` (which is C-level but pays Python + # object-construction overhead per parse). The split is moved into + # the backend so consumers (Router / wsgi) skip per-dispatch work. + target = self._cur_target + qix = target.find(b"?") + if qix >= 0: + path = target[:qix] + query_string = target[qix + 1 :] + else: + path = target + query_string = b"" # ``method`` and ``self._cur_target`` are already ``bytes`` (httptools # callbacks deliver real ``bytes``, not memoryview/bytearray); no copy. From 15f64e2c378da9927eb0b05f29a03976cabe7a72 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 13:12:05 +0400 Subject: [PATCH 135/286] =?UTF-8?q?docs(http):=20document=20Phase=2011=20?= =?UTF-8?q?=E2=80=94=20Tier-3=20diet=20+=20Request=20enrichment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append a Phase 11 section to PERF_FINDINGS.md covering: - redundant bytes() copies removed in both backends - pre-serialised canned protocol-error responses in _base.py - cached 404 / 405 responses in Router - new Request.path / Request.query_string (one-time API break) - the parse_url-vs-manual-split surprise (manual is ~2x faster) Bench numbers within run-to-run noise on the hot scenarios — we're already at the parser ceiling on macOS — but the changes land correctness/maintenance + smaller-path wins (404/405, consumer-side decode removal, error-path serialisation skipped). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 113 +++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 1f22c83..d4261f3 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -946,6 +946,115 @@ considered and dropped — synchronisation against pipelined clients needs *something*, and the unregister is the cheapest correct mechanism (~1 µs each on macOS kqueue). +## Phase 11 (shipped): Tier-3 allocation diet + Request enrichment (2026-04-30) + +Mechanical follow-up to Phase 10, plus one one-time API tightening on +``Request``. Numbers are within run-to-run noise on the macOS bench +because we're already at the C-parser ceiling (Phase 6 / 10 closed the +big gaps); the wins land mostly on smaller paths and on the API +shape. + +### What shipped + +1. **No-op ``bytes()`` wraps removed.** httptools and h11 hand back real + ``bytes`` objects from their callbacks / events; ``bytes(b)`` for a + ``bytes`` argument returns the same object, so the wraps were dead + weight. The h11 backend's per-request ``[(bytes(n), bytes(v)) for n, v + in event.headers]`` is replaced by ``list(event.headers)`` — a + shallow copy that skips the per-tuple alloc but stays insulated from + h11's per-event ``Headers`` subclass. Multi-fragment URL accumulation + in httptools' ``on_url`` switched to a lazy ``bytearray`` so the rare + fragmented case doesn't pay quadratic ``bytes`` concat. +2. **Pre-serialised canned protocol-error responses.** Each + ``*_RESPONSE`` constant in ``_base.py`` now ships with a sibling + ``*_WIRE: bytes`` — full status line + headers + body, built once at + module import time. The httptools backend's ``_try_send_status`` and + ``emit_stale_408`` use the pre-built bytes via ``_send_all`` and skip + ``_serialize_response`` on the error path entirely. +3. **Cached 404 / 405 responses in Router.** The 404 ``NativeResponse`` + + body is a module-level constant; the per-route 405 pair is + pre-built at ``Routes.build()`` time and stored on ``Route`` next to + the existing ``allow_header``. ``Router.dispatch`` uses + ``ctx.complete(*cached)`` directly; the per-miss list / encode / + ``Response`` build is gone. +4. **``Request.path`` and ``Request.query_string``.** New pre-split + fields populated by the backend at parse time. Each consumer + (``Router``, ``wsgi._build_environ``, ``router_sentry``) now reads + them directly instead of doing the same ``target.decode + split`` + per dispatch. The h11 backend also normalises ``method`` to + uppercase here (h11 is lenient on method case; httptools rejects + lowercase at parse time, so it's already uppercase). + +### One implementation surprise: skip ``httptools.parse_url`` + +The first cut of #4 used ``httptools.parse_url(target)`` in the +httptools backend, on the assumption that a C-level URL parser would +beat a Python ``find`` / ``split``. **It didn't** — bench showed +plaintext regressed ~8 % on the httptools backend, and a +``timeit`` micro-bench confirmed ``parse_url`` is ~2× slower than the +manual split for typical short JSON-API targets (it builds a +``URL`` object with multiple bytes attributes — Python +object-construction overhead per parse, on top of the C parsing). +Both backends now use ``target.find(b'?')`` + slice; the API is the +same, and the implementation is consistent across backends. + +### Bench (10 s/cell, standard CPython 3.13 on Darwin arm64) + +| Scenario | Phase 10 RPS / p50 | Phase 11 RPS / p50 | Δ RPS | +| ----------------- | ---------------------: | ---------------------: | -------: | +| `httptools` plaintext | 25,230 / 2.50 ms | 25,438 / 2.49 ms | +1% | +| `httptools` path_param | 25,247 / 2.51 ms | 25,408 / 2.49 ms | +1% | +| `httptools` json_post | 24,712 / 1.28 ms | 24,342 / 1.29 ms | -1% | +| `httptools` profile_update | 6,311 / 5.08 ms | 6,315 / 5.08 ms | 0% | +| `h11` plaintext | 13,225 / 4.81 ms | 13,332 / 4.77 ms | +1% | +| `h11` path_param | ~13 k / ~4.8 ms | 13,324 / 4.77 ms | +2% | +| `h11` json_post | 12,432 / 2.55 ms | 12,420 / 2.56 ms | 0% | + +All within run-to-run noise (~±3 %) on the bench's hot scenarios. +That matches the prediction: the bench is parser-bound at the +selector for httptools and selector-bound at h11 parsing for h11; +the per-request work we trimmed is real but small relative to those +floors. **The wins are on paths the bench doesn't stress hard**: + +- 404 / 405 dispatch is now per-miss-allocation-free (matters under + scanner / probing traffic) +- consumer-side decode + split removed (smaller benefit at 25 k RPS, + larger at higher concurrency where consumers stack up) +- error-path serialisation is gone for the protocol-error responses +- the API shape is now right for httptools' speed: the backend + produces ``path`` / ``query_string`` natively, with the same + cost on h11 + +192 / 192 ``tests/http/`` green. ``just check`` clean on all +touched files. + +### One-time API break + +``Request`` gained two non-default fields (``path``, ``query_string``). +Code constructing ``Request`` by hand needs both. All in-tree +backends and the one test fixture (``tests/http/service.py``) that +built a ``Request`` directly are updated. User code using +``HTTPReqCtx.request.target`` is unaffected — ``target`` is preserved. + +### What we didn't ship + +- **Reusing ``_cur_headers`` across requests on a conn (httptools).** + Investigated and dropped: the previous Request holds the list, so + reuse requires a fresh copy at Request construction (O(N)), + replacing a per-request ``[]`` allocation that's already O(1) via + CPython's list freelist. Net loss, plus it weakens Request's + effective immutability. +- **Caching header-presence flags on ``Response``.** Per-response + scan in ``_scan_response_headers`` is real but tiny (2-4 headers + on typical JSON responses). The clean fix needs mutable cache + slots on a frozen dataclass; not worth the structural change for + the measured impact. The static error responses bypass the scan + entirely now via the pre-serialised path (see above). +- **Lowercasing common header names via an intern table** in the + httptools ``on_header`` callback. Profile evidence didn't push us + there; revisit if a future profile shows ``name.lower()`` + alloc cost climbing. + ## What's left for future perf work Within the [Optimisation boundaries](#optimisation-boundaries) at the top of @@ -969,10 +1078,6 @@ this doc: - **Pre-baked common headers** (Date, Server) cached per-second on the ``BaseServer``, written by both backends — only if we ever auto-emit them. -- **Per-request allocation diet.** Drop redundant ``bytes()`` copies in - the httptools callbacks; cache header-presence flags so we stop scanning - ``_has_connection_close`` / ``_has_content_length_or_te`` linearly per - response. Explicit non-goals (per [Optimisation boundaries](#optimisation-boundaries)): multi-process, async/ASGI on the server side, thread-per-connection rewrite From 5eefc368e5acb9c028dd778c93b2f0eb441a0152 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 11:16:17 +0000 Subject: [PATCH 136/286] WIP on Python bench matrix --- .gitignore | 4 +- benchmarks/.gitignore | 3 + benchmarks/http/_pythons.py | 30 +++++++ benchmarks/http/_setup.py | 42 +++++++++ benchmarks/http/runner.py | 167 +++++++++++++++++++++++++++--------- justfile | 9 ++ 6 files changed, 213 insertions(+), 42 deletions(-) create mode 100644 benchmarks/.gitignore create mode 100644 benchmarks/http/_pythons.py create mode 100644 benchmarks/http/_setup.py diff --git a/.gitignore b/.gitignore index dc9de15..383853f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ celerybeat.pid # Environments .env .venv +.venv-bench/ env/ venv/ ENV/ @@ -169,6 +170,3 @@ cython_debug/ # PyPI configuration file .pypirc - -# Benchmark output — regenerated by `just bench-http`, not authoritative. -benchmarks/http/results/ diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000..1ff75ca --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,3 @@ +# Benchmark output — regenerated by `just bench-http`, not authoritative. +results/ +results*/ diff --git a/benchmarks/http/_pythons.py b/benchmarks/http/_pythons.py new file mode 100644 index 0000000..33cdcd0 --- /dev/null +++ b/benchmarks/http/_pythons.py @@ -0,0 +1,30 @@ +"""Bench-matrix Python interpreters. + +Single source of truth for which Python interpreters the HTTP benchmark +runs against. Read by both ``_setup.py`` (to provision each venv) and +``runner.py`` (as the default value for ``--pythons``). + +Bench venvs live in ``.venv-bench//`` — fully separate from the +project's primary ``.venv``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class BenchPython: + name: str + venv: str + uv_python: str + + @property + def bin(self) -> str: + return f"{self.venv}/bin/python" + + +PYTHONS: tuple[BenchPython, ...] = ( + BenchPython(name="3.13", venv=".venv-bench/3.13", uv_python="3.13"), + BenchPython(name="3.14t", venv=".venv-bench/3.14t", uv_python="3.14t"), +) diff --git a/benchmarks/http/_setup.py b/benchmarks/http/_setup.py new file mode 100644 index 0000000..1974c60 --- /dev/null +++ b/benchmarks/http/_setup.py @@ -0,0 +1,42 @@ +"""Sync every venv in the bench matrix. + +Iterates ``benchmarks.http._pythons.PYTHONS`` and runs +``uv sync --python --all-groups --all-extras`` for each, redirected +to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on failure; +exits non-zero if any sync failed. + +Invoked by ``just bench-deps`` and ``just bench-deps-upgrade``. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from benchmarks.http._pythons import PYTHONS + + +def _sync_one(venv: str, uv_python: str) -> int: + env = {**os.environ, "UV_PROJECT_ENVIRONMENT": venv} + cmd = ["uv", "sync", "--python", uv_python, "--all-groups", "--all-extras"] + return subprocess.run(cmd, env=env, check=False).returncode # noqa: S603 + + +def main() -> int: + failures: list[str] = [] + for py in PYTHONS: + print(f"\n=== {py.name} ({py.venv}, --python {py.uv_python}) ===", flush=True) + if _sync_one(py.venv, py.uv_python) != 0: + failures.append(py.name) + + print() + if failures: + print(f"Failed: {', '.join(failures)}", file=sys.stderr) + return 1 + print(f"OK ({len(PYTHONS)} venv(s) synced)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index 7cbca90..92b9b48 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -1,21 +1,30 @@ """Macro HTTP benchmark runner. -For each (stack, scenario) cell: - 1. Boot the stack as a subprocess (``python -m benchmarks.http.apps.``). +For each (python, stack, scenario) cell: + 1. Boot the stack as a subprocess (`` -m benchmarks.http.apps.``). 2. Poll ``/ping`` until ready (or fail fast). 3. Run ``oha --json -z s -c ...``. 4. Parse JSON; record RPS + p50/p90/p99 + status histogram. 5. SIGTERM the stack, wait, move on. Output: - results/latest.json — raw cells. - results/RESULTS.md — markdown summary, one table per scenario. + results///results.json — raw cells. + results///RESULTS.md — markdown summary, one + table per scenario. + +Each Python interpreter gets its own subdirectory; cross-interpreter numbers +are intentionally not merged (different runtimes are not directly comparable). + +By default the runner targets every interpreter declared in +``benchmarks.http._pythons.PYTHONS``. Override with ``--pythons`` for ad-hoc +runs against a custom interpreter. Run from the repo root:: - just bench-http # full matrix - just bench-http --duration 5 # quick sanity + just bench-http # full matrix + just bench-http --duration 5 # quick sanity just bench-http --stacks localpost_native,flask_gunicorn + just bench-http --pythons 3.13=.venv-bench/3.13/bin/python """ from __future__ import annotations @@ -34,11 +43,19 @@ from datetime import UTC, datetime from pathlib import Path +from benchmarks.http._pythons import PYTHONS from benchmarks.http.scenarios import SCENARIOS, Scenario REPO_ROOT = Path(__file__).resolve().parents[2] RESULTS_DIR = Path(__file__).parent / "results" + +@dataclass(frozen=True, slots=True) +class PythonInterp: + name: str + bin: str + + STACKS: tuple[str, ...] = ( "localpost_native", "localpost_native_s2", @@ -79,6 +96,7 @@ class RunReport: duration_s: int host: str python: str + python_version: str cells: list[Cell] @@ -99,9 +117,9 @@ def _wait_ready(port: int, deadline_s: float = 10.0) -> bool: return False -def _spawn_stack(stack: str, port: int) -> subprocess.Popen: +def _spawn_stack(stack: str, port: int, python_bin: str) -> subprocess.Popen: return subprocess.Popen( # noqa: S603 - [sys.executable, "-m", f"benchmarks.http.apps.{stack}", "--port", str(port)], + [python_bin, "-m", f"benchmarks.http.apps.{stack}", "--port", str(port)], cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, @@ -170,8 +188,8 @@ def _parse_oha(raw: dict) -> dict: } -def _run_cell(stack: str, scenario: Scenario, port: int, duration_s: int) -> Cell | None: - proc = _spawn_stack(stack, port) +def _run_cell(stack: str, scenario: Scenario, port: int, duration_s: int, python_bin: str) -> Cell | None: + proc = _spawn_stack(stack, port, python_bin) try: if not _wait_ready(port): stderr = proc.stderr.read().decode() if proc.stderr else "" @@ -187,18 +205,18 @@ def _run_cell(stack: str, scenario: Scenario, port: int, duration_s: int) -> Cel _kill(proc) -def _write_results(report: RunReport) -> None: - RESULTS_DIR.mkdir(parents=True, exist_ok=True) - (RESULTS_DIR / "latest.json").write_text(json.dumps(asdict(report), indent=2) + "\n") - (RESULTS_DIR / "RESULTS.md").write_text(_render_markdown(report)) +def _write_results(report: RunReport, target_dir: Path) -> None: + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "results.json").write_text(json.dumps(asdict(report), indent=2) + "\n") + (target_dir / "RESULTS.md").write_text(_render_markdown(report)) def _render_markdown(report: RunReport) -> str: out: list[str] = [] - out.append("# HTTP benchmark results\n") + out.append(f"# HTTP benchmark results — Python {report.python}\n") out.append(f"- Run at: `{report.started_at}`") out.append(f"- Host: `{report.host}`") - out.append(f"- Python: `{report.python}`") + out.append(f"- Python: `{report.python}` (`{report.python_version}`)") out.append(f"- Duration per cell: `{report.duration_s}s`") out.append("") out.append("> Numbers are single-process, single-host. Don't read absolute RPS as gospel —") @@ -223,11 +241,51 @@ def _render_markdown(report: RunReport) -> str: return "\n".join(out) +_PROBE_CODE = ( + "import sys;" + "gil=getattr(sys,'_is_gil_enabled',lambda:True)();" + "print(f'{sys.version_info.major}.{sys.version_info.minor}{\"\" if gil else \"t\"}');" + "print(sys.version.split()[0])" +) + + +def _probe_python(python_bin: str) -> tuple[str, str]: + """Return (short label like ``3.14t``, full ``X.Y.Z`` version).""" + out = subprocess.check_output([python_bin, "-c", _PROBE_CODE], text=True).strip().splitlines() # noqa: S603 + return out[0], out[1] + + +def _parse_pythons(arg: str) -> list[PythonInterp]: + """Parse ``name=path,name=path`` (or fall back to the bench matrix).""" + if not arg: + return [PythonInterp(name=p.name, bin=p.bin) for p in PYTHONS] + pythons: list[PythonInterp] = [] + for spec in arg.split(","): + spec = spec.strip() + if not spec: + continue + if "=" not in spec: + raise ValueError(f"--pythons entry must be name=path, got: {spec!r}") + name, _, path = spec.partition("=") + pythons.append(PythonInterp(name=name.strip(), bin=path.strip())) + return pythons + + +def _timestamp_slug() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S") + + def main() -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--duration", type=int, default=20, help="seconds per cell (default: 20)") p.add_argument("--stacks", default="", help="comma-separated stack filter (default: all)") p.add_argument("--scenarios", default="", help="comma-separated scenario filter (default: all)") + p.add_argument( + "--pythons", + default="", + help="comma-separated name=path pairs to override the bench matrix, e.g. " + "'3.13=.venv-bench/3.13/bin/python' (default: every entry in benchmarks.http._pythons.PYTHONS)", + ) p.add_argument("--port-base", type=int, default=18800) args = p.parse_args() @@ -246,31 +304,62 @@ def main() -> int: print("error: no scenarios selected.", file=sys.stderr) return 2 - cells: list[Cell] = [] + try: + pythons = _parse_pythons(args.pythons) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + for py in pythons: + if not Path(py.bin).exists(): + print(f"error: interpreter not found: {py.bin}", file=sys.stderr) + return 2 + + run_dir = RESULTS_DIR / _timestamp_slug() started_at = datetime.now(UTC).isoformat(timespec="seconds") - print(f"Running {len(stacks)} stack(s) x {len(scenarios)} scenario(s) @ {args.duration}s each.") - for stack_idx, stack in enumerate(stacks): - for scen_idx, scenario in enumerate(scenarios): - port = _pick_port(args.port_base, stack_idx * len(SCENARIOS) + scen_idx) - print(f" [{stack}/{scenario.name}] port={port} c={scenario.concurrency} ...", flush=True) - cell = _run_cell(stack, scenario, port, args.duration) - if cell is not None: - print( - f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " - f"({cell.status_2xx} 2xx / {cell.status_other} other)" - ) - cells.append(cell) - - report = RunReport( - started_at=started_at, - duration_s=args.duration, - host=f"{platform.system()} {platform.release()} {platform.machine()}", - python=sys.version.split()[0], - cells=cells, + host = f"{platform.system()} {platform.release()} {platform.machine()}" + + print( + f"Running {len(pythons)} python(s) x {len(stacks)} stack(s) " + f"x {len(scenarios)} scenario(s) @ {args.duration}s each." ) - _write_results(report) - print(f"\nWrote {RESULTS_DIR / 'RESULTS.md'} and {RESULTS_DIR / 'latest.json'}.") - return 0 if cells else 1 + print(f"Results dir: {run_dir}") + + any_cells = False + for py in pythons: + try: + _, full_version = _probe_python(py.bin) + except (subprocess.CalledProcessError, OSError) as e: + print(f"error: cannot probe {py.bin}: {e}", file=sys.stderr) + continue + print(f"\n=== python={py.name} ({full_version}) ===") + cells: list[Cell] = [] + for stack_idx, stack in enumerate(stacks): + for scen_idx, scenario in enumerate(scenarios): + port = _pick_port(args.port_base, stack_idx * len(SCENARIOS) + scen_idx) + print(f" [{py.name}/{stack}/{scenario.name}] port={port} c={scenario.concurrency} ...", flush=True) + cell = _run_cell(stack, scenario, port, args.duration, py.bin) + if cell is not None: + print( + f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " + f"({cell.status_2xx} 2xx / {cell.status_other} other)" + ) + cells.append(cell) + + report = RunReport( + started_at=started_at, + duration_s=args.duration, + host=host, + python=py.name, + python_version=full_version, + cells=cells, + ) + target_dir = run_dir / py.name + _write_results(report, target_dir) + print(f" wrote {target_dir / 'RESULTS.md'}") + if cells: + any_cells = True + + return 0 if any_cells else 1 if __name__ == "__main__": diff --git a/justfile b/justfile index d3944e8..12bea3f 100755 --- a/justfile +++ b/justfile @@ -47,6 +47,15 @@ unit-tests: integration-tests: pytest -m "integration" -n auto -v +[doc("Set up all venvs in the bench matrix (.venv-bench/)")] +bench-deps: + uv run python -m benchmarks.http._setup + +[doc("Refresh lock and re-sync all bench-matrix venvs")] +bench-deps-upgrade: + uv lock --upgrade + uv run python -m benchmarks.http._setup + [doc("Run macro HTTP benchmarks (oha-driven, requires `brew install oha`)")] bench-http *args: uv run --group bench --group dev-http --group dev-hosting-services \ From 0690aa433335317e023af553e27feded77b457a3 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 15:28:03 +0400 Subject: [PATCH 137/286] feat(bench-http): typed stack matrix + HTML report Promote the flat STACKS tuple to typed Stack records (app/backend/selectors/ pool/tags) so the runner can slice by dimension instead of by stack name. New --filter / --group CLI flags with glob + negation; --stacks stays as the verbatim escape hatch. Each cell carries its dimensions in results.json, which the new RESULTS.html (Grid.js, sortable + per-dimension dropdowns) reads directly. Markdown reporter and existing apps/* untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/_render_html.py | 223 ++++++++++++++++++++++++++++++++ benchmarks/http/runner.py | 116 ++++++++++++----- benchmarks/http/stacks.py | 186 ++++++++++++++++++++++++++ justfile | 12 ++ tests/benchmarks/__init__.py | 0 tests/benchmarks/http_stacks.py | 102 +++++++++++++++ 6 files changed, 605 insertions(+), 34 deletions(-) create mode 100644 benchmarks/http/_render_html.py create mode 100644 benchmarks/http/stacks.py create mode 100644 tests/benchmarks/__init__.py create mode 100644 tests/benchmarks/http_stacks.py diff --git a/benchmarks/http/_render_html.py b/benchmarks/http/_render_html.py new file mode 100644 index 0000000..2a170df --- /dev/null +++ b/benchmarks/http/_render_html.py @@ -0,0 +1,223 @@ +"""HTML report renderer for the HTTP benchmark. + +Self-contained ``RESULTS.html`` next to ``RESULTS.md``. Renders one section +per scenario, with sortable tables and per-dimension dropdown filters. The +raw report is embedded as a JSON `` + + + + +""" diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index 92b9b48..b574a9d 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -23,7 +23,10 @@ just bench-http # full matrix just bench-http --duration 5 # quick sanity - just bench-http --stacks localpost_native,flask_gunicorn + just bench-http --group quick # ~4 stacks, fast PR check + just bench-http --filter app=flask # all Flask servers + just bench-http --filter 'backend=lp-*' --filter selectors=1 + just bench-http --stacks localpost_native,flask_gunicorn # exact list (escape hatch) just bench-http --pythons 3.13=.venv-bench/3.13/bin/python """ @@ -39,12 +42,14 @@ import subprocess import sys import time -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from datetime import UTC, datetime from pathlib import Path from benchmarks.http._pythons import PYTHONS +from benchmarks.http._render_html import render_html from benchmarks.http.scenarios import SCENARIOS, Scenario +from benchmarks.http.stacks import GROUPS, Stack, select_stacks REPO_ROOT = Path(__file__).resolve().parents[2] RESULTS_DIR = Path(__file__).parent / "results" @@ -56,26 +61,6 @@ class PythonInterp: bin: str -STACKS: tuple[str, ...] = ( - "localpost_native", - "localpost_native_s2", - "localpost_native_s4", - "localpost_httptools", - "localpost_httptools_s2", - "localpost_httptools_s4", - "localpost_httptools_inline", - "localpost_httptools_inline_s2", - "localpost_httptools_inline_s4", - "localpost_wsgi", - "localpost_flask", - "flask_cheroot", - "flask_gunicorn", - "flask_granian", - "starlette_uvicorn", - "starlette_granian", -) - - @dataclass(slots=True) class Cell: stack: str @@ -88,6 +73,13 @@ class Cell: success_rate: float status_2xx: int status_other: int + # Dimensions copied from `Stack`, so results.json is self-describing + # for the HTML reporter. + app: str = "" + backend: str = "" + selectors: int = 1 + pool: bool = True + tags: list[str] = field(default_factory=list) @dataclass(slots=True) @@ -98,6 +90,8 @@ class RunReport: python: str python_version: str cells: list[Cell] + selection: str = "" + """Human-readable description of the stack selection (group/filter/stacks).""" def _pick_port(base: int, offset: int) -> int: @@ -188,18 +182,27 @@ def _parse_oha(raw: dict) -> dict: } -def _run_cell(stack: str, scenario: Scenario, port: int, duration_s: int, python_bin: str) -> Cell | None: - proc = _spawn_stack(stack, port, python_bin) +def _run_cell(stack: Stack, scenario: Scenario, port: int, duration_s: int, python_bin: str) -> Cell | None: + proc = _spawn_stack(stack.name, port, python_bin) try: if not _wait_ready(port): stderr = proc.stderr.read().decode() if proc.stderr else "" - print(f" [{stack}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) + print(f" [{stack.name}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) return None raw = _run_oha(scenario, port, duration_s) parsed = _parse_oha(raw) - return Cell(stack=stack, scenario=scenario.name, **parsed) + return Cell( + stack=stack.name, + scenario=scenario.name, + app=stack.app, + backend=stack.backend, + selectors=stack.selectors, + pool=stack.pool, + tags=sorted(stack.tags), + **parsed, + ) except Exception as e: # noqa: BLE001 - print(f" [{stack}/{scenario.name}] ERROR: {e}", file=sys.stderr) + print(f" [{stack.name}/{scenario.name}] ERROR: {e}", file=sys.stderr) return None finally: _kill(proc) @@ -207,8 +210,10 @@ def _run_cell(stack: str, scenario: Scenario, port: int, duration_s: int, python def _write_results(report: RunReport, target_dir: Path) -> None: target_dir.mkdir(parents=True, exist_ok=True) - (target_dir / "results.json").write_text(json.dumps(asdict(report), indent=2) + "\n") + payload = asdict(report) + (target_dir / "results.json").write_text(json.dumps(payload, indent=2) + "\n") (target_dir / "RESULTS.md").write_text(_render_markdown(report)) + (target_dir / "RESULTS.html").write_text(render_html(payload)) def _render_markdown(report: RunReport) -> str: @@ -218,6 +223,8 @@ def _render_markdown(report: RunReport) -> str: out.append(f"- Host: `{report.host}`") out.append(f"- Python: `{report.python}` (`{report.python_version}`)") out.append(f"- Duration per cell: `{report.duration_s}s`") + if report.selection: + out.append(f"- Selection: {report.selection}") out.append("") out.append("> Numbers are single-process, single-host. Don't read absolute RPS as gospel —") out.append("> what matters is the relative ordering on the same machine in one run.") @@ -275,10 +282,38 @@ def _timestamp_slug() -> str: return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S") +def _describe_selection(args: argparse.Namespace) -> str: + parts: list[str] = [] + if args.stacks: + parts.append(f"stacks={args.stacks}") + if args.group: + parts.append(f"group={args.group}") + if args.filter: + parts.append("filter=" + ", ".join(args.filter)) + return "; ".join(parts) if parts else "all" + + def main() -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--duration", type=int, default=20, help="seconds per cell (default: 20)") - p.add_argument("--stacks", default="", help="comma-separated stack filter (default: all)") + p.add_argument( + "--stacks", + default="", + help="comma-separated stack name(s) — verbatim escape hatch (bypasses --group/--filter)", + ) + p.add_argument( + "--group", + default="", + help=f"named preset; one of: {', '.join(sorted(GROUPS))}", + ) + p.add_argument( + "--filter", + action="append", + default=[], + metavar="KEY=VAL", + help="dimension filter, e.g. 'app=flask', 'backend=lp-*', 'selectors=1', 'app!=starlette'. " + "Repeatable (filters AND together). Comma-separated values inside one key are OR.", + ) p.add_argument("--scenarios", default="", help="comma-separated scenario filter (default: all)") p.add_argument( "--pythons", @@ -293,10 +328,17 @@ def main() -> int: print("error: 'oha' not found on PATH. Install via 'brew install oha'.", file=sys.stderr) return 2 - stacks = tuple(s for s in (args.stacks.split(",") if args.stacks else STACKS) if s) - unknown = [s for s in stacks if s not in STACKS] - if unknown: - print(f"error: unknown stack(s): {unknown}. Known: {list(STACKS)}", file=sys.stderr) + try: + stacks = select_stacks( + names=tuple(s for s in args.stacks.split(",") if s) if args.stacks else None, + group=args.group or None, + filters=args.filter, + ) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + if not stacks: + print("error: no stacks selected by the given group/filter.", file=sys.stderr) return 2 scenarios = tuple(s for s in SCENARIOS if not args.scenarios or s.name in args.scenarios.split(",")) @@ -317,11 +359,13 @@ def main() -> int: run_dir = RESULTS_DIR / _timestamp_slug() started_at = datetime.now(UTC).isoformat(timespec="seconds") host = f"{platform.system()} {platform.release()} {platform.machine()}" + selection = _describe_selection(args) print( f"Running {len(pythons)} python(s) x {len(stacks)} stack(s) " f"x {len(scenarios)} scenario(s) @ {args.duration}s each." ) + print(f"Selection: {selection}") print(f"Results dir: {run_dir}") any_cells = False @@ -336,7 +380,10 @@ def main() -> int: for stack_idx, stack in enumerate(stacks): for scen_idx, scenario in enumerate(scenarios): port = _pick_port(args.port_base, stack_idx * len(SCENARIOS) + scen_idx) - print(f" [{py.name}/{stack}/{scenario.name}] port={port} c={scenario.concurrency} ...", flush=True) + print( + f" [{py.name}/{stack.name}/{scenario.name}] port={port} c={scenario.concurrency} ...", + flush=True, + ) cell = _run_cell(stack, scenario, port, args.duration, py.bin) if cell is not None: print( @@ -352,6 +399,7 @@ def main() -> int: python=py.name, python_version=full_version, cells=cells, + selection=selection, ) target_dir = run_dir / py.name _write_results(report, target_dir) diff --git a/benchmarks/http/stacks.py b/benchmarks/http/stacks.py new file mode 100644 index 0000000..20837c4 --- /dev/null +++ b/benchmarks/http/stacks.py @@ -0,0 +1,186 @@ +"""Bench stack registry — typed dimensions over the flat stack list. + +Each stack is one row of the matrix. Rather than a flat tuple of names, each +stack carries explicit dimension fields (``app``, ``backend``, ``selectors``, +``pool``, ``tags``). The runner uses these to: + +* select a subset for a run via ``--filter`` / ``--group`` / ``--stacks``; +* annotate each cell in ``results.json`` so the HTML reporter can pivot. + +The ``name`` field doubles as the module name under ``benchmarks/http/apps/``, +so spawning a stack stays a one-liner — no app-module changes needed. + +Filter language (parsed by :func:`parse_filters`): + +* ``key=value`` — exact match. Multiple ``--filter`` flags AND together. +* ``key=a,b`` — comma list inside a key is OR. +* ``key=lp-*`` — glob via ``*`` / ``?`` (fnmatch). +* ``key!=value`` — negation (combines with comma + glob). +* Keys: ``app``, ``backend``, ``selectors``, ``pool``, ``tags``, ``name``. +""" + +from __future__ import annotations + +import fnmatch +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from typing import Final + + +@dataclass(frozen=True, slots=True) +class Stack: + name: str + """Display id; matches ``benchmarks/http/apps/.py``.""" + + app: str + """``native`` | ``wsgi`` | ``flask`` | ``starlette``.""" + + backend: str + """``lp-h11`` | ``lp-httptools`` | ``cheroot`` | ``gunicorn`` | ``granian`` | ``uvicorn``.""" + + selectors: int + """1 | 2 | 4. Always 1 for non-LocalPost backends.""" + + pool: bool + """``False`` = handlers run inline on the selector thread.""" + + tags: frozenset[str] = field(default_factory=frozenset) + """Free-form labels, e.g. ``reference``.""" + + +STACKS: Final[tuple[Stack, ...]] = ( + Stack("localpost_native", app="native", backend="lp-h11", selectors=1, pool=True), + Stack("localpost_native_s2", app="native", backend="lp-h11", selectors=2, pool=True), + Stack("localpost_native_s4", app="native", backend="lp-h11", selectors=4, pool=True), + Stack("localpost_httptools", app="native", backend="lp-httptools", selectors=1, pool=True), + Stack("localpost_httptools_s2", app="native", backend="lp-httptools", selectors=2, pool=True), + Stack("localpost_httptools_s4", app="native", backend="lp-httptools", selectors=4, pool=True), + Stack("localpost_httptools_inline", app="native", backend="lp-httptools", selectors=1, pool=False), + Stack("localpost_httptools_inline_s2", app="native", backend="lp-httptools", selectors=2, pool=False), + Stack("localpost_httptools_inline_s4", app="native", backend="lp-httptools", selectors=4, pool=False), + Stack("localpost_wsgi", app="wsgi", backend="lp-h11", selectors=1, pool=True), + Stack("localpost_flask", app="flask", backend="lp-h11", selectors=1, pool=True), + Stack("flask_cheroot", app="flask", backend="cheroot", selectors=1, pool=True), + Stack("flask_gunicorn", app="flask", backend="gunicorn", selectors=1, pool=True), + Stack("flask_granian", app="flask", backend="granian", selectors=1, pool=True), + Stack( + "starlette_uvicorn", + app="starlette", + backend="uvicorn", + selectors=1, + pool=True, + tags=frozenset({"reference"}), + ), + Stack( + "starlette_granian", + app="starlette", + backend="granian", + selectors=1, + pool=True, + tags=frozenset({"reference"}), + ), +) + +_STACKS_BY_NAME: Final[dict[str, Stack]] = {s.name: s for s in STACKS} + + +GROUPS: Final[dict[str, Callable[[Stack], bool]]] = { + # Smallest sensible matrix — one representative per backend family. + "quick": lambda s: s.name in {"localpost_httptools", "flask_granian", "flask_cheroot", "starlette_uvicorn"}, + "localpost": lambda s: s.backend.startswith("lp-"), + "flask": lambda s: s.app == "flask", + "reference": lambda s: "reference" in s.tags, + "no-reference": lambda s: "reference" not in s.tags, + "single-sel": lambda s: s.selectors == 1, +} + + +_VALID_KEYS: Final[frozenset[str]] = frozenset({"app", "backend", "selectors", "pool", "tags", "name"}) + + +@dataclass(frozen=True, slots=True) +class _Filter: + key: str + values: tuple[str, ...] + """Each entry is an fnmatch pattern.""" + negate: bool + + +def parse_filters(specs: Iterable[str]) -> tuple[_Filter, ...]: + """Parse ``key=v[,v]`` / ``key!=v[,v]`` filter strings. + + Raises ``ValueError`` with a helpful message on bad input. + """ + out: list[_Filter] = [] + for raw in specs: + spec = raw.strip() + if not spec: + continue + negate = False + if "!=" in spec: + key, _, values = spec.partition("!=") + negate = True + elif "=" in spec: + key, _, values = spec.partition("=") + else: + raise ValueError(f"--filter must be 'key=value' or 'key!=value', got: {spec!r}") + key = key.strip() + if key not in _VALID_KEYS: + raise ValueError(f"unknown filter key {key!r}; valid keys: {sorted(_VALID_KEYS)}") + items = tuple(v.strip() for v in values.split(",") if v.strip()) + if not items: + raise ValueError(f"filter {spec!r} has no values") + out.append(_Filter(key=key, values=items, negate=negate)) + return tuple(out) + + +def _stack_field(stack: Stack, key: str) -> tuple[str, ...]: + if key == "tags": + return tuple(sorted(stack.tags)) + if key == "selectors": + return (str(stack.selectors),) + if key == "pool": + return ("true" if stack.pool else "false",) + return (str(getattr(stack, key)),) + + +def _match_filter(stack: Stack, f: _Filter) -> bool: + haystack = _stack_field(stack, f.key) + matched = any(fnmatch.fnmatchcase(h, pat) for h in haystack for pat in f.values) + return not matched if f.negate else matched + + +def select_stacks( + *, + names: Iterable[str] | None = None, + group: str | None = None, + filters: Iterable[str] = (), +) -> tuple[Stack, ...]: + """Resolve which stacks to run. + + Resolution order: + + 1. If ``names`` is given, return those stacks verbatim (escape hatch; + ``group`` and ``filters`` are ignored). + 2. Start from ``GROUPS[group]`` if set, else all of :data:`STACKS`. + 3. AND every parsed filter on top. + + Raises ``ValueError`` for unknown names / groups / filter keys. + """ + if names: + unknown = [n for n in names if n not in _STACKS_BY_NAME] + if unknown: + raise ValueError(f"unknown stack name(s): {unknown}. Known: {sorted(_STACKS_BY_NAME)}") + return tuple(_STACKS_BY_NAME[n] for n in names) + + pool: tuple[Stack, ...] = STACKS + if group is not None: + if group not in GROUPS: + raise ValueError(f"unknown group {group!r}; known: {sorted(GROUPS)}") + pred = GROUPS[group] + pool = tuple(s for s in pool if pred(s)) + + parsed = parse_filters(filters) + if parsed: + pool = tuple(s for s in pool if all(_match_filter(s, f) for f in parsed)) + return pool diff --git a/justfile b/justfile index 12bea3f..4f0e878 100755 --- a/justfile +++ b/justfile @@ -61,6 +61,18 @@ bench-http *args: uv run --group bench --group dev-http --group dev-hosting-services \ python -m benchmarks.http.runner {{ args }} +[doc("Quick PR-time HTTP bench: representative subset of stacks")] +bench-http-quick *args: + just bench-http --group quick --duration 10 {{ args }} + +[doc("Compare Flask across all server backends")] +bench-http-flask *args: + just bench-http --filter app=flask {{ args }} + +[doc("Compare LocalPost variants only (h11/httptools, selectors, inline)")] +bench-http-localpost *args: + just bench-http --group localpost {{ args }} + [doc("Run micro-benchmarks (router, URI template) via pytest-benchmark")] bench-micro *args: uv run --group bench pytest benchmarks/micro/ \ diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/benchmarks/http_stacks.py b/tests/benchmarks/http_stacks.py new file mode 100644 index 0000000..a39252b --- /dev/null +++ b/tests/benchmarks/http_stacks.py @@ -0,0 +1,102 @@ +"""Tests for the HTTP benchmark stack registry + filter parser.""" + +from __future__ import annotations + +import pytest + +from benchmarks.http.stacks import GROUPS, STACKS, parse_filters, select_stacks + + +def test_stacks_registry_unique_names(): + names = [s.name for s in STACKS] + assert len(names) == len(set(names)) + + +def test_select_all_by_default(): + assert select_stacks() == STACKS + + +def test_select_by_app(): + selected = select_stacks(filters=["app=flask"]) + assert {s.name for s in selected} == { + "localpost_flask", + "flask_cheroot", + "flask_gunicorn", + "flask_granian", + } + + +def test_select_by_backend_glob_and_selectors(): + selected = select_stacks(filters=["backend=lp-*", "selectors=1"]) + names = {s.name for s in selected} + assert names == { + "localpost_native", + "localpost_httptools", + "localpost_httptools_inline", + "localpost_wsgi", + "localpost_flask", + } + + +def test_select_negation(): + selected = select_stacks(filters=["app!=starlette"]) + assert all(s.app != "starlette" for s in selected) + assert len(selected) == len(STACKS) - 2 + + +def test_select_multi_value_or(): + selected = select_stacks(filters=["app=wsgi,starlette"]) + assert {s.app for s in selected} == {"wsgi", "starlette"} + + +def test_select_pool_off(): + selected = select_stacks(filters=["pool=false"]) + assert all(not s.pool for s in selected) + assert len(selected) == 3 # the three inline httptools variants + + +def test_group_localpost(): + selected = select_stacks(group="localpost") + assert all(s.backend.startswith("lp-") for s in selected) + + +def test_group_quick_is_small(): + selected = select_stacks(group="quick") + assert 2 <= len(selected) <= 6 + + +def test_group_plus_filter_ands(): + selected = select_stacks(group="localpost", filters=["selectors=1"]) + assert all(s.backend.startswith("lp-") and s.selectors == 1 for s in selected) + + +def test_explicit_names_bypass_filters(): + selected = select_stacks(names=["flask_gunicorn"], filters=["app=starlette"]) + assert [s.name for s in selected] == ["flask_gunicorn"] + + +def test_unknown_filter_key_raises(): + with pytest.raises(ValueError, match="unknown filter key"): + parse_filters(["foo=bar"]) + + +def test_unknown_name_raises(): + with pytest.raises(ValueError, match="unknown stack name"): + select_stacks(names=["does_not_exist"]) + + +def test_unknown_group_raises(): + with pytest.raises(ValueError, match="unknown group"): + select_stacks(group="not-a-group") + + +def test_filter_without_equals_raises(): + with pytest.raises(ValueError, match="must be 'key="): + parse_filters(["just-a-word"]) + + +def test_groups_have_predicates(): + # Smoke check: every advertised group is callable + returns a non-empty subset. + for name, pred in GROUPS.items(): + hits = [s for s in STACKS if pred(s)] + assert hits, f"group {name!r} is empty" From 3bc02cbd68b7835ccc2e3fc90098d5f3ed902a36 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 16:19:59 +0400 Subject: [PATCH 138/286] build(bench-http): install only the groups/extras the bench actually needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uv sync --all-groups --all-extras` was pulling native packages (grpcio-tools, google-cloud-pubsub, confluent-kafka, opentelemetry-*, testcontainers, …) that aren't used by the HTTP benchmark and have spotty wheel coverage on free- threaded Python (3.14t). Switch to an explicit allow-list: groups bench / dev-http / dev-hosting-services + extras http-server / http-fast / http-flask. Verified all 16 stacks still import on both 3.13 and 3.14t bench venvs. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/_setup.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/benchmarks/http/_setup.py b/benchmarks/http/_setup.py index 1974c60..b525502 100644 --- a/benchmarks/http/_setup.py +++ b/benchmarks/http/_setup.py @@ -1,9 +1,14 @@ """Sync every venv in the bench matrix. -Iterates ``benchmarks.http._pythons.PYTHONS`` and runs -``uv sync --python --all-groups --all-extras`` for each, redirected -to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on failure; -exits non-zero if any sync failed. +Iterates ``benchmarks.http._pythons.PYTHONS`` and runs ``uv sync`` for each, +redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on +failure; exits non-zero if any sync failed. + +Only the groups + extras the HTTP benchmark stacks actually import are +installed — ``--all-groups --all-extras`` drags in native packages +(``grpcio-tools``, ``confluent-kafka``, ``google-cloud-pubsub``, +``opentelemetry-*``, …) that have spotty wheel coverage on free-threaded +Python (e.g. 3.14t). Invoked by ``just bench-deps`` and ``just bench-deps-upgrade``. """ @@ -16,10 +21,27 @@ from benchmarks.http._pythons import PYTHONS +# Just what the HTTP bench stacks need at runtime. See `benchmarks/http/apps/` +# for the actual imports. +GROUPS: tuple[str, ...] = ( + "bench", # gunicorn, granian, starlette, pytest-benchmark + "dev-http", # flask, cheroot, a2wsgi, pydantic, httpx + "dev-hosting-services", # uvicorn (also hypercorn + grpcio — unused but light) +) +EXTRAS: tuple[str, ...] = ( + "http-server", # h11 + "http-fast", # httptools + "http-flask", # localpost.http.flask handler +) + def _sync_one(venv: str, uv_python: str) -> int: env = {**os.environ, "UV_PROJECT_ENVIRONMENT": venv} - cmd = ["uv", "sync", "--python", uv_python, "--all-groups", "--all-extras"] + cmd = ["uv", "sync", "--python", uv_python, "--no-default-groups"] + for g in GROUPS: + cmd += ["--group", g] + for e in EXTRAS: + cmd += ["--extra", e] return subprocess.run(cmd, env=env, check=False).returncode # noqa: S603 From 06819e8957e22c8b22344db44c26beaed646923e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 16:42:45 +0400 Subject: [PATCH 139/286] build(bench-http): track restructured bench/dev-http groups + http extra rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bench` is now self-sufficient (starlette, uvicorn, a2wsgi, cheroot, gunicorn, granian); `http-server` extra renamed to `http`; `http-flask` extra removed (flask lives in `dev-http`). Drop `dev-hosting-services` (no longer needed for the bench), simplify the bench-deps install list, and update the `bench-http` recipe accordingly. Verified all 16 stacks still import on both 3.13 and 3.14t bench venvs (89 → 75 packages). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/_setup.py | 10 ++++------ justfile | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/benchmarks/http/_setup.py b/benchmarks/http/_setup.py index b525502..557ff0d 100644 --- a/benchmarks/http/_setup.py +++ b/benchmarks/http/_setup.py @@ -24,14 +24,12 @@ # Just what the HTTP bench stacks need at runtime. See `benchmarks/http/apps/` # for the actual imports. GROUPS: tuple[str, ...] = ( - "bench", # gunicorn, granian, starlette, pytest-benchmark - "dev-http", # flask, cheroot, a2wsgi, pydantic, httpx - "dev-hosting-services", # uvicorn (also hypercorn + grpcio — unused but light) + "bench", # starlette, uvicorn, a2wsgi, cheroot, gunicorn, granian, pytest-benchmark + "dev-http", # flask, pydantic, httpx ) EXTRAS: tuple[str, ...] = ( - "http-server", # h11 - "http-fast", # httptools - "http-flask", # localpost.http.flask handler + "http", # h11 + "http-fast", # httptools ) diff --git a/justfile b/justfile index 4f0e878..f5030e5 100755 --- a/justfile +++ b/justfile @@ -58,7 +58,7 @@ bench-deps-upgrade: [doc("Run macro HTTP benchmarks (oha-driven, requires `brew install oha`)")] bench-http *args: - uv run --group bench --group dev-http --group dev-hosting-services \ + uv run --group bench --group dev-http \ python -m benchmarks.http.runner {{ args }} [doc("Quick PR-time HTTP bench: representative subset of stacks")] From 9950bf28843b537ca04cd8d944f9b1025a77f612 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 12:44:55 +0000 Subject: [PATCH 140/286] deps --- pyproject.toml | 46 +++----- uv.lock | 312 +++++++------------------------------------------ 2 files changed, 55 insertions(+), 303 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 734fd89..e67739a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,15 +49,13 @@ dependencies = [ cron = [ "croniter >=2.0,<4.0", ] -scheduler = [ # Better name?.. +scheduler = [ # "localpost[cron]", "humanize >=3.0,<5.0", "pytimeparse2 ~=1.6", ] -http-server = [ +http = [ "h11 ~=0.16", -# "werkzeug ~=3.1", -# "multipart", ] http-fast = [ # C-based HTTP/1.1 parser (llhttp); enables ``start_httptools_server``. @@ -66,15 +64,9 @@ http-fast = [ http-openapi = [ "msgspec ~=0.19", ] -http-flask = [ - "flask ~=3.1", -] -http-sentry = [ - "sentry-sdk ~=2.51", -] sqs = [ "botocore ~=1.38", # Sync SQS client (default) -# "boto3 ~=1.38", # In case boto3 is available, the default session will be used + "boto3 ~=1.38", # In case boto3 is available, the default session will be used ] kafka = [ "confluent-kafka ~=2.4", @@ -82,23 +74,14 @@ kafka = [ nats = [ "nats-py ~=2.8", ] -pubsub = [ - "google-cloud-pubsub ~=2.28", -] -azure-queue = [ - "azure-identity", - "azure-storage-queue ~=12.8", -] -azure-servicebus = [ - "azure-identity", - "azure-servicebus ~=7.10", -] [dependency-groups] dev = [ "icecream ~=2.1", "structlog ~=25.0", "trio ~=0.32", + + "vulture ~=2.14", ] dev-hosting-services = [ "uvicorn ~=0.30", @@ -115,10 +98,8 @@ dev-consumers = [ ] dev-http = [ "flask ~=3.1", - "cheroot ~=11.1", # "defspec ~=0.5", # Replaced by our own implementation - "pydantic ~=2.11", - "a2wsgi", + "pydantic ~=2.12", "httpx", ] dev-sentry = [ @@ -137,12 +118,9 @@ dev-types = [ "types-protobuf ~=6.29", # Must be in sync with protobuf version, see above "types-grpcio", # Replaces grpc-stubs, see: https://github.com/python/typeshed/pull/11204 "types-boto3-lite[sqs] ~=1.38", - "vulture ~=2.14", ] examples = [ - "fast-depends ~=3.0", "fastapi-slim ~=0.128", - "psycopg[binary,pool] ~=3.2", ] tests = [ "pytest ~=9.0", @@ -162,12 +140,14 @@ tests-integration = [ ] bench = [ # Macro (HTTP load): peer servers + ASGI app stack. - # Flask, cheroot, uvicorn already pulled by dev-http / dev-hosting-services. - "gunicorn ~=23.0", - "granian ~=2.6", - "starlette ~=0.49", + "starlette ~=1.0", + "uvicorn ~=0.30", + "a2wsgi", + "cheroot ~=11.1", + "gunicorn ~=25.0", + "granian ~=2.7", # Micro (pytest-benchmark): regression smoke for router / URI template. - "pytest-benchmark ~=5.1", + "pytest-benchmark ~=5.2", ] [tool.coverage.run] diff --git a/uv.lock b/uv.lock index 7642e31..243f1dd 100644 --- a/uv.lock +++ b/uv.lock @@ -91,64 +91,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/ab/531e86d4a4306f9fedc78609259aa0ee7849d592da63168694ac5b96abb7/aws_lambda_powertools-3.28.0-py3-none-any.whl", hash = "sha256:6db5996784913b4b3a7ff04943cefaef4089440569ecbc2180ce07f08dd87659", size = 933148, upload-time = "2026-04-14T10:34:15.234Z" }, ] -[[package]] -name = "azure-core" -version = "1.39.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, -] - -[[package]] -name = "azure-identity" -version = "1.25.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "msal" }, - { name = "msal-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, -] - -[[package]] -name = "azure-servicebus" -version = "7.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "isodate" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/d2/a5c11d4c955e2875de2383c1af8e43ce4899e8418b22e61d32d2bf103626/azure_servicebus-7.14.3.tar.gz", hash = "sha256:70a63384557aec0bee727740e7b25ded29e9e701b77611764577fd7402389402", size = 534786, upload-time = "2025-10-31T05:30:03.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/e9/d9fd0b2bef14d85b408c51802142b1c8b7bc3ab08514c89432547b1d87d3/azure_servicebus-7.14.3-py3-none-any.whl", hash = "sha256:386f8d32dae8881661ec8d791c38978eca2bbf7ea9f489d6cff8ad9cc6990234", size = 412522, upload-time = "2025-10-31T05:30:05.252Z" }, -] - -[[package]] -name = "azure-storage-queue" -version = "12.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "azure-core" }, - { name = "cryptography" }, - { name = "isodate" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/23/e3b46de244a133675c8c20f3ef2be6cbaf22a41f03e04e1cb2acd609bf5f/azure_storage_queue-12.15.0.tar.gz", hash = "sha256:4e01dcae5aefd0c463f7bae5c75c8a91f955c893f14ed7590fc0cd447ac4666d", size = 197521, upload-time = "2026-01-07T00:18:03.616Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/22/5da115105c9fe7e2fc11804018649b394f60a62735e19642acf336e3807a/azure_storage_queue-12.15.0-py3-none-any.whl", hash = "sha256:056cfce0cd60458f0b7653d804f639098b14593f843899c6c0fc65b3ebe61210", size = 187547, upload-time = "2026-01-07T00:18:05.23Z" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -160,30 +102,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.97" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/65/47670987f2f9e181397872c7ee6415b7b95156d711b7eab6c55f66e575bc/boto3-1.43.0.tar.gz", hash = "sha256:80d44a943ef90aba7958ab31d30c155c198acc8a9581b5846b3878b2c8951086", size = 113143, upload-time = "2026-04-29T22:07:49.084Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/43/84c1888139aa1aaf1dc53f8f914e6ec629e5a571fbafdd42fb2d98ac361f/boto3-1.42.97-py3-none-any.whl", hash = "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd", size = 140556, upload-time = "2026-04-27T20:39:15.509Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl", hash = "sha256:8ebe03754a4b73a5cb6ec2f14cca03ac33bd4760d0adea53da4724845130258b", size = 140497, upload-time = "2026-04-29T22:07:46.216Z" }, ] [[package]] name = "botocore" -version = "1.42.97" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/d2/8e025ba1a4e257879af72d06913272311af79673d82fa2581a351b924317/botocore-1.42.97-py3-none-any.whl", hash = "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc", size = 14950367, upload-time = "2026-04-27T20:39:01.261Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, ] [[package]] @@ -603,19 +545,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] -[[package]] -name = "fast-depends" -version = "3.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/6d/787a21ca8043a8fdb737cf28f645e94a46fc30b44a31de54573299156bad/fast_depends-3.0.8.tar.gz", hash = "sha256:896b16f79a512b6ea1df721b0aa1708a192a06f964be6597e01fcf5412559101", size = 18382, upload-time = "2026-03-02T19:54:28.649Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/1d/e4843e4eeb65f51447b8c22d200d12d8f94f27c97e77bb7162515cc8d61f/fast_depends-3.0.8-py3-none-any.whl", hash = "sha256:4c52c8a3907bca46d43e70e4364d6d016872d9a3aae4bc0c1c85e72e0a6a21c7", size = 25507, upload-time = "2026-03-02T19:54:27.594Z" }, -] - [[package]] name = "fastapi" version = "0.136.1" @@ -938,14 +867,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "23.0.0" +version = "25.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, ] [[package]] @@ -1126,15 +1055,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "isodate" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, -] - [[package]] name = "itsdangerous" version = "2.2.0" @@ -1198,60 +1118,48 @@ dependencies = [ ] [package.optional-dependencies] -azure-queue = [ - { name = "azure-identity" }, - { name = "azure-storage-queue" }, -] -azure-servicebus = [ - { name = "azure-identity" }, - { name = "azure-servicebus" }, -] cron = [ { name = "croniter" }, ] +http = [ + { name = "h11" }, +] http-fast = [ { name = "httptools" }, ] -http-flask = [ - { name = "flask" }, -] http-openapi = [ { name = "msgspec" }, ] -http-sentry = [ - { name = "sentry-sdk" }, -] -http-server = [ - { name = "h11" }, -] kafka = [ { name = "confluent-kafka" }, ] nats = [ { name = "nats-py" }, ] -pubsub = [ - { name = "google-cloud-pubsub" }, -] scheduler = [ { name = "humanize" }, { name = "pytimeparse2" }, ] sqs = [ + { name = "boto3" }, { name = "botocore" }, ] [package.dev-dependencies] bench = [ + { name = "a2wsgi" }, + { name = "cheroot" }, { name = "granian" }, { name = "gunicorn" }, { name = "pytest-benchmark" }, { name = "starlette" }, + { name = "uvicorn" }, ] dev = [ { name = "icecream" }, { name = "structlog" }, { name = "trio" }, + { name = "vulture" }, ] dev-consumers = [ { name = "aws-lambda-powertools" }, @@ -1265,8 +1173,6 @@ dev-hosting-services = [ { name = "uvicorn" }, ] dev-http = [ - { name = "a2wsgi" }, - { name = "cheroot" }, { name = "flask" }, { name = "httpx" }, { name = "pydantic" }, @@ -1285,12 +1191,9 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, { name = "types-protobuf" }, - { name = "vulture" }, ] examples = [ - { name = "fast-depends" }, { name = "fastapi-slim" }, - { name = "psycopg", extra = ["binary", "pool"] }, ] tests = [ { name = "pytest" }, @@ -1312,36 +1215,34 @@ tests-unit = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = "~=4.12" }, - { name = "azure-identity", marker = "extra == 'azure-queue'" }, - { name = "azure-identity", marker = "extra == 'azure-servicebus'" }, - { name = "azure-servicebus", marker = "extra == 'azure-servicebus'", specifier = "~=7.10" }, - { name = "azure-storage-queue", marker = "extra == 'azure-queue'", specifier = "~=12.8" }, + { name = "boto3", marker = "extra == 'sqs'", specifier = "~=1.38" }, { name = "botocore", marker = "extra == 'sqs'", specifier = "~=1.38" }, { name = "confluent-kafka", marker = "extra == 'kafka'", specifier = "~=2.4" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, - { name = "flask", marker = "extra == 'http-flask'", specifier = "~=3.1" }, - { name = "google-cloud-pubsub", marker = "extra == 'pubsub'", specifier = "~=2.28" }, - { name = "h11", marker = "extra == 'http-server'", specifier = "~=0.16" }, + { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", specifier = ">=0.6,<0.8" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, { name = "msgspec", marker = "extra == 'http-openapi'", specifier = "~=0.19" }, { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, - { name = "sentry-sdk", marker = "extra == 'http-sentry'", specifier = "~=2.51" }, ] -provides-extras = ["cron", "scheduler", "http-server", "http-fast", "http-openapi", "http-flask", "http-sentry", "sqs", "kafka", "nats", "pubsub", "azure-queue", "azure-servicebus"] +provides-extras = ["cron", "scheduler", "http", "http-fast", "http-openapi", "sqs", "kafka", "nats"] [package.metadata.requires-dev] bench = [ - { name = "granian", specifier = "~=2.6" }, - { name = "gunicorn", specifier = "~=23.0" }, - { name = "pytest-benchmark", specifier = "~=5.1" }, - { name = "starlette", specifier = "~=0.49" }, + { name = "a2wsgi" }, + { name = "cheroot", specifier = "~=11.1" }, + { name = "granian", specifier = "~=2.7" }, + { name = "gunicorn", specifier = "~=25.0" }, + { name = "pytest-benchmark", specifier = "~=5.2" }, + { name = "starlette", specifier = "~=1.0" }, + { name = "uvicorn", specifier = "~=0.30" }, ] dev = [ { name = "icecream", specifier = "~=2.1" }, { name = "structlog", specifier = "~=25.0" }, { name = "trio", specifier = "~=0.32" }, + { name = "vulture", specifier = "~=2.14" }, ] dev-consumers = [ { name = "aws-lambda-powertools", specifier = "~=3.10" }, @@ -1355,11 +1256,9 @@ dev-hosting-services = [ { name = "uvicorn", specifier = "~=0.30" }, ] dev-http = [ - { name = "a2wsgi" }, - { name = "cheroot", specifier = "~=11.1" }, { name = "flask", specifier = "~=3.1" }, { name = "httpx" }, - { name = "pydantic", specifier = "~=2.11" }, + { name = "pydantic", specifier = "~=2.12" }, ] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, @@ -1373,13 +1272,8 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, { name = "types-protobuf", specifier = "~=6.29" }, - { name = "vulture", specifier = "~=2.14" }, -] -examples = [ - { name = "fast-depends", specifier = "~=3.0" }, - { name = "fastapi-slim", specifier = "~=0.128" }, - { name = "psycopg", extras = ["binary", "pool"], specifier = "~=3.2" }, ] +examples = [{ name = "fastapi-slim", specifier = "~=0.128" }] tests = [ { name = "pytest", specifier = "~=9.0" }, { name = "pytest-timeout", specifier = "~=2.3" }, @@ -1469,32 +1363,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] -[[package]] -name = "msal" -version = "1.36.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, -] - -[[package]] -name = "msal-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "msal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, -] - [[package]] name = "msgspec" version = "0.21.1" @@ -1780,79 +1648,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] -[[package]] -name = "psycopg" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, -] - -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] -pool = [ - { name = "psycopg-pool" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, - { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, - { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, - { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, - { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, - { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, - { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, - { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, - { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, - { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, - { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, - { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, - { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, - { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, -] - -[[package]] -name = "psycopg-pool" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, -] - [[package]] name = "py-cpuinfo" version = "9.0.0" @@ -1991,20 +1786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyjwt" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -2157,14 +1938,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.16.1" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" }, + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, ] [[package]] @@ -2276,15 +2057,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -2352,15 +2133,15 @@ wheels = [ [[package]] name = "types-boto3-lite" -version = "1.42.97" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/ca/29bbdab1e137bf4576c1b4c9fc08f2b50acc0785c199e550f3952dadbb1f/types_boto3_lite-1.42.97.tar.gz", hash = "sha256:c541b203ff83a54a158d0d12b1d82704ec572daf937ed9566aa0767df5415775", size = 74153, upload-time = "2026-04-27T21:17:53.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/9b/6244fe6177d64be2703e2060d74f03128f465a7b74072511cdc97a6ecc7c/types_boto3_lite-1.43.0.tar.gz", hash = "sha256:e4c6dec113e0f34fec71df5d43ca2b9c4d8e1eb1ad8f909966875f783f408f82", size = 74138, upload-time = "2026-04-29T23:08:19.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d9/e0886fe2934c7a13b199e70ea034a6f3035180a43dc0d4380d83de3a5352/types_boto3_lite-1.42.97-py3-none-any.whl", hash = "sha256:8be13a98226dd0117a4df1f38116ed1df9666f4920790d22e996306fe69d9642", size = 43010, upload-time = "2026-04-27T21:17:50.888Z" }, + { url = "https://files.pythonhosted.org/packages/54/cf/9bc74452d627d74900ef8e0abaae15ceb8564dca0ac9783f4896f344d30b/types_boto3_lite-1.43.0-py3-none-any.whl", hash = "sha256:491df290ed18355ab44eeaddd6314d0d3d8a86fd6cb855df36249d753c489afe", size = 42987, upload-time = "2026-04-29T23:08:13.508Z" }, ] [package.optional-dependencies] @@ -2370,11 +2151,11 @@ sqs = [ [[package]] name = "types-boto3-sqs" -version = "1.42.3" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/54/94fd263aedb90113ab95d05c54958b9a71a5c4dd46b7d4faa0f3b7794df0/types_boto3_sqs-1.42.3.tar.gz", hash = "sha256:b7df81d6f1cc94ac9d59ee8ddafb21b1c4e9c1140960156c55e19b1cdc3358e3", size = 23134, upload-time = "2025-12-04T21:12:55.84Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/be/353e9d04a06a48227af527d7e1da5c580f562b87d8b400532e8fe4eac498/types_boto3_sqs-1.43.0.tar.gz", hash = "sha256:a699170d774fb5dfab8d7881e02f102e07706cbfc44874825d4d6ddee77edf61", size = 23183, upload-time = "2026-04-29T23:07:09.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/fa/65a1e627791fe5408a1551bacc081b4e49f7fac87053dd1549957b5edd8d/types_boto3_sqs-1.42.3-py3-none-any.whl", hash = "sha256:9290509e99f22464d39cba39feb8034b295ca312a84e43f8c7ad9b511c488e40", size = 33300, upload-time = "2025-12-04T21:12:54.202Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/6e7daea77680793a1410f134869008f8199ef3c3277c1d6144460cbfd536/types_boto3_sqs-1.43.0-py3-none-any.whl", hash = "sha256:c23e43a13822208d26a946c991ebf9b4ba7194dec5b5537fd4eb850a2a5c25a7", size = 33398, upload-time = "2026-04-29T23:07:06.97Z" }, ] [[package]] @@ -2434,15 +2215,6 @@ 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 = "urllib3" version = "2.6.3" From 7ed3d3efb30bfb4aa299d5c56b11be8f4e4793f7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 17:27:59 +0400 Subject: [PATCH 141/286] fix(bench-http): tolerate JSON null in oha output oha emits explicit JSON null for percentiles / RPS when a cell flooded the server hard enough that no responses fit the latency buckets (we saw it on 3.14t flask_gunicorn/plaintext). dict.get's default only kicks in for missing keys, not present-but-null, so float(None) was crashing _parse_oha. Coerce with a small _as_float helper. Tests cover both the null and missing-key paths so the contract sticks. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/runner.py | 17 ++++++++---- tests/benchmarks/http_runner.py | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/benchmarks/http_runner.py diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index b574a9d..ea463fb 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -163,6 +163,13 @@ def _run_oha(scenario: Scenario, port: int, duration_s: int) -> dict: return json.loads(result.stdout) +def _as_float(v: float | int | str | None, default: float = 0.0) -> float: + # oha emits explicit JSON null for percentiles when no responses fit the + # bucket (e.g. flooded server, near-zero successful requests). dict.get's + # default kicks in only for missing keys, not present-but-null, so coerce. + return default if v is None else float(v) + + def _parse_oha(raw: dict) -> dict: summary = raw.get("summary", {}) pct = raw.get("latencyPercentiles", {}) @@ -171,12 +178,12 @@ def _parse_oha(raw: dict) -> dict: sother = sum(v for k, v in status.items() if not k.startswith("2")) total = s2xx + sother return { - "rps": float(summary.get("requestsPerSec", 0.0)), - "p50_ms": float(pct.get("p50", 0.0)) * 1000, - "p90_ms": float(pct.get("p90", 0.0)) * 1000, - "p99_ms": float(pct.get("p99", 0.0)) * 1000, + "rps": _as_float(summary.get("requestsPerSec")), + "p50_ms": _as_float(pct.get("p50")) * 1000, + "p90_ms": _as_float(pct.get("p90")) * 1000, + "p99_ms": _as_float(pct.get("p99")) * 1000, "total_requests": total, - "success_rate": float(summary.get("successRate", 0.0)), + "success_rate": _as_float(summary.get("successRate")), "status_2xx": s2xx, "status_other": sother, } diff --git a/tests/benchmarks/http_runner.py b/tests/benchmarks/http_runner.py new file mode 100644 index 0000000..c90085b --- /dev/null +++ b/tests/benchmarks/http_runner.py @@ -0,0 +1,48 @@ +"""Tests for the HTTP benchmark runner internals.""" + +from __future__ import annotations + +from benchmarks.http.runner import _parse_oha + + +def test_parse_oha_normal(): + raw = { + "summary": {"requestsPerSec": 1234.5, "successRate": 1.0}, + "latencyPercentiles": {"p50": 0.001, "p90": 0.002, "p99": 0.005}, + "statusCodeDistribution": {"200": 100, "500": 2}, + } + parsed = _parse_oha(raw) + assert parsed["rps"] == 1234.5 + assert parsed["p50_ms"] == 1.0 + assert parsed["p99_ms"] == 5.0 + assert parsed["total_requests"] == 102 + assert parsed["status_2xx"] == 100 + assert parsed["status_other"] == 2 + + +def test_parse_oha_null_percentiles(): + # oha emits JSON null when there were ~0 responses to bucket. dict.get's + # default does NOT kick in for present-but-null keys. + raw = { + "summary": {"requestsPerSec": None, "successRate": None}, + "latencyPercentiles": {"p50": None, "p90": None, "p99": None}, + "statusCodeDistribution": {}, + } + parsed = _parse_oha(raw) + assert parsed == { + "rps": 0.0, + "p50_ms": 0.0, + "p90_ms": 0.0, + "p99_ms": 0.0, + "total_requests": 0, + "success_rate": 0.0, + "status_2xx": 0, + "status_other": 0, + } + + +def test_parse_oha_missing_keys(): + parsed = _parse_oha({}) + assert parsed["rps"] == 0.0 + assert parsed["p50_ms"] == 0.0 + assert parsed["total_requests"] == 0 From 6f13b7e9790c90fdc92c1e8e3945d512acf87b48 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 18:01:03 +0400 Subject: [PATCH 142/286] fix(threadtools): enforce rendezvous channel semantics --- localpost/threadtools.py | 20 +++++++++-- tests/threadtools/channels.py | 67 ++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/localpost/threadtools.py b/localpost/threadtools.py index f2539e9..9ce60d2 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -203,11 +203,14 @@ class ChannelState[T]: capacity: int | None open_send_channels: int open_receive_channels: int + waiting_receivers: int _lock: CancellableLock not_empty: threading.Condition not_full: threading.Condition def __init__(self, capacity: int | None = None): + if capacity is not None and capacity < 0: + raise ValueError("capacity must be >= 0 or None") self.buffer = deque() self.capacity = capacity """ @@ -217,6 +220,7 @@ def __init__(self, capacity: int | None = None): """ self.open_send_channels = 0 self.open_receive_channels = 0 + self.waiting_receivers = 0 self._lock = CancellableLock(threading.RLock()) self.not_empty = cancellable_condition(self._lock) self.not_full = cancellable_condition(self._lock) @@ -227,6 +231,14 @@ def can_put(self) -> bool: raise ClosedResourceError("no more receivers") return self.capacity is None or len(self.buffer) < max(self.capacity, 1) + @property + def can_put_nowait(self) -> bool: + if self.open_receive_channels == 0: # TODO Make this state permanent + raise ClosedResourceError("no more receivers") + if self.capacity == 0: + return len(self.buffer) == 0 and self.waiting_receivers > 0 + return self.capacity is None or len(self.buffer) < self.capacity + def __enter__(self) -> Self: self._lock.acquire() return self @@ -257,7 +269,7 @@ def put_nowait(self, item: T, /) -> None: with self._state as state: if self._closed: raise ClosedResourceError("send channel has been closed") - if not state.can_put: + if not state.can_put_nowait: raise WouldBlock state.buffer.append(item) state.not_empty.notify() @@ -327,7 +339,11 @@ def get(self) -> T: return item if state.open_send_channels == 0: raise EndOfStream("no more senders") - state.not_empty.wait() + state.waiting_receivers += 1 + try: + state.not_empty.wait() + finally: + state.waiting_receivers -= 1 raise ClosedResourceError("receive channel has been closed") @override diff --git a/tests/threadtools/channels.py b/tests/threadtools/channels.py index c704000..a455eba 100644 --- a/tests/threadtools/channels.py +++ b/tests/threadtools/channels.py @@ -1,7 +1,7 @@ import threading import time import pytest -from anyio import EndOfStream, ClosedResourceError +from anyio import ClosedResourceError, EndOfStream, WouldBlock from localpost import threadtools from localpost.threadtools import Channel, SendChannel @@ -202,6 +202,71 @@ def receive(): assert result == ["delayed message"] +def test_negative_capacity_rejected(no_anyio): + """Channel capacities are None, 0, or a positive integer.""" + with pytest.raises(ValueError, match="capacity"): + Channel.create(capacity=-1) + + +def test_rendezvous_put_nowait_requires_waiting_receiver(no_anyio): + """A capacity=0 channel has no spare buffer slot for put_nowait.""" + sender, receiver = Channel.create(capacity=0) + try: + with pytest.raises(WouldBlock): + sender.put_nowait("not-yet") + finally: + sender.close() + receiver.close() + + +def test_rendezvous_put_nowait_hands_off_to_waiting_receiver(no_anyio): + sender, receiver = Channel.create(capacity=0) + received: list[str] = [] + + def receive() -> None: + received.append(receiver.get()) + + receiver_thread = threading.Thread(target=receive) + receiver_thread.start() + + deadline = time.monotonic() + 1.0 + while sender._state.waiting_receivers == 0 and time.monotonic() < deadline: + time.sleep(0.001) + + try: + assert sender._state.waiting_receivers == 1 + sender.put_nowait("ready") + receiver_thread.join(timeout=1.0) + assert not receiver_thread.is_alive() + assert received == ["ready"] + finally: + sender.close() + receiver.close() + + +def test_rendezvous_put_blocks_until_receiver_consumes(no_anyio): + sender, receiver = Channel.create(capacity=0) + sent = threading.Event() + + def send() -> None: + sender.put("value") + sent.set() + + sender_thread = threading.Thread(target=send) + sender_thread.start() + + try: + time.sleep(0.05) + assert not sent.is_set() + assert receiver.get() == "value" + sender_thread.join(timeout=1.0) + assert not sender_thread.is_alive() + assert sent.is_set() + finally: + sender.close() + receiver.close() + + def test_concurrent_stress(no_anyio): """Stress test with many concurrent senders and receivers.""" num_senders = 5 From 5690b8041f2dcadf34d19cc85bc91db0e119a896 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 23:21:39 +0400 Subject: [PATCH 143/286] refactor(http): public server/conn on HTTPReqCtx, RouteMatch as attrs key - HTTPReqCtx Protocol exposes server/conn (was _server/_conn) so handlers and middleware can reach the underlying server / connection without poking at "private" attributes. - attrs is now dict[Any, Any] keyed by RouteMatch class instead of the string "route_match", removing the cross-module string dependency. - Request.headers / Response.headers typed as Sequence to mark them immutable at the public boundary. - Drop redundant fd-in-map guard in BaseHTTPConn.close (the unregister is already wrapped in try/except). - Drop the try/except wrapper around `import httptools` and the __all__ declaration in _pool.py. - Move HttpApp __main__ demo to examples/http/app_server.py. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/app_server.py | 22 +++++++++++ localpost/http/README.md | 2 +- localpost/http/_base.py | 29 +++++++-------- localpost/http/_pool.py | 30 +++++++-------- localpost/http/_service.py | 5 +-- localpost/http/_types.py | 5 ++- localpost/http/app.py | 42 +++++---------------- localpost/http/router.py | 18 ++++----- localpost/http/router_sentry.py | 4 ++ localpost/http/server_h11.py | 59 ++++++++++++++---------------- localpost/http/server_httptools.py | 53 ++++++++++++--------------- localpost/http/wsgi.py | 8 ++-- tests/http/app.py | 13 ++----- tests/http/integration.py | 9 ++--- tests/http/router.py | 4 +- tests/http/service.py | 10 ++--- 16 files changed, 143 insertions(+), 170 deletions(-) create mode 100644 examples/http/app_server.py diff --git a/examples/http/app_server.py b/examples/http/app_server.py new file mode 100644 index 0000000..8e24d67 --- /dev/null +++ b/examples/http/app_server.py @@ -0,0 +1,22 @@ +import json +import sys + +from localpost import hosting +from localpost.http import HttpApp, HTTPReqCtx, ServerConfig + +app = HttpApp() + + +@app.get("/{name}") +def hello(name: str): + return f"Hello, {name}!" + + +@app.post("/{name}/profile") +def update_user_profile(ctx: HTTPReqCtx, name: str): + profile = json.loads(ctx.body) + return {"updated_for": name, "profile": profile} + + +if __name__ == "__main__": + sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) diff --git a/localpost/http/README.md b/localpost/http/README.md index f52312f..8f816d0 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -10,7 +10,7 @@ plus a URI-template router, a WSGI bridge, and a small framework (httptools) accept connections, parse HTTP, dispatch to a `RequestHandler`. ~540 lines of sync code. - **Router**: thin URI-template dispatcher. Matches the request, - attaches a `RouteMatch` to `ctx.attrs["route_match"]`, delegates to + attaches a `RouteMatch` to `ctx.attrs[RouteMatch]`, delegates to the registered handler. 404 / 405 inline. - **HttpApp**: decorator-driven framework — parameter injection, response conversion, worker-pool dispatch, middleware. diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 83033b2..c2d840d 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -139,11 +139,12 @@ class HTTPReqCtx(Protocol): """Per-request context handed to a :data:`RequestHandler`. Both server backends populate concrete implementations of this Protocol - with the same observable surface. ``_server`` and ``_conn`` are not - public API but are documented here because :func:`thread_pool_handler` - reaches into them to borrow the connection for a worker. They're - declared as read-only properties so covariant subtypes (concrete - ``HTTPConnH11`` / ``HTTPConnHttptools``) satisfy the Protocol. + with the same observable surface. ``server`` and ``conn`` give access + to the underlying server / connection for advanced use cases — e.g. + :func:`thread_pool_handler` reaches into them to borrow the connection + for a worker. They're declared as read-only properties so covariant + subtypes (concrete ``HTTPConnH11`` / ``HTTPConnHttptools``) satisfy + the Protocol. The ``body`` attribute is empty when a :data:`RequestHandler` runs (pre-body phase). It is populated by the selector with the fully @@ -158,12 +159,12 @@ class HTTPReqCtx(Protocol): request: Request body: bytes response_status: int | None - attrs: dict[str, Any] + attrs: dict[Any, Any] @property - def _server(self) -> BaseServer: ... + def server(self) -> BaseServer: ... @property - def _conn(self) -> BaseHTTPConn: ... + def conn(self) -> BaseHTTPConn: ... @property def borrowed(self) -> bool: ... def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... @@ -287,7 +288,7 @@ def emit_handler_error(ctx: HTTPReqCtx) -> None: logger.exception("Failed to send 500 after handler error; closing") else: return - ctx._conn.close() + ctx.conn.close() class BaseHTTPConn(ABC): @@ -341,12 +342,10 @@ def close(self) -> None: if not was_tracked: return if threading.get_ident() == self.server._selector_thread_id: - sel = self.server.selector - if self.fd in sel.get_map(): - try: - sel.unregister(self.fd) - except (KeyError, ValueError): - pass + try: + self.server.selector.unregister(self.fd) + except (KeyError, ValueError): + pass else: self.server._ops.append(_OpClose(self.fd)) self.server._wake() diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index eca7b11..daf0cb9 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -46,9 +46,6 @@ from localpost.http.config import LOGGER_NAME from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error -__all__ = ["thread_pool_handler", "streaming_pool_handler"] - - # --- Internal pool primitive -------------------------------------------- @@ -107,15 +104,15 @@ def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: shutdown_event = self._shutdown_event def dispatched(ctx: HTTPReqCtx) -> None: - ctx._server.stop_tracking(ctx._conn) - cancel = RequestCancel(_sock=ctx._conn.sock, _shutdown_event=shutdown_event) + ctx.server.stop_tracking(ctx.conn) + cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) try: tx.put_nowait((ctx, cancel, fn)) except WouldBlock: _reject_overloaded(ctx) except (ClosedResourceError, BrokenResourceError): with suppress(Exception): - ctx._conn.close() + ctx.conn.close() return dispatched @@ -130,7 +127,7 @@ def _reject_overloaded(ctx: HTTPReqCtx) -> None: with suppress(Exception): ctx.complete(SERVICE_UNAVAILABLE_RESPONSE, SERVICE_UNAVAILABLE_BODY) with suppress(Exception): - ctx._conn.close() + ctx.conn.close() @asynccontextmanager @@ -165,7 +162,7 @@ def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: except RequestCancelled: # Handler bailed cleanly. Connection state is uncertain — close. with suppress(Exception): - ctx._conn.close() + ctx.conn.close() except BodyTooLarge: _emit_body_too_large(ctx) except Exception: @@ -181,7 +178,7 @@ def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: # On the success path the handler's ``finish_response`` # already re-tracked the conn via ``_maybe_give_back`` — # we MUST NOT touch it here. We can't read - # ``ctx._conn.tracked`` either: that field is shared + # ``ctx.conn.tracked`` either: that field is shared # with the next request's dispatcher, which clears it # via ``stop_tracking`` before this finally runs. # @@ -191,7 +188,7 @@ def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: # ``cancel.fired`` is the cheap (no-syscall) check. if cancel.fired: with suppress(Exception): - ctx._conn.close() + ctx.conn.close() async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: await to_thread.run_sync(worker, my_rx, limiter=workers_limiter) @@ -214,12 +211,14 @@ def _emit_body_too_large(ctx: HTTPReqCtx) -> None: ctx.complete(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) return with suppress(Exception): - ctx._conn.close() + ctx.conn.close() # --- Public wrappers ---------------------------------------------------- +# TODO Make sure we can configure 1) max_concurrency and 2) backlog. By default backlog should be 0, +# so we block when max_concurrency is reached. def thread_pool_handler( inner: RequestHandler, /, @@ -260,9 +259,7 @@ def thread_pool_handler( @asynccontextmanager -async def _thread_pool_handler( - inner: RequestHandler, max_concurrency: int -) -> AsyncGenerator[RequestHandler]: +async def _thread_pool_handler(inner: RequestHandler, max_concurrency: int) -> AsyncGenerator[RequestHandler]: async with _pool_context(max_concurrency) as pool: def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: @@ -303,6 +300,7 @@ def upload(ctx: HTTPReqCtx) -> None: f.write(chunk) ctx.complete(NativeResponse(204, [(b"content-length", b"0")]), b"") + async with streaming_pool_handler(upload, max_concurrency=4) as h: async with http_server(config, h): ... @@ -313,8 +311,6 @@ def upload(ctx: HTTPReqCtx) -> None: @asynccontextmanager -async def _streaming_pool_handler( - inner: _WorkFn, max_concurrency: int -) -> AsyncGenerator[RequestHandler]: +async def _streaming_pool_handler(inner: _WorkFn, max_concurrency: int) -> AsyncGenerator[RequestHandler]: async with _pool_context(max_concurrency) as pool: yield pool.dispatch_streaming(inner) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 55ffaaa..53eeb1d 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -44,6 +44,7 @@ def _serve( raise ValueError(f"selectors must be >= 1 (got {selectors})") if selectors == 1: + def run_single(lt: ServiceLifetime) -> Awaitable[None]: def run_server() -> None: with start(config, handler) as server: @@ -64,9 +65,7 @@ def run_server() -> None: # with ``thread_pool_handler`` the underlying channel naturally accepts # multiple producers. actual_config = ( - dataclasses.replace(config, port=_resolve_ephemeral_port(config.host)) - if config.port == 0 - else config + dataclasses.replace(config, port=_resolve_ephemeral_port(config.host)) if config.port == 0 else config ) async def run_multi(lt: ServiceLifetime) -> None: diff --git a/localpost/http/_types.py b/localpost/http/_types.py index a746812..5360fea 100644 --- a/localpost/http/_types.py +++ b/localpost/http/_types.py @@ -13,6 +13,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field __all__ = [ @@ -41,7 +42,7 @@ class Request: query_string: bytes """Query string of ``target`` (everything after the first ``?``), or ``b""`` if absent. Pre-split alongside :attr:`path`.""" - headers: list[tuple[bytes, bytes]] + headers: Sequence[tuple[bytes, bytes]] """Header pairs in arrival order. Names are lowercased; values are as-sent.""" http_version: bytes = b"1.1" """HTTP version as bare bytes (``b"1.1"`` or ``b"1.0"``).""" @@ -52,7 +53,7 @@ class Response: """Final response (2xx-5xx) — exactly one per request.""" status_code: int - headers: list[tuple[bytes, bytes]] + headers: Sequence[tuple[bytes, bytes]] reason: bytes = b"" """Reason phrase. Empty → backend supplies a default for the status code.""" diff --git a/localpost/http/app.py b/localpost/http/app.py index 5de8b5a..12dcf55 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -28,15 +28,18 @@ app = HttpApp() + @app.get("/{name}") def hello(name: str): return f"Hello, {name}!" + @app.post("/{name}/profile") def update_profile(ctx: HTTPReqCtx, name: str): profile = json.loads(ctx.body) return {"updated": name, "profile": profile} + @app.post("/{name}/avatar", buffer_body=False) def upload_avatar(ctx: HTTPReqCtx, name: str): # Streaming: ctx.body is empty here; read chunks via ctx.receive(...) @@ -45,6 +48,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): f.write(chunk) return NativeResponse(status_code=204, headers=[(b"content-length", b"0")]) + sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) """ @@ -52,7 +56,6 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): import inspect import json -import sys from collections.abc import Callable, Sequence from dataclasses import dataclass from http import HTTPMethod @@ -93,9 +96,7 @@ def _build_resolvers(fn: Callable[..., Any], path: str) -> dict[str, _ParamResol resolvers: dict[str, _ParamResolver] = {} for name, param in sig.parameters.items(): if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - raise ValueError( - f"handler {fn_name!r}: *args / **kwargs not supported in handler signatures" - ) + raise ValueError(f"handler {fn_name!r}: *args / **kwargs not supported in handler signatures") if name in template_vars: resolvers[name] = _make_path_arg_resolver(name) continue @@ -278,9 +279,7 @@ def deco(fn: Callable[..., Any]) -> Callable[..., Any]: # ----- Building ----- - def _build_buffered_handler( - self, route: _Route, pool: _Pool | None - ) -> RequestHandler: + def _build_buffered_handler(self, route: _Route, pool: _Pool | None) -> RequestHandler: """Buffered route: pre-body returns BodyHandler that, if pooled, dispatches to a worker; otherwise runs inline on selector.""" resolvers = _build_resolvers(route.fn, route.path) @@ -305,9 +304,7 @@ def pre_body_inline(_ctx: HTTPReqCtx) -> BodyHandler: return self._with_route_middleware(pre_body_inline, route.middleware) - def _build_streaming_handler( - self, route: _Route, pool: _Pool - ) -> RequestHandler: + def _build_streaming_handler(self, route: _Route, pool: _Pool) -> RequestHandler: """Streaming route: pre-body borrows + queues for a worker, which runs the user fn on a blocking conn (body not buffered). """ @@ -320,13 +317,9 @@ def streaming_inner(ctx: HTTPReqCtx) -> None: response, body = _wrap_response(result) ctx.complete(response, body) - return self._with_route_middleware( - pool.dispatch_streaming(streaming_inner), route.middleware - ) + return self._with_route_middleware(pool.dispatch_streaming(streaming_inner), route.middleware) - def _with_route_middleware( - self, handler: RequestHandler, middleware: tuple[Middleware, ...] - ) -> RequestHandler: + def _with_route_middleware(self, handler: RequestHandler, middleware: tuple[Middleware, ...]) -> RequestHandler: if not middleware: return handler return compose(*middleware)(handler) @@ -381,20 +374,3 @@ async def _app_service(): yield return _app_service() - - -# ---- Example usage ---------------------------------------------------- - -if __name__ == "__main__": - app = HttpApp() - - @app.get("/{name}") - def hello(name: str): - return f"Hello, {name}!" - - @app.post("/{name}/profile") - def update_user_profile(ctx: HTTPReqCtx, name: str): - profile = json.loads(ctx.body) - return {"updated_for": name, "profile": profile} - - sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) diff --git a/localpost/http/router.py b/localpost/http/router.py index 12d193e..d3228b9 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -1,7 +1,7 @@ """URI-template router for LocalPost HTTP — a thin dispatcher middleware. Matches the request URI against a table of compiled :class:`URITemplate` s, -attaches the :class:`RouteMatch` to ``ctx.attrs["route_match"]``, and +attaches the :class:`RouteMatch` to ``ctx.attrs[RouteMatch]``, and delegates to the matched route's :data:`localpost.http.RequestHandler`. 404 / 405 are answered inline on the selector. The :class:`Router` @@ -75,7 +75,7 @@ def match(self, uri: str) -> dict[str, str] | None: @final @dataclass(frozen=True, slots=True) class RouteMatch: - """Attached to ``ctx.attrs["route_match"]`` by :class:`Router` on a successful match. + """Attached to ``ctx.attrs[RouteMatch]`` by :class:`Router` on a successful match. Read via :func:`route_match` from inside a route handler or middleware. """ @@ -91,7 +91,7 @@ def route_match(ctx: HTTPReqCtx) -> RouteMatch: Raises :exc:`KeyError` if called outside a route handler (or before the Router has run). """ - return ctx.attrs["route_match"] + return ctx.attrs[RouteMatch] @final @@ -130,12 +130,14 @@ class Routes: routes = Routes() + @routes.get("/hello/{name}") def hello(ctx: HTTPReqCtx) -> BodyHandler | None: match = route_match(ctx) ctx.complete(NativeResponse(...), b"hi " + match.path_args["name"].encode()) return None + router = routes.build() """ @@ -151,9 +153,7 @@ def add( key = _find_template(self.paths, template) or URITemplate.parse(template) self.paths.setdefault(key, {})[m] = handler - def _decorator( - self, method: HTTPMethod, template: str - ) -> Callable[[NativeRequestHandler], NativeRequestHandler]: + def _decorator(self, method: HTTPMethod, template: str) -> Callable[[NativeRequestHandler], NativeRequestHandler]: def deco(handler: NativeRequestHandler) -> NativeRequestHandler: self.add(method, template, handler) return handler @@ -273,7 +273,7 @@ def _match(self, path: str, method_str: str) -> _MatchResult: def as_handler(self) -> NativeRequestHandler: """Return a :data:`localpost.http.RequestHandler` that dispatches via this router. - On a match, attaches :class:`RouteMatch` to ``ctx.attrs["route_match"]`` + On a match, attaches :class:`RouteMatch` to ``ctx.attrs[RouteMatch]`` and delegates to the registered per-route handler. 404 / 405 are answered inline (via ``ctx.complete``) using pre-built responses; the body bytes (if any) are silently drained by the http layer. @@ -297,7 +297,7 @@ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: ctx.complete(response, body) return None - ctx.attrs["route_match"] = match.match + ctx.attrs[RouteMatch] = match.match return match.handler(ctx) return dispatch @@ -392,5 +392,3 @@ def _build_method_not_allowed(allow_header: str) -> tuple[_NativeResponse, bytes extra_headers=((b"Allow", allow_header.encode("ascii")),), ) return response, _METHOD_NOT_ALLOWED_BODY - - diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 58ef714..cb0406d 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -12,8 +12,12 @@ Usage:: routes = Routes() + + @routes.get("/books/{id}") def get_book(ctx): ... + + router = routes.build() sentry_sdk.init(dsn=..., traces_sample_rate=1.0) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index a2ecac1..d068030 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -49,9 +49,10 @@ def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11.InformationalResponse: + headers: list[tuple[bytes, bytes]] = list(r.headers) if isinstance(r, Response): - return h11.Response(status_code=r.status_code, headers=r.headers, reason=r.reason) - return h11.InformationalResponse(status_code=r.status_code, headers=r.headers, reason=r.reason) + return h11.Response(status_code=r.status_code, headers=headers, reason=r.reason) + return h11.InformationalResponse(status_code=r.status_code, headers=headers, reason=r.reason) def _content_length(headers) -> int | None: @@ -167,11 +168,7 @@ def _loop(self, h: RequestHandler) -> None: if self._continuation is not None: # Body is wanted (handler returned a continuation) — # tell the client to send it. Fall through to recv. - self.send( - h11.InformationalResponse( - status_code=100, headers=[], reason="Continue" - ) - ) + self.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) else: # No body needed — short-circuit with 417. self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) @@ -312,24 +309,24 @@ class HTTPReqCtxH11: chunk in a single ``sendall``. """ - _server: BaseServer - _conn: HTTPConnH11 + server: BaseServer + conn: HTTPConnH11 request: Request body: bytes = b"" response_status: int | None = None - attrs: dict[str, Any] = field(default_factory=dict) + attrs: dict[Any, Any] = field(default_factory=dict) _pending_header_bytes: bytes | None = None @property def borrowed(self) -> bool: - return not self._conn.tracked + return not self.conn.tracked @contextmanager def borrow(self) -> Iterator[HTTPReqCtxH11]: """Switch the conn out of selector tracking for the duration of the block.""" assert not self.borrowed - self._server.stop_tracking(self._conn) + self.server.stop_tracking(self.conn) try: yield self finally: @@ -345,13 +342,13 @@ def _maybe_give_back(self) -> None: # "no more data" signal. ``gettimeout`` reads cached state on the # socket object (no syscall), so the no-fallback common case # pays nothing. - sock = self._conn.sock + sock = self.conn.sock if sock.gettimeout() != 0: try: sock.settimeout(0) except OSError: pass - self._server.track(self._conn) + self.server.track(self.conn) def complete(self, response: Response, body: bytes | None = None) -> None: self.start_response(response) @@ -364,11 +361,11 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: callers receive the buffered body via ``ctx.body``. After the :data:`BodyHandler` continuation runs, the body has been fully consumed and the parser's state side reads ``b""``.""" - parser = self._conn.parser + parser = self.conn.parser if parser.their_state is h11.DONE: return b"" if parser.they_are_waiting_for_100_continue: - self._conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) + self.conn.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) while True: event = parser.next_event() if event is h11.NEED_DATA: @@ -378,20 +375,20 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: # so the next iteration's ``recv`` skips the BlockingIOError # path entirely; the give-back path resets it on hand-back. # On the selector thread we restore non-blocking inline. - sock = self._conn.sock + sock = self.conn.sock try: - self._conn.receive(size) + self.conn.receive(size) except BlockingIOError: - sock.settimeout(self._server.config.rw_timeout) + sock.settimeout(self.server.config.rw_timeout) try: - self._conn.receive(size) + self.conn.receive(size) finally: - if self._conn.tracked: + if self.conn.tracked: sock.settimeout(0) elif isinstance(event, h11.Data): - self._conn.body_bytes_received += len(event.data) - if self._conn.body_bytes_received > self._server.config.max_body_size: - raise BodyTooLarge(self._conn.body_bytes_received) + self.conn.body_bytes_received += len(event.data) + if self.conn.body_bytes_received > self.server.config.max_body_size: + raise BodyTooLarge(self.conn.body_bytes_received) return bytes(event.data) elif isinstance(event, h11.EndOfMessage): return b"" @@ -403,17 +400,17 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: self.response_status = response.status_code # Drive the h11 state machine, but buffer the bytes for a # coalesced ``sendall`` with the first body chunk. - payload = self._conn.parser.send(_to_h11_response(response)) + payload = self.conn.parser.send(_to_h11_response(response)) self._pending_header_bytes = bytes(payload) if payload else b"" else: # Informational responses (100 Continue, etc.) flush immediately. - self._conn.send(_to_h11_response(response)) + self.conn.send(_to_h11_response(response)) def send(self, chunk: Buffer, /) -> None: # h11 wants bytes; widen the public API to any Buffer (memoryview, # bytearray, …) so callers can avoid an explicit copy. chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk) - payload = self._conn.parser.send(h11.Data(data=chunk_bytes)) + payload = self.conn.parser.send(h11.Data(data=chunk_bytes)) if payload is None: return if self._pending_header_bytes is not None: @@ -427,7 +424,7 @@ def finish_response(self) -> None: # Coalesce: ``EndOfMessage`` payload (chunked terminator or nothing) # plus any still-pending header bytes (empty-body case) emit in one # ``sendall``. - eom_payload = self._conn.parser.send(h11.EndOfMessage()) + eom_payload = self.conn.parser.send(h11.EndOfMessage()) eom_bytes = bytes(eom_payload) if eom_payload else b"" if self._pending_header_bytes is not None: combined = self._pending_header_bytes + eom_bytes @@ -445,16 +442,16 @@ def finish_response(self) -> None: # # If h11 needs more bytes (handler didn't read a non-empty body), # close the conn — keep-alive isn't safe with un-drained body bytes. - parser = self._conn.parser + parser = self.conn.parser while parser.their_state is not h11.DONE: event = parser.next_event() if event is h11.NEED_DATA or event is h11.PAUSED or isinstance(event, h11.ConnectionClosed): - self._conn.close() + self.conn.close() return self._maybe_give_back() def _sock_sendall(self, payload: bytes) -> None: - _send_all(self._conn, payload) + _send_all(self.conn, payload) def start_http_server(config: ServerConfig, handler: RequestHandler, /): diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 3aa9d7c..5e04b4c 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -26,17 +26,12 @@ import socket import time -from collections.abc import Buffer, Callable, Iterator +from collections.abc import Buffer, Callable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass, field from typing import Any, final -try: - import httptools -except ImportError as _e: # pragma: no cover - raise ImportError( - "httptools is not installed. Install with: pip install localpost[http-fast]" - ) from _e +import httptools from localpost.http._base import ( BAD_REQUEST_WIRE, @@ -95,7 +90,7 @@ def _serialize_response(r: Response | InformationalResponse) -> bytes: return bytes(out) -def _scan_response_headers(headers: list[tuple[bytes, bytes]]) -> tuple[bool, bool]: +def _scan_response_headers(headers: Sequence[tuple[bytes, bytes]]) -> tuple[bool, bool]: """One-pass scan: ``(has_connection_close, has_content_length_or_te)``. Combined to avoid two walks per response. @@ -343,9 +338,7 @@ def on_message_complete(self) -> None: except BodyTooLarge: raise except Exception: - self.server.logger.exception( - "Body handler raised for %s %r", ctx.request.method, ctx.request.target - ) + self.server.logger.exception("Body handler raised for %s %r", ctx.request.method, ctx.request.target) emit_handler_error(ctx) self._message_complete = True @@ -475,15 +468,15 @@ class HTTPReqCtxHttptools: chunk together; one per subsequent chunk). """ - _server: BaseServer - _conn: HTTPConnHttptools + server: BaseServer + conn: HTTPConnHttptools request: Request _expect_100_continue: bool = False _keep_alive: bool = True body: bytes = b"" response_status: int | None = None - attrs: dict[str, Any] = field(default_factory=dict) + attrs: dict[Any, Any] = field(default_factory=dict) _continue_sent: bool = False _chunked: bool = False """``True`` if the backend auto-added ``Transfer-Encoding: chunked`` because @@ -495,12 +488,12 @@ class HTTPReqCtxHttptools: @property def borrowed(self) -> bool: - return not self._conn.tracked + return not self.conn.tracked @contextmanager def borrow(self) -> Iterator[HTTPReqCtxHttptools]: assert not self.borrowed - self._server.stop_tracking(self._conn) + self.server.stop_tracking(self.conn) try: yield self finally: @@ -516,8 +509,8 @@ def _defer_streaming_dispatch(self, dispatcher: Callable[[HTTPReqCtxHttptools], body bytes from the current packet are buffered by ``on_body``, then let ``_feed`` run the handoff once callbacks are done. """ - self._conn._streaming_active = True - self._conn._deferred_streaming_dispatch = lambda: dispatcher(self) + self.conn._streaming_active = True + self.conn._deferred_streaming_dispatch = lambda: dispatcher(self) def _maybe_give_back(self) -> None: if not self.borrowed: @@ -528,13 +521,13 @@ def _maybe_give_back(self) -> None: # non-blocking socket. ``gettimeout`` reads cached state on the # socket object (no syscall), so the no-fallback common case # pays nothing. - sock = self._conn.sock + sock = self.conn.sock if sock.gettimeout() != 0: try: sock.settimeout(0) except OSError: pass - self._server.track(self._conn) + self.server.track(self.conn) def complete(self, response: Response, body: bytes | None = None) -> None: self.start_response(response) @@ -561,8 +554,8 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: if self._expect_100_continue and not self._continue_sent: self._send_continue() - conn = self._conn - rw = self._server.config.rw_timeout + conn = self.conn + rw = self.server.config.rw_timeout if conn._streaming_active: while not conn._streaming_body_buf and not conn._streaming_eom: @@ -607,7 +600,7 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: conn.sock.settimeout(0) def _send_continue(self) -> None: - _send_all(self._conn, b"HTTP/1.1 100 Continue\r\n\r\n") + _send_all(self.conn, b"HTTP/1.1 100 Continue\r\n\r\n") self._continue_sent = True def start_response(self, response: Response | InformationalResponse, /) -> None: @@ -616,10 +609,10 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: # bytes without buffering (rare path). if isinstance(response, Response): self.response_status = response.status_code - self._conn._response_started = True + self.conn._response_started = True has_close, has_framing = _scan_response_headers(response.headers) if has_close or not self._keep_alive: - self._conn._close_after_response = True + self.conn._close_after_response = True if not has_framing: # Auto-frame: no Content-Length / Transfer-Encoding → chunked. # Without framing, an HTTP/1.1 client would wait for the @@ -633,7 +626,7 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: self._pending_header_bytes = _serialize_response(response) else: # Informational responses (e.g., 100 Continue) flush immediately. - _send_all(self._conn, _serialize_response(response)) + _send_all(self.conn, _serialize_response(response)) def send(self, chunk: Buffer, /) -> None: if not isinstance(chunk, bytes): @@ -643,20 +636,20 @@ def send(self, chunk: Buffer, /) -> None: framed = (f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n") if self._chunked and chunk else chunk if self._pending_header_bytes is not None: # Headers + first body chunk in one syscall. - _send_all(self._conn, self._pending_header_bytes + framed) + _send_all(self.conn, self._pending_header_bytes + framed) self._pending_header_bytes = None elif framed: - _send_all(self._conn, framed) + _send_all(self.conn, framed) def finish_response(self) -> None: # Flush any still-buffered headers (empty-body case) plus the # chunked terminator, in one sendall. terminator = b"0\r\n\r\n" if self._chunked else b"" if self._pending_header_bytes is not None: - _send_all(self._conn, self._pending_header_bytes + terminator) + _send_all(self.conn, self._pending_header_bytes + terminator) self._pending_header_bytes = None elif terminator: - _send_all(self._conn, terminator) + _send_all(self.conn, terminator) self._maybe_give_back() diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 80ef535..8767c4c 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -82,9 +82,7 @@ def start_response( exc_info = None status_code = int(status.split(" ", 1)[0]) reason = status.split(" ", 1)[1] if " " in status else "" - wire_headers = [ - (name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in headers - ] + wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in headers] response_state["response"] = _NativeResponse( status_code=status_code, headers=wire_headers, @@ -141,8 +139,8 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: "QUERY_STRING": query_string, "CONTENT_TYPE": "", "CONTENT_LENGTH": "", - "SERVER_NAME": ctx._server.config.host, - "SERVER_PORT": str(ctx._server.port), + "SERVER_NAME": ctx.server.config.host, + "SERVER_PORT": str(ctx.server.port), "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", "wsgi.version": (1, 0), "wsgi.url_scheme": "http", diff --git a/tests/http/app.py b/tests/http/app.py index 0abad03..59371ec 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -556,9 +556,7 @@ def hit() -> bytes: with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as sock: sock.sendall( b"POST /upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n" - b"10\r\n" - + (b"x" * 16) - + b"\r\n0\r\n\r\n" + b"10\r\n" + (b"x" * 16) + b"\r\n0\r\n\r\n" ) return drain_socket(sock, deadline=2.0) @@ -626,8 +624,8 @@ async def test_httptools_deferred_streaming_dispatch_sees_current_feed_body(self def handler(ctx: HTTPReqCtx): def dispatch(req_ctx: HTTPReqCtx) -> None: - req_ctx._server.stop_tracking(req_ctx._conn) - conn = cast(Any, req_ctx._conn) + req_ctx.server.stop_tracking(req_ctx.conn) + conn = cast(Any, req_ctx.conn) captured["buffer_before_receive"] = bytes(conn._streaming_body_buf) captured["eom_before_receive"] = conn._streaming_eom chunks: list[bytes] = [] @@ -649,10 +647,7 @@ def dispatch(req_ctx: HTTPReqCtx) -> None: def hit() -> bytes: payload = b"a" * 1024 with socket.create_connection(("127.0.0.1", free_port), timeout=3.0) as s: - s.sendall( - b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 1024\r\n\r\n" - + payload - ) + s.sendall(b"POST /upload HTTP/1.1\r\nHost: x\r\nContent-Length: 1024\r\n\r\n" + payload) buf = b"" while b"\r\n\r\nok" not in buf: chunk = s.recv(4096) diff --git a/tests/http/integration.py b/tests/http/integration.py index 57f442b..42eaaa2 100644 --- a/tests/http/integration.py +++ b/tests/http/integration.py @@ -48,7 +48,7 @@ def _spawn(port: int, *, backend: str = "asyncio", mode: str = "router", **extra "LP_TEST_MODE": mode, **extra_env, } - return subprocess.Popen( # noqa: S603 + return subprocess.Popen( [sys.executable, "-u", "-m", "tests.http._integration_app"], env=env, stdout=subprocess.PIPE, @@ -68,8 +68,7 @@ def app_process(request) -> Iterator[tuple[int, subprocess.Popen]]: if proc.poll() is not None: stdout, stderr = proc.communicate(timeout=1) pytest.fail( - f"Server did not become ready on port {port} (params={params}).\n" - f"stdout: {stdout!r}\nstderr: {stderr!r}" + f"Server did not become ready on port {port} (params={params}).\nstdout: {stdout!r}\nstderr: {stderr!r}" ) yield port, proc finally: @@ -108,9 +107,7 @@ def test_concurrent_requests_span_multiple_threads(self, app_process): port, _ = app_process n = 8 with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex: - futures = [ - ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) for _ in range(n) - ] + futures = [ex.submit(lambda: httpx.get(f"http://127.0.0.1:{port}/slow", timeout=5)) for _ in range(n)] responses = [f.result() for f in futures] assert all(r.status_code == 200 for r in responses) diff --git a/tests/http/router.py b/tests/http/router.py index e9409fa..ad1f740 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -1,6 +1,6 @@ """Tests for localpost.http.router — the lean dispatcher. -The Router attaches a :class:`RouteMatch` to ``ctx.attrs["route_match"]`` +The Router attaches a :class:`RouteMatch` to ``ctx.attrs[RouteMatch]`` and delegates to the registered :class:`localpost.http.RequestHandler`. 404 / 405 are answered inline. Pythonic helpers (response converters, param injection, etc.) live in ``HttpApp``; tests for those live elsewhere. @@ -136,7 +136,7 @@ def handler(ctx: HTTPReqCtx) -> BodyHandler | None: assert captured["path_args"] == {"book_id": "xyz-123"} def test_route_match_outside_router_raises(self): - # ``route_match`` reads ``ctx.attrs["route_match"]``; outside a router + # ``route_match`` reads ``ctx.attrs[RouteMatch]``; outside a router # the key is missing and we get the natural KeyError. Documented. ctx = Mock() ctx.attrs = {} diff --git a/tests/http/service.py b/tests/http/service.py index 14b5091..9be3855 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -432,11 +432,9 @@ def stop_tracking(self, conn: _FakeConn) -> None: class _DeferringCtx: def __init__(self, server: _FakeServer, conn: _FakeConn) -> None: - self._server = server - self._conn = conn - self.request = Request( - method=b"POST", target=b"/upload", path=b"/upload", query_string=b"", headers=[] - ) + self.server = server + self.conn = conn + self.request = Request(method=b"POST", target=b"/upload", path=b"/upload", query_string=b"", headers=[]) self.body = b"" self.response_status = None self.attrs = {} @@ -467,7 +465,7 @@ def work(_ctx: HTTPReqCtx) -> None: ctx.deferred() assert await to_thread.run_sync(lambda: ran.wait(2.0)) assert server.stopped is True - assert ctx._conn.tracked is False + assert ctx.conn.tracked is False finally: sock.close() peer.close() From 54bf908eb7b2809e749aab11f2c51a51e550ec53 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 19:40:37 +0000 Subject: [PATCH 144/286] WIP --- localpost/experimental/openapi/app.py | 2 +- localpost/http/_types.py | 5 +++-- localpost/http/server_h11.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/localpost/experimental/openapi/app.py b/localpost/experimental/openapi/app.py index e75999c..02b584b 100644 --- a/localpost/experimental/openapi/app.py +++ b/localpost/experimental/openapi/app.py @@ -12,8 +12,8 @@ import msgspec import localpost.experimental.openapi.spec as openapi_spec -from localpost.http.router import RequestCtx, RequestHandler, Response, Router, URITemplate from localpost.experimental.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML +from localpost.http.router import RequestCtx, RequestHandler, Response, Router, URITemplate P = ParamSpec("P") R = TypeVar("R") diff --git a/localpost/http/_types.py b/localpost/http/_types.py index 5360fea..9d6f8db 100644 --- a/localpost/http/_types.py +++ b/localpost/http/_types.py @@ -53,7 +53,7 @@ class Response: """Final response (2xx-5xx) — exactly one per request.""" status_code: int - headers: Sequence[tuple[bytes, bytes]] + headers: Sequence[tuple[bytes, bytes]] = field(default_factory=list) reason: bytes = b"" """Reason phrase. Empty → backend supplies a default for the status code.""" @@ -63,8 +63,9 @@ class InformationalResponse: """1xx response (100 Continue, 102 Processing, …). Multiple may precede the final response.""" status_code: int - headers: list[tuple[bytes, bytes]] = field(default_factory=list) + headers: Sequence[tuple[bytes, bytes]] = field(default_factory=list) reason: bytes = b"" + """Reason phrase. Empty → backend supplies a default for the status code.""" class BodyTooLarge(Exception): diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index d068030..e541621 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -21,7 +21,7 @@ from collections.abc import Buffer, Iterator from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Any, final +from typing import Any, cast, final import h11 @@ -49,7 +49,7 @@ def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11.InformationalResponse: - headers: list[tuple[bytes, bytes]] = list(r.headers) + headers = cast(list, r.headers) if isinstance(r, Response): return h11.Response(status_code=r.status_code, headers=headers, reason=r.reason) return h11.InformationalResponse(status_code=r.status_code, headers=headers, reason=r.reason) @@ -68,6 +68,7 @@ def _content_length(headers) -> int | None: @final @dataclass(eq=False, slots=True) +# TODO Rename to just HTTPConn class HTTPConnH11(BaseHTTPConn): server: BaseServer sock: socket.socket From 63338ccd3ffbb8f4c5dc1236f902444dc1a00597 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 1 May 2026 01:23:08 +0400 Subject: [PATCH 145/286] feat(http): admission semaphore + configurable backlog on thread_pool_handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool's effective headroom was 2*max_concurrency: capacity=max_concurrency on the channel plus max_concurrency worker threads. Tighten it so max_concurrency means exactly N in-flight, and add a backlog knob (default 0) for callers that want a small queue behind the worker pool. Admission is now a threading.Semaphore of size max_concurrency + backlog, acquired non-blocking by the selector on dispatch and released by the worker in finally. The channel is unbounded — the semaphore is the budget, the channel is just the conduit. Bounded-channel-only doesn't work: capacity=0 serializes to one in-flight at a time (rendezvous needs an empty buffer + waiting receiver, but the buffer briefly holds the item between put and pop), and capacity=backlog>0 503s bursts before workers wake because items in running workers don't count toward the buffer. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/README.md | 8 +- localpost/http/_pool.py | 83 +++++++++++----- localpost/http/app.py | 13 ++- tests/http/service.py | 198 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 268 insertions(+), 34 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index 8f816d0..f4fc3a2 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -258,7 +258,7 @@ on the documented public Flask API. | `http_server(config, handler)` | `localpost.http._service` | `@hosting.service` — runs the server loop with `handler`. No thread pool. | | `wsgi_server(config, app)` | `localpost.http._service` | Same, for a generic WSGI app. | | `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask`. | -| `thread_pool_handler(inner, *, max_concurrency)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a worker thread. | +| `thread_pool_handler(inner, *, max_concurrency, backlog=0)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a worker thread. Admission cap = `max_concurrency + backlog`; default `backlog=0` means exactly `max_concurrency` in flight. | The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. The @@ -305,6 +305,12 @@ What this gives you: there is no per-route pool API). - **No max\_concurrency on `http_server`.** The pool is the wrapper's concern; `http_server` has one job and one job only. +- **Admission is the pool's concern, not the server's.** The pool admits up to + `max_concurrency + backlog` requests at once (a `threading.Semaphore` + acquired by the selector on dispatch, released by the worker on completion). + The default `backlog=0` is the strict-N case: every dispatch needs a free + worker, otherwise it 503s. Bump `backlog` to let bursts queue briefly + instead of bouncing. ## Design diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index daf0cb9..c3aa056 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -26,10 +26,8 @@ from anyio import ( BrokenResourceError, - CapacityLimiter, ClosedResourceError, EndOfStream, - WouldBlock, create_task_group, to_thread, ) @@ -54,24 +52,27 @@ class _Pool: - """Shared channel + workers, plus dispatch helpers. + """Shared channel + workers + admission semaphore, plus dispatch helpers. Created/torn-down by :func:`_pool_context`. Two ``dispatch_*`` methods let callers wire either a post-body :data:`BodyHandler` or a pre-body streaming handler onto the same worker pool. Admission is - non-blocking from the selector thread: if the bounded pending queue is - full, the request receives 503 and the connection is closed. + non-blocking from the selector thread: the semaphore caps the number + of in-flight + buffered requests at ``max_concurrency + backlog``; if + the cap is hit, the request receives 503 and the connection is closed. """ - __slots__ = ("_shutdown_event", "_tx") + __slots__ = ("_admission", "_shutdown_event", "_tx") def __init__( self, tx: threadtools.SendChannel[_WorkItem], shutdown_event: threading.Event, + admission: threading.Semaphore, ) -> None: self._tx = tx self._shutdown_event = shutdown_event + self._admission = admission def dispatch_buffered(self, body_handler: BodyHandler) -> BodyHandler: """Wrap a :data:`BodyHandler` so when it's invoked post-body it @@ -101,16 +102,21 @@ def pre_body(ctx: HTTPReqCtx) -> BodyHandler | None: def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: tx = self._tx + admission = self._admission shutdown_event = self._shutdown_event def dispatched(ctx: HTTPReqCtx) -> None: ctx.server.stop_tracking(ctx.conn) + if not admission.acquire(blocking=False): + _reject_overloaded(ctx) + return cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) try: tx.put_nowait((ctx, cancel, fn)) - except WouldBlock: - _reject_overloaded(ctx) except (ClosedResourceError, BrokenResourceError): + # Pool shutting down; semaphore release happens here since the + # worker won't run. + admission.release() with suppress(Exception): ctx.conn.close() @@ -131,22 +137,30 @@ def _reject_overloaded(ctx: HTTPReqCtx) -> None: @asynccontextmanager -async def _pool_context(max_concurrency: int) -> AsyncGenerator[_Pool]: - """Open a worker pool for ``max_concurrency`` concurrent requests. +async def _pool_context(max_concurrency: int, backlog: int) -> AsyncGenerator[_Pool]: + """Open a worker pool for ``max_concurrency`` concurrent requests with an + optional ``backlog`` of additional buffered requests. Yields a :class:`_Pool` whose ``dispatch_*`` helpers can be used to wire handlers onto the shared channel. On exit, signals in-flight handlers via the cancel event and waits for workers to drain. + + Admission is gated by a :class:`threading.Semaphore` of size + ``max_concurrency + backlog``: the selector acquires a permit on + dispatch, the worker releases it when the handler finishes. The + channel itself is unbounded — it's only the conduit; the semaphore + is the budget. ``backlog=0`` makes the budget exactly + ``max_concurrency``: only an idle worker can pick up a new request. """ if max_concurrency < 1: raise ValueError("max_concurrency must be >= 1") + if backlog < 0: + raise ValueError("backlog must be >= 0") logger = logging.getLogger(LOGGER_NAME) - tx, rx = threadtools.Channel[_WorkItem].create(capacity=max_concurrency) + tx, rx = threadtools.Channel[_WorkItem].create(capacity=None) shutdown_event = threading.Event() - # Dedicated limiter so workers don't consume slots from AnyIO's global - # default thread pool. Each worker holds a slot for its full lifetime. - workers_limiter = CapacityLimiter(max_concurrency) + admission = threading.Semaphore(max_concurrency + backlog) def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: with my_rx: @@ -189,16 +203,17 @@ def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: if cancel.fired: with suppress(Exception): ctx.conn.close() + admission.release() async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: - await to_thread.run_sync(worker, my_rx, limiter=workers_limiter) + await to_thread.run_sync(worker, my_rx) async with create_task_group() as tg: for _ in range(max_concurrency): tg.start_soon(run_worker, rx.clone()) rx.close() try: - yield _Pool(tx, shutdown_event) + yield _Pool(tx, shutdown_event, admission) finally: shutdown_event.set() tx.close() @@ -217,13 +232,12 @@ def _emit_body_too_large(ctx: HTTPReqCtx) -> None: # --- Public wrappers ---------------------------------------------------- -# TODO Make sure we can configure 1) max_concurrency and 2) backlog. By default backlog should be 0, -# so we block when max_concurrency is reached. def thread_pool_handler( inner: RequestHandler, /, *, max_concurrency: int, + backlog: int = 0, ) -> AbstractAsyncContextManager[RequestHandler]: """Async context manager: yields a ``RequestHandler`` that offloads body-handler continuations to a worker thread. @@ -237,11 +251,17 @@ def thread_pool_handler( - :data:`BodyHandler` — the wrapper returns a *new* continuation that, when invoked by the selector after the body has been buffered, ``stop_tracking`` s the conn and queues the work for a - worker. If all workers and the bounded pending queue are full, the + worker. If neither a worker nor a backlog slot is available, the request is rejected with 503 instead of blocking the selector. ``max_concurrency`` workers pull from the channel and run the continuation under a per-request cancel scope. + ``backlog`` controls the channel buffer between selector and workers. + The default ``0`` is rendezvous — a request is dispatched only when a + worker is currently idle on ``Channel.get()``. ``backlog=K`` lets up + to K extra requests sit in the channel before 503s start. Total + system capacity = ``max_concurrency + backlog``. + Per-request cancellation surfaces through :func:`localpost.http.check_cancelled` (client disconnect via non-blocking ``MSG_PEEK``; pool shutdown via a single @@ -255,12 +275,16 @@ def thread_pool_handler( """ if max_concurrency < 1: raise ValueError("max_concurrency must be >= 1") - return _thread_pool_handler(inner, max_concurrency) + if backlog < 0: + raise ValueError("backlog must be >= 0") + return _thread_pool_handler(inner, max_concurrency, backlog) @asynccontextmanager -async def _thread_pool_handler(inner: RequestHandler, max_concurrency: int) -> AsyncGenerator[RequestHandler]: - async with _pool_context(max_concurrency) as pool: +async def _thread_pool_handler( + inner: RequestHandler, max_concurrency: int, backlog: int +) -> AsyncGenerator[RequestHandler]: + async with _pool_context(max_concurrency, backlog) as pool: def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: result = inner(ctx) @@ -276,6 +300,7 @@ def streaming_pool_handler( /, *, max_concurrency: int, + backlog: int = 0, ) -> AbstractAsyncContextManager[RequestHandler]: """Async context manager: yields a ``RequestHandler`` that runs ``inner`` on a worker thread with a *borrowed* conn — body **not** @@ -288,6 +313,10 @@ def streaming_pool_handler( ``inner`` shape: ``(ctx) -> None``. Must complete the response or close the conn. + ``backlog`` has the same meaning as in :func:`thread_pool_handler`: + default ``0`` is rendezvous; ``backlog=K`` allows K extra queued + requests before 503s start. + Per-request cancellation surfaces through :func:`localpost.http.check_cancelled` — same triggers as :func:`thread_pool_handler`. @@ -307,10 +336,14 @@ def upload(ctx: HTTPReqCtx) -> None: """ if max_concurrency < 1: raise ValueError("max_concurrency must be >= 1") - return _streaming_pool_handler(inner, max_concurrency) + if backlog < 0: + raise ValueError("backlog must be >= 0") + return _streaming_pool_handler(inner, max_concurrency, backlog) @asynccontextmanager -async def _streaming_pool_handler(inner: _WorkFn, max_concurrency: int) -> AsyncGenerator[RequestHandler]: - async with _pool_context(max_concurrency) as pool: +async def _streaming_pool_handler( + inner: _WorkFn, max_concurrency: int, backlog: int +) -> AsyncGenerator[RequestHandler]: + async with _pool_context(max_concurrency, backlog) as pool: yield pool.dispatch_streaming(inner) diff --git a/localpost/http/app.py b/localpost/http/app.py index 12dcf55..ad359d5 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -190,6 +190,12 @@ class HttpApp: and non-blocking). Streaming routes (``buffer_body=False``) require a pool — registering one with ``max_concurrency=0`` raises at service-startup time. + backlog: Additional channel buffer between selector and workers. + Default ``0`` = rendezvous: a request is dispatched only when + a worker is currently waiting; otherwise the selector replies + 503 immediately. ``backlog=K`` allows up to K extra requests + to sit in the channel before 503s start. Total system capacity + is ``max_concurrency + backlog``. middleware: App-level middlewares wrapping the entire dispatcher (after Router). Outermost-first. """ @@ -198,11 +204,15 @@ def __init__( self, *, max_concurrency: int = 32, + backlog: int = 0, middleware: Sequence[Middleware] = (), ) -> None: if max_concurrency < 0: raise ValueError("max_concurrency must be >= 0") + if backlog < 0: + raise ValueError("backlog must be >= 0") self.max_concurrency = max_concurrency + self.backlog = backlog self._middleware = tuple(middleware) self._routes: list[_Route] = [] @@ -360,6 +370,7 @@ def service( raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") server_fn = _BACKEND_FACTORIES[backend] max_concurrency = self.max_concurrency + backlog = self.backlog @hosting.service async def _app_service(): @@ -368,7 +379,7 @@ async def _app_service(): async with server_fn(config, inner, selectors=selectors): yield return - async with _pool_context(max_concurrency) as pool: + async with _pool_context(max_concurrency, backlog) as pool: inner = self._build_router_handler(pool) async with server_fn(config, inner, selectors=selectors): yield diff --git a/tests/http/service.py b/tests/http/service.py index 9be3855..543c268 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -45,12 +45,13 @@ async def _serve_pooled( handler: RequestHandler, *, max_concurrency: int, + backlog: int = 0, ) -> AsyncGenerator[ServiceLifetimeView]: """Compose ``thread_pool_handler`` + ``http_server`` and yield the http_server lifetime view. Tests own shutdown via the yielded lifetime; the pool drains on exit. """ - async with thread_pool_handler(handler, max_concurrency=max_concurrency) as wrapped: + async with thread_pool_handler(handler, max_concurrency=max_concurrency, backlog=backlog) as wrapped: async with serve(http_server(cfg, wrapped)) as lt: yield lt @@ -175,7 +176,7 @@ def wait_for_three(): await lt.stopped async def test_max_concurrency_one_serializes(self, free_port): - """With a 1-slot pool, requests are handled one at a time.""" + """With a 1-slot pool plus a queue, all requests run, just one at a time.""" in_flight = 0 peak = 0 lock = threading.Lock() @@ -194,7 +195,7 @@ def handler(_ctx: HTTPReqCtx): return body_handler cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=1, backlog=2) as lt: await lt.started await _wait_server_ready(free_port) @@ -294,6 +295,185 @@ async def test_invalid_max_concurrency(self): # need to enter it. thread_pool_handler(_handler_200(), max_concurrency=0) + async def test_invalid_backlog(self): + with pytest.raises(ValueError, match="backlog"): + thread_pool_handler(_handler_200(), max_concurrency=1, backlog=-1) + + +class TestBacklogAdmission: + """``backlog`` controls the channel buffer between selector and workers. + + Default ``backlog=0`` is rendezvous: the selector's ``put_nowait`` only + succeeds when a worker is currently waiting on ``Channel.get()``; + otherwise the request is rejected with 503 immediately. ``backlog=K`` + admits up to ``max_concurrency + K`` requests before 503s start. + """ + + async def test_rendezvous_default_rejects_when_workers_busy(self, free_port): + """backlog=0, max_concurrency=1: a 2nd concurrent request 503s + immediately because no worker is waiting on get().""" + entered = threading.Event() + release = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + entered.set() + release.wait(timeout=5.0) + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + def handler(_ctx: HTTPReqCtx): + return body_handler + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: + await lt.started + await _wait_server_ready(free_port) + + try: + # Block the only worker. + async def slow(): + return await _get(f"http://127.0.0.1:{free_port}/", timeout=5.0) + + async with anyio.create_task_group() as tg: + + async def hold_first(): + await slow() + + tg.start_soon(hold_first) + + # Wait until the first request is in the worker. + await to_thread.run_sync(lambda: entered.wait(2.0)) + + # Second request: no worker waiting → 503 immediately. + r = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + assert r.status_code == 503 + + release.set() + finally: + release.set() + + lt.shutdown() + await lt.stopped + + async def test_rendezvous_idle_worker_dispatches_normally(self, free_port): + """backlog=0 must not 503 when a worker IS waiting. Sequential + requests on max_concurrency=1 are the regression check — each + request finishes before the next arrives, so the worker is always + idle on get() at dispatch time.""" + + def handler(ctx: HTTPReqCtx): + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(5): + r = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) + assert r.status_code == 200 + + lt.shutdown() + await lt.stopped + + async def test_backlog_admits_extra_requests(self, free_port): + """backlog=2 with max_concurrency=2: 4 concurrent slow requests all + succeed (2 in flight + 2 buffered).""" + entered = threading.Semaphore(0) + release = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + entered.release() + release.wait(timeout=5.0) + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + def handler(_ctx: HTTPReqCtx): + return body_handler + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler, max_concurrency=2, backlog=2) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[httpx.Response | None] = [None] * 4 + + async def fire(i: int) -> None: + results[i] = await _get(f"http://127.0.0.1:{free_port}/", timeout=5.0) + + try: + async with anyio.create_task_group() as tg: + for i in range(4): + tg.start_soon(fire, i) + + # Two workers run concurrently; the other two sit in the + # backlog. Wait for the first wave to enter, then release. + def wait_for_two(): + for _ in range(2): + assert entered.acquire(timeout=5.0) + + await to_thread.run_sync(wait_for_two) + release.set() + finally: + release.set() + + for r in results: + assert r is not None + assert r.status_code == 200 + + lt.shutdown() + await lt.stopped + + async def test_backlog_overflow_rejects_with_503(self, free_port): + """backlog=1 with max_concurrency=1: 1 worker + 1 buffered = 2 + capacity. A 3rd concurrent request is rejected with 503.""" + entered = threading.Event() + release = threading.Event() + + def body_handler(ctx: HTTPReqCtx): + entered.set() + release.wait(timeout=5.0) + ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + + def handler(_ctx: HTTPReqCtx): + return body_handler + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with _serve_pooled(cfg, handler, max_concurrency=1, backlog=1) as lt: + await lt.started + await _wait_server_ready(free_port) + + sockets: list[socket.socket] = [] + + def open_slow() -> socket.socket: + sock = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + return sock + + try: + # Saturate worker. + sockets.append(await to_thread.run_sync(open_slow)) + assert await to_thread.run_sync(lambda: entered.wait(2.0)) + # Saturate the single backlog slot. + sockets.append(await to_thread.run_sync(open_slow)) + await anyio.sleep(0.1) + + # 3rd request must 503. + def probe() -> bytes: + s = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) + sockets.append(s) + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + return read_http_response(s, deadline=2.0) + + response = await to_thread.run_sync(probe) + assert b"HTTP/1.1 503" in response, response + finally: + release.set() + for s in sockets: + with contextlib.suppress(OSError): + s.close() + + lt.shutdown() + await lt.stopped + class TestMultiSelector: """``selectors=N > 1`` spawns N independent ``BaseServer`` threads bound @@ -327,7 +507,9 @@ async def test_serves_requests_pooled_concurrent(self, free_port): """selectors=2 + thread pool. Multiple selectors push onto the single shared channel; the pool drains across both producers.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with thread_pool_handler(_handler_200(b"hi"), max_concurrency=4) as wrapped: + # backlog=8: with 10 fast requests against max_concurrency=4, headroom keeps the + # rendezvous race from intermittently 503'ing late arrivals during the test. + async with thread_pool_handler(_handler_200(b"hi"), max_concurrency=4, backlog=8) as wrapped: async with serve(http_server(cfg, wrapped, selectors=2)) as lt: await lt.started await _wait_server_ready(free_port) @@ -491,7 +673,7 @@ def handler(_ctx: HTTPReqCtx): return body_handler cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=3) as lt: + async with _serve_pooled(cfg, handler, max_concurrency=3, backlog=2) as lt: await lt.started await _wait_server_ready(free_port) @@ -534,7 +716,7 @@ def slow(_ctx: HTTPReqCtx) -> BodyHandler: return body_handler cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, routes.build().as_handler(), max_concurrency=1) as lt: + async with _serve_pooled(cfg, routes.build().as_handler(), max_concurrency=1, backlog=1) as lt: await lt.started await _wait_server_ready(free_port) @@ -646,7 +828,9 @@ def body_handler(ctx: HTTPReqCtx): def handler(_ctx: HTTPReqCtx): return body_handler - async with _serve_pooled(cfg, handler, max_concurrency=8) as lt: + # backlog gives 10 fast requests headroom past the rendezvous default, + # so this test isolates the dispatch-distribution check from admission timing. + async with _serve_pooled(cfg, handler, max_concurrency=8, backlog=4) as lt: await lt.started await _wait_server_ready(free_port) From 1763705be82f7458cd205d37f949063be14d2aed Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 21:54:23 +0000 Subject: [PATCH 146/286] WIP --- localpost/http/_types.py | 6 +++--- localpost/http/server_httptools.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/localpost/http/_types.py b/localpost/http/_types.py index 9d6f8db..f358ae1 100644 --- a/localpost/http/_types.py +++ b/localpost/http/_types.py @@ -24,7 +24,7 @@ ] -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, eq=False) class Request: """Parsed HTTP request line + headers. Body is streamed via :meth:`HTTPReqCtx.receive`.""" @@ -48,7 +48,7 @@ class Request: """HTTP version as bare bytes (``b"1.1"`` or ``b"1.0"``).""" -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, eq=False) class Response: """Final response (2xx-5xx) — exactly one per request.""" @@ -58,7 +58,7 @@ class Response: """Reason phrase. Empty → backend supplies a default for the status code.""" -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, eq=False) class InformationalResponse: """1xx response (100 Continue, 102 Processing, …). Multiple may precede the final response.""" diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 5e04b4c..94e5403 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -108,6 +108,7 @@ def _scan_response_headers(headers: Sequence[tuple[bytes, bytes]]) -> tuple[bool @final @dataclass(eq=False, slots=True) +# TODO Rename to HTTPConn class HTTPConnHttptools(BaseHTTPConn): server: BaseServer sock: socket.socket From 2d0d16f8bea14a0893d0c48f32fe4443d7aee386 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 1 May 2026 02:00:10 +0400 Subject: [PATCH 147/286] fix(threadtools): allow N in-flight rendezvous puts with N waiting receivers Capacity=0 channel admission used `len(buffer) == 0` as the gate, so back-to-back `put_nowait` serialized to one in-flight item even with multiple idle receivers. `put()` Phase 1 had the same constraint and Phase 2 waited for the whole buffer to drain rather than its own item. Track `pending_handoffs` (buffered items paired with a waiting receiver) and `items_consumed` (monotonic pop counter) on ChannelState. `can_put_nowait` becomes `waiting_receivers > pending_handoffs`; `put()` Phase 2 waits for `items_consumed` to reach the snapshot taken at append time. N receivers + N concurrent puts now pair in parallel. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools.py | 38 ++++++- tests/threadtools/channels.py | 192 +++++++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 9 deletions(-) diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 9ce60d2..d40de6c 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -204,6 +204,8 @@ class ChannelState[T]: open_send_channels: int open_receive_channels: int waiting_receivers: int + pending_handoffs: int + items_consumed: int _lock: CancellableLock not_empty: threading.Condition not_full: threading.Condition @@ -215,12 +217,20 @@ def __init__(self, capacity: int | None = None): self.capacity = capacity """ None: unbounded - 0: rendezvous (at most 1 item in-flight, put() blocks until a receiver consumes the item) + 0: rendezvous — put blocks until its own item is consumed by a receiver; put_nowait succeeds + only if an unclaimed waiting receiver is available. With N waiting receivers, up to N + puts can be in flight concurrently. Positive int: bounded (put blocks until len(buffer) < capacity) """ self.open_send_channels = 0 self.open_receive_channels = 0 self.waiting_receivers = 0 + # Rendezvous bookkeeping (capacity=0 only). ``pending_handoffs`` is a budget counter for + # buffered items already paired with a waiting receiver; ``items_consumed`` is monotonic + # and lets a blocking ``put`` detect consumption of its own item even when the buffer + # holds other in-flight items. + self.pending_handoffs = 0 + self.items_consumed = 0 self._lock = CancellableLock(threading.RLock()) self.not_empty = cancellable_condition(self._lock) self.not_full = cancellable_condition(self._lock) @@ -229,14 +239,16 @@ def __init__(self, capacity: int | None = None): def can_put(self) -> bool: if self.open_receive_channels == 0: # TODO Make this state permanent raise ClosedResourceError("no more receivers") - return self.capacity is None or len(self.buffer) < max(self.capacity, 1) + if self.capacity == 0: + return self.waiting_receivers > self.pending_handoffs or len(self.buffer) == 0 + return self.capacity is None or len(self.buffer) < self.capacity @property def can_put_nowait(self) -> bool: if self.open_receive_channels == 0: # TODO Make this state permanent raise ClosedResourceError("no more receivers") if self.capacity == 0: - return len(self.buffer) == 0 and self.waiting_receivers > 0 + return self.waiting_receivers > self.pending_handoffs return self.capacity is None or len(self.buffer) < self.capacity def __enter__(self) -> Self: @@ -272,11 +284,16 @@ def put_nowait(self, item: T, /) -> None: if not state.can_put_nowait: raise WouldBlock state.buffer.append(item) + if state.capacity == 0: + # ``can_put_nowait`` already gated on ``waiting_receivers > pending_handoffs``, + # so this is always a real claim. + state.pending_handoffs += 1 state.not_empty.notify() @override def put(self, item: T, /) -> None: state = self._state + my_target = 0 # Phase 1: wait for space in the buffer. # Body of ``put_nowait`` is inlined here to avoid re-entering the lock # and to skip the ``WouldBlock`` exception on the contended path. @@ -287,17 +304,24 @@ def put(self, item: T, /) -> None: raise ClosedResourceError("send channel has been closed") if state.can_put: state.buffer.append(item) + if state.capacity == 0: + if state.waiting_receivers > state.pending_handoffs: + state.pending_handoffs += 1 + # Snapshot a target for Phase 2: consumption of *our* item bumps + # ``items_consumed`` to (at least) this value, regardless of any other + # in-flight items the buffer holds. + my_target = state.items_consumed + len(state.buffer) state.not_empty.notify() break state.not_full.wait() - # Phase 2 (rendezvous only): wait until the item is consumed + # Phase 2 (rendezvous only): wait until *our* item is consumed if state.capacity == 0: while True: with state: if self._closed: raise ClosedResourceError("send channel has been closed") - if len(state.buffer) == 0: + if state.items_consumed >= my_target: return # Consumed if state.open_receive_channels == 0: return # No receivers left @@ -335,6 +359,10 @@ def get(self) -> T: with state: if state.buffer: item = state.buffer.popleft() + if state.capacity == 0: + state.items_consumed += 1 + if state.pending_handoffs > 0: + state.pending_handoffs -= 1 state.not_full.notify() return item if state.open_send_channels == 0: diff --git a/tests/threadtools/channels.py b/tests/threadtools/channels.py index a455eba..0c3822e 100644 --- a/tests/threadtools/channels.py +++ b/tests/threadtools/channels.py @@ -1,11 +1,13 @@ import threading import time + import pytest from anyio import ClosedResourceError, EndOfStream, WouldBlock from localpost import threadtools from localpost.threadtools import Channel, SendChannel + @pytest.fixture def no_anyio(): original_check_cancelled = threadtools.check_cancelled @@ -15,6 +17,7 @@ def no_anyio(): finally: threadtools.check_cancelled = original_check_cancelled + def test_basic_send_receive(no_anyio): """Test basic send and receive operations.""" sender, receiver = Channel.create() @@ -267,6 +270,190 @@ def send() -> None: receiver.close() +def _wait_for(predicate, timeout: float = 1.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.001) + return predicate() + + +def test_rendezvous_put_nowait_with_multiple_receivers(no_anyio): + """N concurrent put_nowait calls succeed when N receivers are waiting.""" + n = 4 + sender, receiver = Channel.create(capacity=0) + receivers = [receiver] + [receiver.clone() for _ in range(n - 1)] + received: list[int] = [] + received_lock = threading.Lock() + + def receive(r): + try: + value = r.get() + finally: + r.close() + with received_lock: + received.append(value) + + threads = [threading.Thread(target=receive, args=(r,)) for r in receivers] + for t in threads: + t.start() + + try: + assert _wait_for(lambda: sender._state.waiting_receivers == n), ( + f"only {sender._state.waiting_receivers} receivers waiting" + ) + + for i in range(n): + sender.put_nowait(i) + + with pytest.raises(WouldBlock): + sender.put_nowait(n) + + for t in threads: + t.join(timeout=1.0) + assert not t.is_alive() + assert sorted(received) == list(range(n)) + finally: + sender.close() + + +def test_rendezvous_put_nowait_decrements_after_consume(no_anyio): + """pending_handoffs returns to 0 after each successful handoff.""" + sender, receiver = Channel.create(capacity=0) + + def receive_one(r, sink: list[str]) -> None: + sink.append(r.get()) + + try: + for round_idx in range(2): + received: list[str] = [] + t = threading.Thread(target=receive_one, args=(receiver, received)) + t.start() + assert _wait_for(lambda: sender._state.waiting_receivers == 1) + + sender.put_nowait(f"msg-{round_idx}") + t.join(timeout=1.0) + assert not t.is_alive() + assert received == [f"msg-{round_idx}"] + assert sender._state.pending_handoffs == 0 + finally: + sender.close() + receiver.close() + + +def test_rendezvous_concurrent_blocking_puts_pair_with_receivers(no_anyio): + """N concurrent put() calls all return when N receivers are waiting.""" + n = 4 + sender, receiver = Channel.create(capacity=0) + senders = [sender] + [sender.clone() for _ in range(n - 1)] + receivers = [receiver] + [receiver.clone() for _ in range(n - 1)] + + received: list[int] = [] + received_lock = threading.Lock() + sends_done = threading.Event() + sends_completed = 0 + sends_lock = threading.Lock() + + def receive(r): + try: + value = r.get() + with received_lock: + received.append(value) + finally: + r.close() + + def send(s, value: int): + try: + s.put(value) + finally: + s.close() + nonlocal sends_completed + with sends_lock: + sends_completed += 1 + if sends_completed == n: + sends_done.set() + + receiver_threads = [threading.Thread(target=receive, args=(r,)) for r in receivers] + for t in receiver_threads: + t.start() + + try: + assert _wait_for(lambda: sender._state.waiting_receivers == n) + + sender_threads = [threading.Thread(target=send, args=(s, i)) for i, s in enumerate(senders)] + for t in sender_threads: + t.start() + + assert sends_done.wait(timeout=2.0), "blocking puts did not all return" + for t in sender_threads: + t.join(timeout=1.0) + assert not t.is_alive() + for t in receiver_threads: + t.join(timeout=1.0) + assert not t.is_alive() + + assert sorted(received) == list(range(n)) + finally: + for s in senders: + try: + s.close() + except ClosedResourceError: + pass + + +def test_rendezvous_put_returns_when_its_own_item_consumed(no_anyio): + """Blocking put waits for *its* item, not just any buffer drain.""" + sender, receiver = Channel.create(capacity=0) + sender_b = sender.clone() + received: list[str] = [] + b_returned = threading.Event() + + def receive_one(): + received.append(receiver.get()) + + receiver_thread = threading.Thread(target=receive_one) + receiver_thread.start() + + try: + assert _wait_for(lambda: sender._state.waiting_receivers == 1) + + # "a" claims the only waiting receiver. + sender.put_nowait("a") + + def send_b(): + sender_b.put("b") + b_returned.set() + + sender_b_thread = threading.Thread(target=send_b) + sender_b_thread.start() + + # Receiver pops "a" first (FIFO). Sender B should still be in Phase 2 + # because its own item ("b") has not been consumed yet. + receiver_thread.join(timeout=1.0) + assert not receiver_thread.is_alive() + assert received == ["a"] + + time.sleep(0.05) + assert not b_returned.is_set(), "put('b') returned before 'b' was consumed" + + # New receiver consumes "b" — now B can return. + received_b: list[str] = [] + receiver_b_thread = threading.Thread(target=lambda: received_b.append(receiver.get())) + receiver_b_thread.start() + receiver_b_thread.join(timeout=1.0) + assert not receiver_b_thread.is_alive() + assert received_b == ["b"] + + assert b_returned.wait(timeout=1.0), "put('b') did not return after 'b' was consumed" + sender_b_thread.join(timeout=1.0) + assert not sender_b_thread.is_alive() + finally: + sender.close() + sender_b.close() + receiver.close() + + def test_concurrent_stress(no_anyio): """Stress test with many concurrent senders and receivers.""" num_senders = 5 @@ -327,10 +514,7 @@ def receiver_work(r): assert len(received) == num_senders * messages_per_sender # Verify all messages are unique and accounted for - expected = [] - for sender_id in range(num_senders): - for i in range(messages_per_sender): - expected.append(sender_id * 1000 + i) + expected = [sender_id * 1000 + i for sender_id in range(num_senders) for i in range(messages_per_sender)] assert sorted(received) == sorted(expected) From bfeb7fa01a07e24528a04345126d3ce2d0c9a430 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 1 May 2026 02:15:01 +0400 Subject: [PATCH 148/286] test(threadtools): add Hypothesis state-machine tests for Channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-threaded RuleBasedStateMachine over put_nowait/get/clone/close against a deque-backed reference model, parameterised across capacities (None, 1, 4) and a single-producer FIFO variant. Complements the threaded tests in channels.py — they pin down concurrency invariants that PBT can't drive; this layer fuzzes the state-tracking surface (open_send_channels, open_receive_channels, buffer length, capacity bound) under arbitrary op orderings. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/threadtools/channel_props.py | 262 +++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/threadtools/channel_props.py diff --git a/tests/threadtools/channel_props.py b/tests/threadtools/channel_props.py new file mode 100644 index 0000000..e524e6c --- /dev/null +++ b/tests/threadtools/channel_props.py @@ -0,0 +1,262 @@ +"""Property-based tests for ``Channel`` (single-threaded). + +These complement the example-based / threaded tests in ``channels.py``. + +Scope: + Single-threaded ``RuleBasedStateMachine`` driving sequences of + ``put_nowait`` / ``get`` / ``close`` / ``clone`` against a small reference + model (deque + counters). Catches state-tracking regressions — + ``open_send_channels``, ``open_receive_channels``, buffer length, capacity + bound — under arbitrary op orderings. + +Out of scope: + Rendezvous (``capacity=0``) and any property that depends on thread + interleavings. Those live in ``channels.py``. +""" + +from __future__ import annotations + +from collections import deque +from typing import ClassVar + +import pytest +from anyio import ClosedResourceError, EndOfStream, WouldBlock +from hypothesis import strategies as st +from hypothesis.stateful import ( + Bundle, + RuleBasedStateMachine, + consumes, + initialize, + invariant, + precondition, + rule, +) + +from localpost import threadtools +from localpost.threadtools import Channel + + +def _noop_check_cancelled() -> None: + return None + + +@pytest.fixture +def no_anyio(monkeypatch: pytest.MonkeyPatch): + # The channel calls ``threadtools.check_cancelled()`` (an alias of + # ``anyio.from_thread.check_cancelled``); outside an anyio thread context + # that raises. Stub it for the duration of the test. + monkeypatch.setattr(threadtools, "check_cancelled", _noop_check_cancelled) + + +class _ChannelMachine(RuleBasedStateMachine): + """Drives a ``Channel`` with one capacity, one producer-mode flag. + + Subclasses set ``capacity`` and ``single_producer`` as class vars; the + machine is then exposed to pytest via ``Subclass.TestCase``. + """ + + capacity: ClassVar[int | None] = None + single_producer: ClassVar[bool] = False + + senders = Bundle("senders") + receivers = Bundle("receivers") + closed_senders = Bundle("closed_senders") + closed_receivers = Bundle("closed_receivers") + + def __init__(self) -> None: + super().__init__() + s, r = Channel.create(capacity=self.capacity) + self._first_sender = s + self._first_receiver = r + self._state = s._state + self.model_buffer: deque = deque() + self.model_open_senders = 1 + self.model_open_receivers = 1 + self.model_sent: list = [] + self.model_received: list = [] + + # --- seed bundles ------------------------------------------------------- + + @initialize(target=senders) + def _seed_sender(self): + return self._first_sender + + @initialize(target=receivers) + def _seed_receiver(self): + return self._first_receiver + + # --- clone -------------------------------------------------------------- + + @precondition(lambda self: not self.single_producer) + @rule(target=senders, s=senders) + def clone_sender(self, s): + new_s = s.clone() + self.model_open_senders += 1 + return new_s + + @rule(target=receivers, r=receivers) + def clone_receiver(self, r): + new_r = r.clone() + self.model_open_receivers += 1 + return new_r + + # --- put_nowait variants ------------------------------------------------ + + @precondition( + lambda self: self.model_open_receivers > 0 + and (self.capacity is None or len(self.model_buffer) < self.capacity) + ) + @rule(s=senders, item=st.integers()) + def put_nowait_ok(self, s, item): + s.put_nowait(item) + self.model_buffer.append(item) + if self.single_producer: + self.model_sent.append(item) + + @precondition( + lambda self: self.capacity is not None + and self.capacity > 0 + and len(self.model_buffer) >= self.capacity + and self.model_open_receivers > 0 + ) + @rule(s=senders, item=st.integers()) + def put_nowait_full_blocks(self, s, item): + with pytest.raises(WouldBlock): + s.put_nowait(item) + + @precondition(lambda self: self.model_open_receivers == 0) + @rule(s=senders, item=st.integers()) + def put_nowait_no_receivers(self, s, item): + with pytest.raises(ClosedResourceError): + s.put_nowait(item) + + # --- get variants ------------------------------------------------------- + + @precondition(lambda self: len(self.model_buffer) > 0) + @rule(r=receivers) + def get_nonempty(self, r): + expected = self.model_buffer.popleft() + actual = r.get() + assert actual == expected + if self.single_producer: + self.model_received.append(actual) + + @precondition(lambda self: len(self.model_buffer) == 0 and self.model_open_senders == 0) + @rule(r=receivers) + def get_drained_raises_eos(self, r): + with pytest.raises(EndOfStream): + r.get() + + # --- close -------------------------------------------------------------- + + @rule(target=closed_senders, s=consumes(senders)) + def close_sender(self, s): + s.close() + self.model_open_senders -= 1 + return s + + @rule(target=closed_receivers, r=consumes(receivers)) + def close_receiver(self, r): + r.close() + self.model_open_receivers -= 1 + return r + + @rule(s=closed_senders, item=st.integers()) + def put_on_closed_sender_raises(self, s, item): + with pytest.raises(ClosedResourceError): + s.put_nowait(item) + + @rule(r=closed_receivers) + def get_on_closed_receiver_raises(self, r): + with pytest.raises(ClosedResourceError): + r.get() + + @rule(s=closed_senders) + def clone_closed_sender_raises(self, s): + with pytest.raises(ClosedResourceError): + s.clone() + + @rule(r=closed_receivers) + def clone_closed_receiver_raises(self, r): + with pytest.raises(ClosedResourceError): + r.clone() + + # --- invariants --------------------------------------------------------- + + @invariant() + def open_handle_counters_match_model(self): + assert self._state.open_send_channels == self.model_open_senders + assert self._state.open_receive_channels == self.model_open_receivers + + @invariant() + def buffer_length_matches_model(self): + assert len(self._state.buffer) == len(self.model_buffer) + + @invariant() + def buffer_within_capacity(self): + if self.capacity is not None: + assert len(self._state.buffer) <= self.capacity + + @invariant() + def waiting_receivers_zero_in_single_thread(self): + # No thread ever blocks in get() here, so this should never grow. + assert self._state.waiting_receivers == 0 + + @invariant() + def fifo_under_single_producer(self): + if not self.single_producer: + return + # Items received so far must be a prefix of items sent so far. + n = len(self.model_received) + assert self.model_sent[:n] == self.model_received + + +class _UnboundedMachine(_ChannelMachine): + capacity: ClassVar[int | None] = None + + +class _Bounded1Machine(_ChannelMachine): + capacity: ClassVar[int | None] = 1 + + +class _Bounded4Machine(_ChannelMachine): + capacity: ClassVar[int | None] = 4 + + +class _SingleProducerUnboundedMachine(_ChannelMachine): + capacity: ClassVar[int | None] = None + single_producer: ClassVar[bool] = True + + +class _SingleProducerBounded4Machine(_ChannelMachine): + capacity: ClassVar[int | None] = 4 + single_producer: ClassVar[bool] = True + + +# Expose the auto-generated unittest TestCases to pytest. ``usefixtures`` +# applies ``no_anyio`` for the lifetime of each Hypothesis test method. + + +@pytest.mark.usefixtures("no_anyio") +class TestChannelUnbounded(_UnboundedMachine.TestCase): + pass + + +@pytest.mark.usefixtures("no_anyio") +class TestChannelBounded1(_Bounded1Machine.TestCase): + pass + + +@pytest.mark.usefixtures("no_anyio") +class TestChannelBounded4(_Bounded4Machine.TestCase): + pass + + +@pytest.mark.usefixtures("no_anyio") +class TestChannelSingleProducerUnbounded(_SingleProducerUnboundedMachine.TestCase): + pass + + +@pytest.mark.usefixtures("no_anyio") +class TestChannelSingleProducerBounded4(_SingleProducerBounded4Machine.TestCase): + pass From 0edb99edcbcf99d4f06d7f792a2b56120e0d12c0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 30 Apr 2026 22:17:41 +0000 Subject: [PATCH 149/286] focus on stable, drop experimental --- localpost/experimental/__init__.py | 14 - localpost/experimental/consumers/README.md | 138 ----- localpost/experimental/consumers/__init__.py | 0 localpost/experimental/consumers/_utils.py | 21 - localpost/experimental/consumers/channel.py | 95 --- localpost/experimental/consumers/pubsub.py | 282 --------- .../experimental/consumers/stdlib_queue.py | 89 --- localpost/experimental/consumers/stream.py | 47 -- localpost/experimental/openapi/DESIGN.md | 66 -- localpost/experimental/openapi/README.md | 161 ----- localpost/experimental/openapi/__init__.py | 0 localpost/experimental/openapi/_docs.py | 59 -- localpost/experimental/openapi/app.py | 565 ------------------ localpost/experimental/openapi/converters.py | 5 - localpost/experimental/openapi/msgspec.py | 0 localpost/experimental/openapi/pydantic.py | 34 -- localpost/experimental/openapi/spec.py | 187 ------ localpost/experimental/openapi/sse.py | 29 - 18 files changed, 1792 deletions(-) delete mode 100644 localpost/experimental/__init__.py delete mode 100644 localpost/experimental/consumers/README.md delete mode 100644 localpost/experimental/consumers/__init__.py delete mode 100644 localpost/experimental/consumers/_utils.py delete mode 100644 localpost/experimental/consumers/channel.py delete mode 100644 localpost/experimental/consumers/pubsub.py delete mode 100644 localpost/experimental/consumers/stdlib_queue.py delete mode 100644 localpost/experimental/consumers/stream.py delete mode 100644 localpost/experimental/openapi/DESIGN.md delete mode 100644 localpost/experimental/openapi/README.md delete mode 100644 localpost/experimental/openapi/__init__.py delete mode 100644 localpost/experimental/openapi/_docs.py delete mode 100644 localpost/experimental/openapi/app.py delete mode 100644 localpost/experimental/openapi/converters.py delete mode 100644 localpost/experimental/openapi/msgspec.py delete mode 100644 localpost/experimental/openapi/pydantic.py delete mode 100644 localpost/experimental/openapi/spec.py delete mode 100644 localpost/experimental/openapi/sse.py diff --git a/localpost/experimental/__init__.py b/localpost/experimental/__init__.py deleted file mode 100644 index 73b2430..0000000 --- a/localpost/experimental/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Experimental sub-packages. - -Modules under ``localpost.experimental`` have **unstable public APIs** — -they're usable but their shape is still evolving. Expect breaking changes -in patch / minor releases. The ``experimental`` segment in every import -path is the marker; once a sub-package stabilises we'll move it back up -to ``localpost.``. - -See also: - -* ``localpost.experimental.consumers`` — message-broker consumers. -* ``localpost.experimental.openapi`` — type-driven OpenAPI on top of - ``localpost.http``. -""" diff --git a/localpost/experimental/consumers/README.md b/localpost/experimental/consumers/README.md deleted file mode 100644 index e520516..0000000 --- a/localpost/experimental/consumers/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# localpost.experimental.consumers - -> **Status:** experimental — lives under ``localpost.experimental.`` so the import path itself is the marker; expect breaking changes before `1.0`. - -Consumer services for message sources. A consumer is a small `@hosting.service` -wrapper that reads from a source (in-memory channel, AnyIO stream, `queue.Queue`, -Pub/Sub) and dispatches each item to a handler — sync or async — with a -concurrency cap and graceful-shutdown semantics. - -## Install - -Most consumers need no extras. For the managed brokers: - -```bash -pip install localpost[pubsub] # Google Cloud Pub/Sub -pip install localpost[sqs] # AWS SQS (adapter WIP; see Status below) -pip install localpost[kafka] # confluent-kafka (adapter WIP) -pip install localpost[nats] # nats-py (adapter WIP) -pip install localpost[azure-queue] # Azure Storage Queue (adapter WIP) -pip install localpost[azure-servicebus] # Azure Service Bus (adapter WIP) -``` - -## Status - -Shipped adapters: - -- `channel.py` — in-memory `threadtools.Channel` -- `stream.py` — AnyIO `ObjectReceiveStream` -- `stdlib_queue.py` — stdlib `queue.Queue` / `SimpleQueue` -- `pubsub.py` — Google Cloud Pub/Sub (module imports some internals that are - being reworked — treat as in-progress) - -Adapters for SQS, Kafka, NATS and Azure exist as extras in `pyproject.toml` but -the integrations currently live in [`examples/consumers/`](../../../examples/consumers/). -They will move into `localpost.experimental.consumers` as they stabilise. - -## Quick start - -```python -import anyio -from localpost.experimental.consumers.stream import stream_consumer -from localpost.hosting import run_app - - -async def handle(message: str): - print(f"got: {message}") - await anyio.sleep(0.5) - - -async def main(): - send, recv = anyio.create_memory_object_stream[str]() - - async def produce(): - for i in range(20): - await send.send(f"msg-{i}") - await anyio.sleep(0.1) - await send.aclose() - - consumer = stream_consumer(recv, handle, max_concurrency=5) - # `consumer` is a `@hosting.service`; pass it to run_app or await it directly - async with consumer: - await produce() - - -if __name__ == "__main__": - anyio.run(main) -``` - -For a full consumer-as-service example, see -[`examples/host/channel.py`](../../../examples/host/channel.py). - -## Key concepts - -- **Handler duality** — every consumer accepts both `SyncHandler[T]` and - `AsyncHandler[T]` (types from `_utils.py`). Sync handlers are offloaded to - a thread pool (sized by `max_concurrency`). Async handlers are scheduled in - the consumer's task group. -- **`max_concurrency`** — a semaphore around in-flight items. The puller blocks - once the cap is reached, so back-pressure flows to the source. -- **`process_leftovers`** — on shutdown, finish items that were already pulled - from the source (`True`, default) or drop them (`False`). -- **Async-context-manager lifecycle** — every consumer is an async CM. Entering - it starts the puller; leaving it drains (or cancels) remaining work. -- **Thread-bridge** — `stdlib_queue` and `channel` run the pull loop in a - worker thread (AnyIO `to_thread.run_sync`) and dispatch into the async task - group via `from_thread.run_sync`. - -## Public API - -| Symbol | Source | -| ----------------------------------------------------- | -------------------- | -| `channel_consumer(stream, handler, *, max_concurrency, process_leftovers=True)` | `channel.py` | -| `stream_consumer(stream, handler, *, max_concurrency=inf, process_leftovers=True)` | `stream.py` | -| `queue_consumer(queue, handler, *, max_concurrency, process_leftovers=True, shutdown_timeout=5.0)` | `stdlib_queue.py` | -| `pubsub_consumer(subscription_path, *, num_consumers=1)` | `pubsub.py` | -| `SyncHandler[T]`, `AsyncHandler[T]`, `AnyHandler[T]` | `_utils.py` | - -## Writing a new consumer - -A consumer is a function decorated with `@hosting.service` that yields once it -has started. Pattern from `stream.py`: - -```python -from anyio import Semaphore, create_task_group -from localpost import hosting -from localpost.experimental.consumers._utils import AnyHandler, ensure_async_handler - - -@hosting.service -async def my_consumer(source, h: AnyHandler, /, *, max_concurrency: int): - sem = Semaphore(max_concurrency) - handler = ensure_async_handler(h) - - async def handle(item): - try: - await handler(item) - finally: - sem.release() - - async def pull(): - await sem.acquire() - async for item in source: - tg.start_soon(handle, item) - await sem.acquire() - - async with create_task_group() as tg: - tg.start_soon(pull) - yield # service is ready; wait for shutdown here -``` - -Accept a source, a handler, and at least `max_concurrency`. Honour -`process_leftovers` if your source can be closed mid-flight. - -## See also - -- Examples: [`examples/consumers/`](../../../examples/consumers/) (SQS, Kafka stubs) -- Channel + host: [`examples/host/channel.py`](../../../examples/host/channel.py) -- Sync primitives: [`../../threadtools.py`](../../threadtools.py) diff --git a/localpost/experimental/consumers/__init__.py b/localpost/experimental/consumers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/localpost/experimental/consumers/_utils.py b/localpost/experimental/consumers/_utils.py deleted file mode 100644 index cca1b6a..0000000 --- a/localpost/experimental/consumers/_utils.py +++ /dev/null @@ -1,21 +0,0 @@ -from collections.abc import Awaitable, Callable - -from anyio import CapacityLimiter, from_thread, to_thread - -from localpost._utils import is_async_callable - -type SyncHandler[T] = Callable[[T], None] -type AsyncHandler[T] = Callable[[T], Awaitable[None]] -type AnyHandler[T] = SyncHandler[T] | AsyncHandler[T] - - -def ensure_async_handler(handler, limiter: CapacityLimiter = None) -> AsyncHandler: - if not is_async_callable(handler): - return lambda x: to_thread.run_sync(handler, x, limiter=limiter) - return handler - - -def ensure_sync_handler(handler) -> SyncHandler: - if is_async_callable(handler): - return lambda x: from_thread.run(handler, x) - return handler diff --git a/localpost/experimental/consumers/channel.py b/localpost/experimental/consumers/channel.py deleted file mode 100644 index 62fe50f..0000000 --- a/localpost/experimental/consumers/channel.py +++ /dev/null @@ -1,95 +0,0 @@ -from collections.abc import AsyncIterator -from contextlib import suppress - -from anyio import CapacityLimiter, ClosedResourceError, create_task_group, from_thread, to_thread - -from localpost import hosting, threadtools -from localpost._utils import is_async_callable -from localpost.experimental.consumers._utils import AnyHandler -from localpost.threadtools import Channel, ReceiveChannel - -__all__ = ["channel_consumer"] - - -# noinspection DuplicatedCode -@hosting.service -async def channel_consumer[T]( - stream: ReceiveChannel[T], - h: AnyHandler[T], - /, - *, - max_concurrency: int, - process_leftovers: bool = True, -) -> AsyncIterator[None]: - req_sem = threadtools.cancellable_semaphore(max_concurrency) - - if is_async_callable(h): - - async def handle_item(item): - try: - await h(item) - finally: - req_sem.release() - - handler = handle_item - else: - req_threads = CapacityLimiter(max_concurrency) - - def handle_item(item): - try: - h(item) - finally: - req_sem.release() - - def handler(item): - return to_thread.run_sync(handle_item, item, limiter=req_threads) - - def handle_items(): - with suppress(ClosedResourceError): # Receiver has been closed (according to process_leftovers setting) - req_sem.acquire() - for item in stream: - from_thread.run_sync(tg.start_soon, handler, item) - req_sem.acquire() - - def handle_items_thread(): - return to_thread.run_sync(handle_items, limiter=CapacityLimiter(1)) - - async with stream, create_task_group() as tg: - tg.start_soon(handle_items_thread) - yield - if not process_leftovers: - # Immediately stop consuming (close the receiver) and ignore the remaining items - stream.close() - # Otherwise process all the remaining items (until the source stream is completed) - - -def _sample_usage(): - import logging - import signal - import time - - import anyio - - logging.basicConfig(level=logging.DEBUG) - - def log_item(x): - time.sleep(0.5) - logging.info(f"Received: {x}") - - async def _run(): - sender, receiver = Channel.create() - consumer = channel_consumer(receiver, log_item, max_concurrency=5) - - async with consumer, sender: - for i in range(10): - sender.put_nowait(i) - with anyio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signals: - async for _ in signals: - break # Shutdown - - # noinspection PyTypeChecker - anyio.run(_run) - - -if __name__ == "__main__": - _sample_usage() diff --git a/localpost/experimental/consumers/pubsub.py b/localpost/experimental/consumers/pubsub.py deleted file mode 100644 index 96d8020..0000000 --- a/localpost/experimental/consumers/pubsub.py +++ /dev/null @@ -1,282 +0,0 @@ -from __future__ import annotations - -import dataclasses as dc -import logging -import math -from collections.abc import Callable, Iterable, Sequence -from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager -from functools import partial -from typing import Final, final, overload - -import google.pubsub_v1 as pubsub -from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread -from google.api_core import retry -from google.api_core.exceptions import AlreadyExists -from google.api_core.gapic_v1.method import DEFAULT -from google.pubsub_v1 import SubscriberClient - -from localpost._utils import MemoryStream -from localpost.flow import ( - AnyHandlerManager, - SyncHandler, - ensure_async_handler, - ensure_sync_handler, -) -from localpost.flow._stream import BatchReceiver, create_stream_consumer -from localpost.hosting import ExposedServiceBase, ServiceLifetimeManager - -__all__ = [ - "PubSubMessage", - "PubSubMessages", - "ConsumerClient", - "client_factory", - "pubsub_consumer", -] - -from localpost.hosting.utils import ThreadSafeMemorySendStream, ThreadSafeSendStream - -logger = logging.getLogger(__name__) - -# Timeout for a sync pull request, to check the application state (and exit gracefully on shutdown) -CHECK_INTERVAL: Final = 3.0 # seconds - -_EMPTY_RECEIVE: Final[Sequence[pubsub.ReceivedMessage]] = () # Empty response from pull() - - -@final -@dc.dataclass(frozen=True, eq=False, slots=True) -class PubSubMessage(Sequence["PubSubMessage"], AbstractContextManager[bytes, None]): - payload: pubsub.ReceivedMessage - client: ConsumerClient | AsyncConsumerClient - ack_queue: ThreadSafeSendStream[PubSubMessage] - - def __repr__(self): - return f"{self.__class__.__name__}(subscription_path={self.client.subscription_path!r})" - - def __len__(self): - return 1 - - def __getitem__(self, i): - if i == 0: - return self - raise IndexError() - - def __enter__(self) -> bytes: - return self.data - - def __exit__(self, exc_type, _, __) -> None: - if exc_type is None: - self.ack() - - def ack(self): - self.ack_queue.send_nowait(self) - - @property - def data(self) -> bytes: - return self.payload.message.data - - -@final -@dc.dataclass(frozen=True, slots=True) -class PubSubMessages(Sequence[PubSubMessage], AbstractContextManager[Sequence[bytes], None]): - """Non-empty batch of PubSub messages.""" - - source: Sequence[PubSubMessage] - - def __init__(self, source: Sequence[PubSubMessage]) -> None: - if isinstance(source, PubSubMessages): - source = source.source - if len(source) == 0: - raise ValueError(f"{self.__class__.__name__} must not be empty") - object.__setattr__(self, "source", source) - - def __repr__(self): - subscription_path = self.source[0].client.subscription_path - return f"{self.__class__.__name__}(len={len(self)}, subscription_path={subscription_path!r})" - - def __len__(self): - return len(self.source) - - def __getitem__(self, i) -> PubSubMessage: - return self.source[i] # type: ignore[return-value] - - def __enter__(self) -> Sequence[bytes]: - return self.data - - def __exit__(self, exc_type, _, __) -> None: - if exc_type is None: - for message in self.source: - message.ack() - - @property - def payload(self) -> Sequence[pubsub.ReceivedMessage]: - return [msg.payload for msg in self.source] - - @property - def data(self) -> Sequence[bytes]: - return [msg.data for msg in self.source] - - -@final -class ConsumerClient: - @classmethod - @asynccontextmanager - async def create( - cls, - subscription_path: str, # projects/{project}/subscriptions/{subscription} - topic_path: str, # projects/{project}/topics/{topic} - client: SubscriberClient | None = None, - /, - *, - max_messages: int = 100, - retry_policy: retry.Retry | object = DEFAULT, - ): - client = client or SubscriberClient() - try: - # In case the subscription needs to be customized, it can always be created in advance (so this call will - # end up with ALREADY_EXISTS response), see also: - # https://cloud.google.com/pubsub/docs/reference/error-codes - try: - logger.info("Creating subscription for pulling messages") - await to_thread.run_sync(client.create_subscription, {"name": subscription_path, "topic": topic_path}) - except AlreadyExists: - logger.info("Subscription already exists, using it for pulling messages") - yield cls(client, subscription_path, topic_path, max_messages, retry_policy) - finally: - await to_thread.run_sync(client.transport.close) - - def __init__( - self, - client: SubscriberClient, - subscription_path: str, # projects/{project}/subscriptions/{subscription} - topic_path: str, # projects/{project}/topics/{topic} - max_messages: int, - retry_policy: retry.Retry | object = DEFAULT, - ): - self.client = client - self.subscription_path = subscription_path - self.subscription_name = topic_path - self.max_messages = max_messages - self.retry_policy = retry_policy - - def acknowledge(self, target: Iterable[PubSubMessage]) -> None: - request = {"subscription": self.subscription_path, "ack_ids": [msg.payload.ack_id for msg in target]} - self.client.acknowledge(request) - - def pull(self): - # The actual number of messages pulled may be smaller than max_messages - response = self.client.pull( - {"subscription": self.subscription_path, "max_messages": self.max_messages}, - retry=self.retry_policy or DEFAULT, - timeout=CHECK_INTERVAL, - ) - return response.received_messages - - -# https://cloud.google.com/pubsub/docs/samples/pubsub-subscriber-sync-pull#pubsub_subscriber_sync_pull-python -async def _consume_sync( - client: ConsumerClient, - message_handler: SyncHandler[PubSubMessage], - ack_queue: ThreadSafeSendStream[PubSubMessage], - shutdown_scope: CancelScope, -): - def consume(): - while True: - from_thread.check_cancelled() - messages = client.pull() - if not messages: - continue # No messages received (empty queue or shutdown) - for m in messages: - message_handler(PubSubMessage(m, client, ack_queue)) - - # In Trio shutdown_scope.cancel_called can only be checked in async context - with shutdown_scope: - await to_thread.run_sync(consume, limiter=CapacityLimiter(1)) - - -def client_factory(subscription_path: str) -> Callable[[], AbstractAsyncContextManager[ConsumerClient]]: - """Default PubSub client factory.""" - return partial(ConsumerClient.create, subscription_path) - - -@final -class PubSubConsumerService(ExposedServiceBase): - def __init__( - self, - cf: AnyClientFactory, - handler_m: AnyHandlerManager[PubSubMessage], - /, - *, - consumers: int, - ): - super().__init__() - - if consumers < 1: - raise ValueError("Number of consumers must be at least 1") - - self.client_factory = cf - self.handler_m = handler_m - self.num_consumers = consumers - - async def __call__(self, service_lifetime: ServiceLifetimeManager) -> None: - assert self.num_consumers > 0 - self._lifetime = service_lifetime # Expose service lifetime events - - ack_queue_writer, ack_queue_reader = MemoryStream.create(math.inf) - batched_ack_queue_reader = BatchReceiver[PubSubMessage, list[PubSubMessage]]( - ack_queue_reader, batch_size=100, batch_window=1.0 - ) - robust_ack_queue = ThreadSafeMemorySendStream(ack_queue_writer, service_lifetime.host) - - async def acknowledge_messages(messages: Iterable[PubSubMessage]) -> None: - if isinstance(client, ConsumerClient): - await to_thread.run_sync(client.acknowledge, messages) - else: - await client.acknowledge(messages) - - # Graceful shutdown scopes, one per consumer task (thread) - consumer_scopes = [CancelScope() for _ in range(self.num_consumers)] - - async with ( - self.client_factory() as client, - create_stream_consumer(batched_ack_queue_reader, acknowledge_messages, concurrency=math.inf), - ack_queue_writer, - self.handler_m as handler, - create_task_group() as tg, - ): - service_lifetime.set_started() - # Start pulling messages only after the whole app is started - await service_lifetime.host.started - if isinstance(client, ConsumerClient): - sync_m_handler = ensure_sync_handler(handler) - for cs in consumer_scopes: - tg.start_soon(_consume_sync, client, sync_m_handler, robust_ack_queue, cs) - else: - async_m_handler = ensure_async_handler(handler) - for cs in consumer_scopes: - tg.start_soon(_consume_async, client, async_m_handler, robust_ack_queue, cs) - await service_lifetime.shutting_down - for cs in consumer_scopes: - cs.cancel() - - -@overload -def pubsub_consumer( - subscription_path: str, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[PubSubMessage]], PubSubConsumerService]: - """Decorator to create a PubSub consumer hosted service.""" - - -@overload -def pubsub_consumer( - cf: AnyClientFactory, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[PubSubMessage]], PubSubConsumerService]: - """Decorator to create a PubSub consumer hosted service.""" - - -# PyCharm (at least 2024.3) does not infer the changed type if it's a method, only when it's a function -def pubsub_consumer( - cf_or_sp: AnyClientFactory | str, /, *, num_consumers: int = 1 -) -> Callable[[AnyHandlerManager[PubSubMessage]], PubSubConsumerService]: - cf = client_factory(cf_or_sp) if isinstance(cf_or_sp, str) else cf_or_sp - return lambda handler_m: PubSubConsumerService(cf, handler_m, consumers=num_consumers) diff --git a/localpost/experimental/consumers/stdlib_queue.py b/localpost/experimental/consumers/stdlib_queue.py deleted file mode 100644 index b2ec733..0000000 --- a/localpost/experimental/consumers/stdlib_queue.py +++ /dev/null @@ -1,89 +0,0 @@ -import queue -import sys -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, suppress -from queue import Queue, SimpleQueue - -import anyio -from anyio import CancelScope, CapacityLimiter, create_task_group, from_thread, to_thread - -from localpost import threadtools -from localpost._utils import is_async_callable -from localpost.experimental.consumers._utils import AnyHandler - -if sys.version_info >= (3, 13): - from queue import ShutDown -else: - - class ShutDown(Exception): - pass - - -__all__ = ["queue_consumer"] - - -def _pull_queue[T](q: Queue[T] | SimpleQueue[T]) -> T: - while True: - threadtools.check_cancelled() - try: - return q.get(timeout=threadtools.CHECK_TIMEOUT) - except queue.Empty: - continue - - -# noinspection DuplicatedCode -@asynccontextmanager -async def queue_consumer[T]( - stream: Queue[T] | SimpleQueue[T], - h: AnyHandler[T], - /, - *, - max_concurrency: int, - process_leftovers: bool = True, - shutdown_timeout: float = 5.0, -) -> AsyncIterator[None]: - req_sem = threadtools.cancellable_semaphore(max_concurrency) - - if is_async_callable(h): - - async def handle_item(item): - try: - await h(item) - finally: - req_sem.release() - - handler = handle_item - else: - req_threads = CapacityLimiter(max_concurrency) - - def handle_item(item): - try: - h(item) - finally: - req_sem.release() - - def handler(item): - return to_thread.run_sync(handle_item, item, limiter=req_threads) - - def handle_items(): - with suppress(ShutDown): - req_sem.acquire() - while True: - from_thread.run_sync(tg.start_soon, handler, _pull_queue(stream)) - req_sem.acquire() - - async def handle_items_thread(): - with shutdown_scope: - await to_thread.run_sync(handle_items, limiter=CapacityLimiter(1)) - - async with create_task_group() as tg: - shutdown_scope = CancelScope() - tg.start_soon(handle_items_thread) - yield - if not process_leftovers: - # Immediately stop consuming and ignore the remaining items - shutdown_scope.cancel() - else: - # Wait for the remaining items to be processed (until the source queue is closed) or - # until the shutdown timeout is reached - shutdown_scope.deadline = anyio.current_time() + shutdown_timeout diff --git a/localpost/experimental/consumers/stream.py b/localpost/experimental/consumers/stream.py deleted file mode 100644 index fa171b5..0000000 --- a/localpost/experimental/consumers/stream.py +++ /dev/null @@ -1,47 +0,0 @@ -import math -from collections.abc import AsyncIterator -from contextlib import suppress - -from anyio import ClosedResourceError, Semaphore, create_task_group -from anyio.abc import ObjectReceiveStream - -from localpost import hosting -from localpost._utils import NullSemaphore, ensure_int_or_inf -from localpost.experimental.consumers._utils import AnyHandler, ensure_async_handler - -__all__ = ["stream_consumer"] - - -@hosting.service -async def stream_consumer[T]( - stream: ObjectReceiveStream[T], - h: AnyHandler[T], - /, - *, - max_concurrency: int | float = math.inf, - process_leftovers: bool = True, -) -> AsyncIterator[None]: - max_concurrency = ensure_int_or_inf(max_concurrency, min_value=1) - req_sem = Semaphore(max_concurrency) if max_concurrency != math.inf else NullSemaphore() - handler = ensure_async_handler(h) - - async def handle_item(item): - try: - await handler(item) - finally: - req_sem.release() - - async def handle_items(): - with suppress(ClosedResourceError): # Receiver has been closed (according to process_leftovers setting) - await req_sem.acquire() - async for item in stream: - tg.start_soon(handle_item, item) - await req_sem.acquire() - - async with stream, create_task_group() as tg: - tg.start_soon(handle_items) - yield - if not process_leftovers: - # Immediately stop consuming (close the receiver) and ignore the remaining items - await stream.aclose() - # Otherwise process all the remaining items (until the source stream is completed) diff --git a/localpost/experimental/openapi/DESIGN.md b/localpost/experimental/openapi/DESIGN.md deleted file mode 100644 index bc464e5..0000000 --- a/localpost/experimental/openapi/DESIGN.md +++ /dev/null @@ -1,66 +0,0 @@ -# Idea - -localpost.http.openapi is slim Python web framework built around OpenAPI spec. The main idea is to use Python type -system to define HTTP endpoints and infer as much OpenAPI spec from it as possible. - -## Router - -This is a slim foundation piece, like Werkzeug or Starlette, to communicate with the underlying HTTP server (WSGI, ASGI or our own HTTP server implementation). - -Here we define a RequestHandler, a basic interface for HTTP handlers (Request in, Response out). - -## Operations - -An operation is a Python function, that maps to an HTTP endpoint in OpenAPI terms. - -Operation configuration is taken from the function's signature: -- parameters type and annotations -- return type and annotations - -An operation in an app is directly converted to a request handler for the Router. - -## Arg resolvers - -Each function parameter should have a corresponding resolver, that will resolve the value from the request. - -Built-in resolver factories: -- FromPath -- FromQuery -- FromHeader -- FromBody - -Each arg resolver can end a operation by returning a instance of `OpResult`. Otherwise, the resolver should return a resolved value which will be used as an argument in the function call, for the corresponding parameter. - -## Filters - -A filter is kinda like a middleware, but limited to the first part of the request handling pipeline. - -Each filter can end a operation by returning a instance of `OpResult`. Otherwise, the filter should return `None` to continue the request handling. - -## Result converters - -A result converter is a function that converts the return value of an operation's function call into a Response instance. - -## OpenAPI integration via the type system - -Ideally, in most of the applications, the OpenAPI spec should be inferred from the type annotations of the operation functions. - -Internally, the mechanism works like this: -- when HttpApp instance is created, an empty OpenAPI doc is created -- when an operation is added to the app, we do two things: - - wrap the operation's function to create a request handler, for the Router (with all the filters, arg resolvers and result converters inside) - - modify passed OpenAPI doc to include the operation's details, component schemas, and security schemes - -HttpApp router & openapi_doc are both immutable - -## OpenAPI spec - -OpenAPI spec is a set of Python dataclasses, which represent the OpenAPI specification. - -These dataclasses are designed to create an immutable structure. - -Usual way to with it: -- create an empty OpenAPI doc instance -- add operations to it (each change creates a new doc instance) -- add security schemes to it (each change creates a new doc instance) -- add components (JSON schemas) to it (each change creates a new doc instance) diff --git a/localpost/experimental/openapi/README.md b/localpost/experimental/openapi/README.md deleted file mode 100644 index db6a5ef..0000000 --- a/localpost/experimental/openapi/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# localpost.experimental.openapi - -> **Status:** experimental — lives under ``localpost.experimental.`` so the import path itself is the marker; expect breaking changes before `1.0`. - -A type-driven OpenAPI 3.0 framework sitting on top of [`localpost.http`](../../http/README.md). - -Define operations as ordinary Python functions. Types, annotations, and return -types are inspected to build the OpenAPI doc and to wire path / query / header / -body resolvers — no schemas to hand-write. The generated spec is served at -`/openapi.json`, with three doc UIs built in. - -For deeper design notes see [`DESIGN.md`](DESIGN.md). - -## Install - -```bash -pip install localpost[http-server,http-openapi] -``` - -The OpenAPI layer uses [msgspec](https://jcristharif.com/msgspec/) by default -for schema / JSON handling; Pydantic converters are also provided. - -## Quick start - -```python -from collections.abc import Generator -from dataclasses import dataclass -from typing import Annotated -from wsgiref.simple_server import make_server - -from localpost.experimental.openapi.app import BadRequest, FromPath, HttpApp, NotFound - - -@dataclass -class Book: - id: str - title: str - author: str - - -app = HttpApp() - - -@app.get("/hello/{name}") -def hello(name: str) -> str | BadRequest[str]: - if name.lower() == "donald": - return BadRequest("Sorry, you are not welcome here") - return f"Hello, {name}!" - - -@app.get("/books/{book_id}") -def get_book(book_id: str, page_number: int = 1) -> Book | NotFound[str]: - if book_id != "00a7a2d4-18e4-11f1-899b-d33838f3bef0": - return NotFound(f"Book not found: {book_id}") - return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien") - - -if __name__ == "__main__": - # Try: - # curl http://localhost:8000/hello/world - # curl http://localhost:8000/openapi.json - # Docs: - # http://localhost:8000/docs (Swagger UI) - # http://localhost:8000/docs/redoc (ReDoc) - # http://localhost:8000/docs/scalar (Scalar) - with make_server("", 8000, app.wsgi) as server: - server.serve_forever() -``` - -Full example: [`examples/openapi/app.py`](../../../examples/openapi/app.py). - -## Key concepts - -- **`HttpApp`** — the root object. Holds the router, the OpenAPI doc, and - registered operations. Exposes `.wsgi` for any WSGI host (including - `localpost.http.wsgi_server`). -- **Operations** — Python functions registered with `@app.get(path)`, - `.post`, etc. The path follows [`URITemplate`](../../http/README.md) syntax - (`/books/{id}`). -- **Arg resolvers** — one per parameter, picked from the annotation. Factories: - - `FromPath()` — path variable (auto-detected for params whose name matches a - `{name}` in the template). - - `FromQuery()` — query string (default for unannotated params that aren't - in the path). - - `FromHeader("Name")` - - `FromBody(converter=...)` — e.g. `PydanticBodyConverter`. - Any resolver can short-circuit by returning an `OpResult`. -- **`OpResult` hierarchy** — `Ok`, `BadRequest[T]`, `Unauthorized[T]`, - `NotFound[T]`, `TooManyRequests[T]`, etc. Return one from your function to - emit a non-200 response; the OpenAPI spec learns about it from the return - type annotation. -- **Result converters** — turn the function's return value into HTTP bytes. - Pluggable via `ResultConverterFactory`. Pydantic support is in - [`pydantic.py`](pydantic.py); msgspec handling is the default. -- **Immutable spec** — every registration produces a new OpenAPI doc; the doc - itself is a tree of frozen dataclasses from [`spec.py`](spec.py). - -## Public API - -From `localpost.experimental.openapi.app`: - -| Symbol | Purpose | -| ------------------- | --------------------------------------------------- | -| `HttpApp` | Root app; `.get`, `.post`, `.put`, `.delete`, `.wsgi`, `.openapi_doc` | -| `FromPath()` | Arg resolver factory — path variable | -| `FromQuery()` | Arg resolver factory — query string | -| `FromHeader(name)` | Arg resolver factory — header | -| `FromBody(converter)` | Arg resolver factory — body | -| `Ok[T]` | Success wrapper (usually implicit) | -| `BadRequest[T]`, `Unauthorized[T]`, `NotFound[T]`, `TooManyRequests[T]` | `OpResult` subclasses | -| `OpFilter` (Protocol) | Per-operation pre-filter (e.g. `limit_requests`) | - -From `localpost.experimental.openapi.spec`: - -| Symbol | Notes | -| -------------------- | --------------------------------------------------- | -| `OpenAPI`, `Info`, `Operation`, `Response`, `MediaType`, … | Immutable spec dataclasses | - -## Sub-modules - -| Module | What it provides | -| -------------- | ----------------------------------------------------------------------- | -| `app.py` | `HttpApp`, arg resolvers, `OpResult` hierarchy, `FluentPathOp` | -| `spec.py` | OpenAPI 3.0 spec dataclasses | -| `converters.py`| Generic body / result converter helpers | -| `pydantic.py` | `PydanticBodyConverter`, `PydanticResultConverter` — use if Pydantic is your SoT | -| `msgspec.py` | msgspec converters (stub; msgspec is the default for schema handling) | -| `sse.py` | `Event[T]`, `EventStream[T]` for Server-Sent Events (a generator return type is auto-promoted to SSE) | -| `_docs.py` | HTML templates for Swagger UI, ReDoc, Scalar (CDN-loaded) | - -## Writing a custom arg resolver - -```python -from localpost.experimental.openapi.app import ArgResolverFactory -from localpost.http.router import RequestCtx - -class FromCookie(ArgResolverFactory): - def __init__(self, name: str): - self.name = name - - def __call__(self, param): - def resolve(req: RequestCtx): - cookies = req.headers.get("cookie", "") - # ...parse... - return value - return resolve -``` - -Use it: - -```python -@app.get("/me") -def me(session: Annotated[str, FromCookie("session")]) -> str: - return session -``` - -## See also - -- Examples: [`examples/openapi/`](../../../examples/openapi/) -- Design notes: [`DESIGN.md`](DESIGN.md) -- Underlying server: [`../../http/README.md`](../../http/README.md) diff --git a/localpost/experimental/openapi/__init__.py b/localpost/experimental/openapi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/localpost/experimental/openapi/_docs.py b/localpost/experimental/openapi/_docs.py deleted file mode 100644 index b153f2b..0000000 --- a/localpost/experimental/openapi/_docs.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Built-in doc UI HTML templates (loaded from CDN). -""" - -SWAGGER_HTML = """\ - - - - - - Swagger UI - - - -
- - - -""" - -REDOC_HTML = """\ - - - - - - ReDoc - - - - - - -""" - -SCALAR_HTML = """\ - - - - - - Scalar API Reference - - -
- - - -""" diff --git a/localpost/experimental/openapi/app.py b/localpost/experimental/openapi/app.py deleted file mode 100644 index 02b584b..0000000 --- a/localpost/experimental/openapi/app.py +++ /dev/null @@ -1,565 +0,0 @@ -from __future__ import annotations - -import inspect -import json -import threading -from collections.abc import Callable, Collection, Mapping, Sequence -from dataclasses import dataclass -from http import HTTPMethod -from types import UnionType -from typing import Annotated, Any, ParamSpec, Protocol, Self, TypeVar, Union, final, get_args, get_origin - -import msgspec - -import localpost.experimental.openapi.spec as openapi_spec -from localpost.experimental.openapi._docs import REDOC_HTML, SCALAR_HTML, SWAGGER_HTML -from localpost.http.router import RequestCtx, RequestHandler, Response, Router, URITemplate - -P = ParamSpec("P") -R = TypeVar("R") -FluentOpDecorator = Callable[[Callable[P, R]], Callable[P, R]] - - -class OpFilter(Protocol): - # def __call__(...) -> None | OpResult: ... - # def __call__(...) -> None | OpResult: ... - - def update_doc(self, doc: openapi_spec.OpenAPI, op: openapi_spec.Operation | None = None): ... - - -class HttpBasicAuth(OpFilter): - def handle_req(auth_details: Annotated[bytes, FromHeader("Authorization")]) -> None | Unauthorized: - pass # TODO Implement - - def update_doc(self, doc: openapi_spec.OpenAPI, op: openapi_spec.Operation | None = None): - pass # TODO Add details to app's OpenAPI spec - - -class HttpBearerAuth(OpFilter): - def __init__(self, format: str = "JWT"): - self.format = format - - # So it can be used as a decorator - def __call__(self, f): - pass - - def handle_req(auth_details: Annotated[bytes, FromHeader("Authorization")]) -> None | Unauthorized: - pass # TODO Implement - - def update_doc(self, doc: openapi_spec.OpenAPI, op: openapi_spec.Operation | None = None): - pass # TODO Add details to app's OpenAPI spec - - -class OpenIDConnectAuth: - pass # TODO Implement, later - - -class BodyConverter(Protocol): - def __call__(self, req: RequestCtx) -> Any: ... - - def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI): ... - - -class ResultConverter(Protocol): - def __call__(self, res: Any) -> Iterable[bytes]: ... - - def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI): ... - - -# Params: -# - cache req body? Def NO -class BodyConverterFactory(Protocol): - def __call__(self, param: inspect.Parameter) -> None | BodyConverter: ... - - -class ResultConverterFactory(Protocol): - def __call__(self, return_annotation: type[Any]) -> None | ResultConverter: ... - - -# --- OpResult hierarchy --- - - -@dataclass(frozen=True, eq=False, slots=True) -class OpResult: - status_code: int - headers: Mapping[str, str] - body: object - - def __init__(self, code: int, body: object, /, *, headers: Mapping[str, str] | None = None): - object.__setattr__(self, "code", code) - object.__setattr__(self, "body", body) - object.__setattr__(self, "headers", headers or {}) - - -class Ok[T](OpResult): - _status_code = 200 - _description = "Successful response" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 200, body, headers=headers) - - -class Created[T](OpResult): - _status_code = 201 - _description = "Created" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 201, body, headers=headers) - - -class NoContent(OpResult): - _status_code = 204 - _description = "No Content" - - def __init__(self, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 204, None, headers=headers) - - -class BadRequest[T](OpResult): - _status_code = 400 - _description = "Bad Request" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 400, body, headers=headers) - - -class Unauthorized[T](OpResult): - _status_code = 401 - _description = "Unauthorized" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 401, body, headers=headers) - - -class Forbidden[T](OpResult): - _status_code = 403 - _description = "Forbidden" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 403, body, headers=headers) - - -class NotFound[T](OpResult): - _status_code = 404 - _description = "Not Found" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 404, body, headers=headers) - - -class TooManyRequests[T](OpResult): - _status_code = 429 - _description = "Too Many Requests" - - def __init__(self, body: T, /, *, headers: dict[str, str] | None = None): - OpResult.__init__(self, 429, body, headers=headers) - - -# --- Protocols --- - - -class ArgResolver(Protocol): - def __call__(self, request: RequestCtx, /) -> object: ... - - -class ArgResolverFactory: - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: ... - - -class ResponseResolver(Protocol): - def __call__(self, result: OpResult, /) -> Response: ... - - -# --- Resolver implementations --- - - -@final -class DefaultResponseResolver: - def __call__(self, result: OpResult, /) -> Response: - headers: dict[str, str] = dict(result.headers) - - if result.body is None: - return Response(status_code=result.status_code, headers=headers, body=[]) - - if isinstance(result.body, bytes): - body_bytes = result.body - headers.setdefault("Content-Type", "application/octet-stream") - elif isinstance(result.body, str): - body_bytes = msgspec.json.encode(result.body) - headers.setdefault("Content-Type", "application/json") - else: - try: - body_bytes = msgspec.json.encode(result.body) - except Exception: - body_bytes = json.dumps(result.body).encode() - headers.setdefault("Content-Type", "application/json") - - headers["Content-Length"] = str(len(body_bytes)) - return Response(status_code=result.status_code, headers=headers, body=[body_bytes]) - - -@final -@dataclass(kw_only=True, frozen=True, slots=True) -class FromBody(ArgResolverFactory): - """Built-in body resolver.""" - - json_converter: Callable[[bytes], object] | None = None - - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: - json_converter = self.json_converter or json.loads - - def resolve(request: RequestCtx) -> object: - return json_converter(request.body()) - - return resolve - - -@final -@dataclass(frozen=True, slots=True) -class FromHeader(ArgResolverFactory): - """Resolve from a header, e.g. X-User-Id.""" - - name: str | None = None - converter: Callable[[str], object] | None = None - - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: - header_name = self.name or param.name.replace("_", "-").lower() - converter = self.converter or str - has_default = param.default is not inspect.Parameter.empty - - def resolve(request: RequestCtx) -> object: - value = request.headers.get(header_name) - if value is None: - if has_default: - return param.default - raise ValueError(f"Missing required header: {header_name}") - return converter(value) - - return resolve - - -@final -@dataclass(frozen=True, slots=True) -class FromQuery(ArgResolverFactory): - """Resolve from a query parameter, e.g. /books?author=tolkien.""" - - param_name: str | None = None - converter: Callable[[Sequence[str]], object] | None = None - - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: - param_name = self.param_name or param.name - has_default = param.default is not inspect.Parameter.empty - if self.converter: - converter = self.converter - elif param.annotation is not inspect.Parameter.empty: - param_type = param.annotation - if get_origin(param_type) is Annotated: - param_type = get_args(param_type)[0] - if isinstance(param_type, type) and issubclass(param_type, Collection) and param_type is not str: - converter = param_type - elif isinstance(param_type, type): - converter = lambda values, _t=param_type: _t(values[0]) - else: - converter = lambda values: values[0] - else: - converter = lambda values: values[0] - - def resolve(request: RequestCtx) -> object: - values = request.query_args.get(param_name) - if values is None: - if has_default: - return param.default - raise ValueError(f"Missing required query parameter: {param_name}") - return converter(values) - - return resolve - - -@final -@dataclass(frozen=True, slots=True) -class FromPath(ArgResolverFactory): - """Resolve from a path parameter, e.g. /books/{id}.""" - - param_name: str | None = None - converter: Callable[[str], object] | None = None - - def __call__(self, param: inspect.Parameter, /) -> ArgResolver: - param_name = self.param_name or param.name - if self.converter: - converter = self.converter - elif param.annotation is not inspect.Parameter.empty: - param_type = param.annotation - if get_origin(param_type) is Annotated: - param_type = get_args(param_type)[0] - if isinstance(param_type, type): - converter = param_type - else: - converter = str - else: - converter = str - - def resolve(request: RequestCtx) -> object: - value = request.path_args.get(param_name) - if value is None: - raise ValueError(f"Missing path parameter: {param_name}") - return converter(value) - - return resolve - - -def _extract_responses(return_annotation) -> dict[str, openapi_spec.Response]: - """Build OpenAPI responses dict from a function's return type annotation.""" - if return_annotation is inspect.Parameter.empty: - return {"200": openapi_spec.Response(description="Successful response")} - - # Decompose Union types (str | BadRequest[str], Union[str, BadRequest[str]]) - origin = get_origin(return_annotation) - if origin is Union or origin is UnionType: - members = get_args(return_annotation) - else: - members = (return_annotation,) - - responses: dict[str, openapi_spec.Response] = {} - has_success = False - - for member in members: - # Unwrap generic OpResult subclasses like BadRequest[str] → BadRequest - member_origin = get_origin(member) - cls = member_origin if member_origin is not None else member - - if isinstance(cls, type) and issubclass(cls, OpResult): - code = str(cls._status_code) - description = cls._description - responses[code] = openapi_spec.Response( - description=description, - content={"application/json": openapi_spec.MediaType(schema={"type": "string"})}, - ) - if cls._status_code < 400: - has_success = True - else: - # Plain return type (str, Book, etc.) → 200 - has_success = True - responses["200"] = openapi_spec.Response( - description="Successful response", - content={"application/json": openapi_spec.MediaType(schema={"type": "string"})}, - ) - - if not has_success: - responses["200"] = openapi_spec.Response(description="Successful response") - - return responses - - -def _extract_resolver_factory(annotation) -> ArgResolverFactory | None: - """Extract an ArgResolverFactory from an Annotated type, if present.""" - if get_origin(annotation) is Annotated: - for arg in get_args(annotation): - if isinstance(arg, ArgResolverFactory): - return arg - return None - - -# --- FluentPathOp --- - - -@final -@dataclass(eq=False, frozen=True) -class FluentPathOp: - method: HTTPMethod - path: str - """Path pattern, like /books/{id} or /shop/{name}/checkout.""" - - target: Callable[..., object] - arg_resolvers: Mapping[str, ArgResolver] - response_resolver: ResponseResolver - - @classmethod - def create(cls, method: HTTPMethod, path: str, func: Callable, /) -> Self: - template = URITemplate.parse(path) - path_var_names = set(template.variable_names) - - sig = inspect.signature(func) - arg_resolvers: dict[str, ArgResolver] = {} - - for param_name, param in sig.parameters.items(): - annotation = param.annotation - resolver_factory = _extract_resolver_factory(annotation) - - if resolver_factory is not None: - arg_resolvers[param_name] = resolver_factory(param) - elif param_name in path_var_names: - arg_resolvers[param_name] = FromPath()(param) - else: - arg_resolvers[param_name] = FromQuery()(param) - - return cls( - method=method, - path=path, - target=func, - arg_resolvers=arg_resolvers, - response_resolver=DefaultResponseResolver(), - ) - - def as_handler(self) -> RequestHandler: - def handler(ctx: RequestCtx) -> Response: - return self(ctx) - - return handler - - def __call__(self, request: RequestCtx) -> Response: - args = {} - for name, resolver in self.arg_resolvers.items(): - result = resolver(request) - if isinstance(result, OpResult): - return self.response_resolver(result) - args[name] = result - return_value = self.target(**args) - if isinstance(return_value, OpResult): - op_result = return_value - else: - op_result = Ok(return_value) - return self.response_resolver(op_result) - - -# --- HttpApp --- - - -@final -class HttpApp: - def __init__(self, *, info: openapi_spec.Info | None = None): - self._router: Router | None = None - self._router_lock: threading.Lock = threading.Lock() - self._path_ops: list[FluentPathOp] = [] - self._info = info or openapi_spec.Info() - - def wsgi(self, environ, start_response): - """WSGI app, to be used with any WSGI server, e.g. Granian.""" - return self.router.wsgi(environ, start_response) - - @property - def docs(self) -> openapi_spec.OpenAPI: - spec = openapi_spec.OpenAPI(info=self._info) - for op in self._path_ops: - params: list[openapi_spec.Parameter] = [] - template = URITemplate.parse(op.path) - for var_name in template.variable_names: - params.append( - openapi_spec.Parameter(name=var_name, location="path", required=True, schema={"type": "string"}) - ) - for arg_name in op.arg_resolvers: - if arg_name not in template.variable_names: - params.append( - openapi_spec.Parameter( - name=arg_name, location="query", required=True, schema={"type": "string"} - ) - ) - return_annotation = inspect.signature(op.target).return_annotation - responses = _extract_responses(return_annotation) - operation = openapi_spec.Operation( - summary=op.target.__doc__ or f"{op.method.value} {op.path}", - operation_id=f"{op.method.value.lower()}_{op.path.replace('/', '_').strip('_')}", - parameters=tuple(params), - responses=responses, - ) - spec = spec.add_operation(op.path, op.method.value, operation) - return spec - - @property - def router(self) -> Router: - if router := self._router: - return router - with self._router_lock: - if not self._router: - self._router = self._create_router() - return self._router - - def _create_router(self) -> Router: - paths: dict[URITemplate, dict[HTTPMethod, RequestHandler]] = {} - - for op in self._path_ops: - template = URITemplate.parse(op.path) - # Find existing template with same string - target_key: URITemplate | None = None - for existing in paths: - if existing.template == template.template: - target_key = existing - break - if target_key is None: - paths[template] = {op.method: op.as_handler()} - else: - paths[target_key][op.method] = op.as_handler() - - # Built-in routes - app_ref = self - - def openapi_handler(ctx: RequestCtx) -> Response: - return Response( - status_code=200, - headers={"Content-Type": "application/json"}, - body=[app_ref.docs.to_json()], - ) - - def _html_response(html: str) -> Response: - body = html.encode() - return Response( - status_code=200, - headers={"Content-Type": "text/html; charset=utf-8", "Content-Length": str(len(body))}, - body=[body], - ) - - def swagger_handler(_: RequestCtx) -> Response: - return _html_response(SWAGGER_HTML) - - def redoc_handler(_: RequestCtx) -> Response: - return _html_response(REDOC_HTML) - - def scalar_handler(_: RequestCtx) -> Response: - return _html_response(SCALAR_HTML) - - paths[URITemplate.parse("/openapi.json")] = {HTTPMethod.GET: openapi_handler} - paths[URITemplate.parse("/docs")] = {HTTPMethod.GET: swagger_handler} - paths[URITemplate.parse("/docs/redoc")] = {HTTPMethod.GET: redoc_handler} - paths[URITemplate.parse("/docs/scalar")] = {HTTPMethod.GET: scalar_handler} - return Router(paths=paths) - - def register(self, op: FluentPathOp) -> FluentPathOp: - self._path_ops.append(op) - with self._router_lock: - self._router = None - return op - - def get(self, path: str) -> FluentOpDecorator: - """Decorator to register a GET operation on the given path.""" - - def decorator(func): - self.register(FluentPathOp.create(HTTPMethod.GET, path, func)) - return func - - return decorator - - def post(self, path: str) -> FluentOpDecorator: - """Decorator to register a POST operation on the given path.""" - - def decorator(func): - self.register(FluentPathOp.create(HTTPMethod.POST, path, func)) - return func - - return decorator - - def put(self, path: str) -> FluentOpDecorator: - """Decorator to register a PUT operation on the given path.""" - - def decorator(func): - self.register(FluentPathOp.create(HTTPMethod.PUT, path, func)) - return func - - return decorator - - def delete(self, path: str) -> FluentOpDecorator: - """Decorator to register a DELETE operation on the given path.""" - - def decorator(func): - self.register(FluentPathOp.create(HTTPMethod.DELETE, path, func)) - return func - - return decorator diff --git a/localpost/experimental/openapi/converters.py b/localpost/experimental/openapi/converters.py deleted file mode 100644 index b40947e..0000000 --- a/localpost/experimental/openapi/converters.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Converters for built-in types: numbers, strings, UUIDs, datatime types. -""" - -# TODO diff --git a/localpost/experimental/openapi/msgspec.py b/localpost/experimental/openapi/msgspec.py deleted file mode 100644 index e69de29..0000000 diff --git a/localpost/experimental/openapi/pydantic.py b/localpost/experimental/openapi/pydantic.py deleted file mode 100644 index 6b1e67d..0000000 --- a/localpost/experimental/openapi/pydantic.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel - -import localpost.spec.openapi as openapi_spec -from localpost.http.openapi.app import BadRequest, OpResult -from localpost.http.router import RequestCtx, Response - - -class PydanticBodyConverter: - def __init__(self, model: type[BaseModel]): - self.model = model - - def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI) -> None: - pass # TODO Implement, add schema - - def __call__(self, req: RequestCtx) -> BaseModel | BadRequest[str]: - return self.model.model_validate_json(req.body()) - - -class PydanticResultConverter: - def __init__(self, model: type[BaseModel]): - self.model = model - - def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI) -> None: - pass # TODO Implement, add schema - - def __call__(self, res: OpResult, req: RequestCtx) -> Response: - body = res.body - assert isinstance(body, BaseModel), f"Expected {self.model.__name__}, got {type(body).__name__}" - # TODO Handle PydanticSerializationError - # TODO Do it via TypeAdapter, to get bytes directly - # return [res.model_dump_json().encode()] - return Response(res.status_code, res.headers, [body.model_dump_json().encode()]) diff --git a/localpost/experimental/openapi/spec.py b/localpost/experimental/openapi/spec.py deleted file mode 100644 index 6846db7..0000000 --- a/localpost/experimental/openapi/spec.py +++ /dev/null @@ -1,187 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field, replace -from typing import Any, Literal - -import msgspec - -DEFAULT_CONTENT_TYPE = "application/json" - - -@dataclass(frozen=True, slots=True) -class Schema: - type: str | None = None - properties: dict[str, Any] = field(default_factory=dict) - required: list[str] = field(default_factory=list) - items: dict[str, Any] | None = None - ref: str | None = None - extra: dict[str, Any] = field(default_factory=dict) - """Any additional JSON Schema fields.""" - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.ref: - d["$ref"] = self.ref - return d - if self.type: - d["type"] = self.type - if self.properties: - d["properties"] = self.properties - if self.required: - d["required"] = self.required - if self.items: - d["items"] = self.items - d.update(self.extra) - return d - - -@dataclass(frozen=True, slots=True) -class MediaType: - schema: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.schema: - d["schema"] = self.schema - return d - - -@dataclass(frozen=True, slots=True) -class RequestBody: - content: dict[str, MediaType] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - if not self.content: - return {} - return {"content": {k: v.to_dict() for k, v in self.content.items()}} - - -@dataclass(frozen=True, slots=True) -class Response: - description: str = "" - content: dict[str, MediaType] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"description": self.description} - if self.content: - d["content"] = {k: v.to_dict() for k, v in self.content.items()} - return d - - -@dataclass(frozen=True, slots=True) -class Parameter: - name: str - location: Literal["query", "header", "cookie", "path"] = "query" - required: bool = True - description: str = "" - schema: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"name": self.name, "in": self.location, "required": self.required} - if self.description: - d["description"] = self.description - if self.schema: - d["schema"] = self.schema - return d - - -@dataclass(frozen=True, slots=True) -class Operation: - summary: str = "" - operation_id: str = "" - description: str = "" - parameters: tuple[Parameter, ...] = () - request_body: RequestBody | None = None - responses: dict[str, Response] = field(default_factory=dict) - deprecated: bool = False - tags: tuple[str, ...] = () - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.summary: - d["summary"] = self.summary - if self.operation_id: - d["operationId"] = self.operation_id - if self.description: - d["description"] = self.description - if self.parameters: - d["parameters"] = [p.to_dict() for p in self.parameters] - if self.request_body: - rb = self.request_body.to_dict() - if rb: - d["requestBody"] = rb - if self.responses: - d["responses"] = {k: v.to_dict() for k, v in self.responses.items()} - if self.deprecated: - d["deprecated"] = True - if self.tags: - d["tags"] = list(self.tags) - return d - - -@dataclass(frozen=True, slots=True) -class PathItem: - operations: dict[str, Operation] = field(default_factory=dict) - """Keyed by lowercase HTTP method: get, post, put, etc.""" - - def to_dict(self) -> dict[str, Any]: - return {method: op.to_dict() for method, op in self.operations.items()} - - -@dataclass(frozen=True, slots=True) -class Info: - title: str = "API" - description: str = "" - version: str = "0.1.0" - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"title": self.title, "version": self.version} - if self.description: - d["description"] = self.description - return d - - -@dataclass(frozen=True, slots=True) -class Tag: - name: str - description: str = "" - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"name": self.name} - if self.description: - d["description"] = self.description - return d - - -@dataclass(frozen=True, slots=True) -class OpenAPI: - openapi: str = "3.1.0" - info: Info = field(default_factory=Info) - paths: dict[str, PathItem] = field(default_factory=dict) - tags: tuple[Tag, ...] = () - - def add_operation(self, path: str, method: str, operation: Operation) -> OpenAPI: - """Return a new OpenAPI instance with the operation added.""" - new_paths = dict(self.paths) - if path in new_paths: - existing = new_paths[path] - new_ops = dict(existing.operations) - new_ops[method.lower()] = operation - new_paths[path] = PathItem(operations=new_ops) - else: - new_paths[path] = PathItem(operations={method.lower(): operation}) - return replace(self, paths=new_paths) - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = { - "openapi": self.openapi, - "info": self.info.to_dict(), - } - if self.paths: - d["paths"] = {path: item.to_dict() for path, item in self.paths.items()} - if self.tags: - d["tags"] = [t.to_dict() for t in self.tags] - return d - - def to_json(self) -> bytes: - return msgspec.json.encode(self.to_dict()) diff --git a/localpost/experimental/openapi/sse.py b/localpost/experimental/openapi/sse.py deleted file mode 100644 index 5fd0768..0000000 --- a/localpost/experimental/openapi/sse.py +++ /dev/null @@ -1,29 +0,0 @@ -from collections.abc import Callable, Iterator -from dataclasses import dataclass - -import msgspec - -import localpost.spec.openapi as openapi_spec -from localpost.http.openapi.app import OpResult -from localpost.http.router import RequestCtx, Response - - -class Event[T](msgspec.Struct, eq=False, omit_defaults=True): - data: T - type: str | None = None - id: str | None = None - retry: int | None = None - - -@dataclass(frozen=True, eq=False, slots=True) -class EventStream[T]: - source: Iterator[T] | Iterator[Event[T]] - event_data_converter: Callable[[T], str] - - -class EventStreamResultConverter: - def update_doc(self, op: openapi_spec.Operation, doc: openapi_spec.OpenAPI) -> None: - pass # TODO Implement, add response content-type, etc. - - def __call__(self, res: OpResult, req: RequestCtx) -> Response: - pass # TODO Implement (set SSE headers, run the iterator, etc.) From 51f7afc12a94710f2a615bd25c9dd27933e3b113 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 1 May 2026 02:25:14 +0400 Subject: [PATCH 150/286] fix: handle service and http edge cases --- localpost/hosting/_host.py | 56 ++++++++++++++++++++++++++++-- localpost/http/router.py | 46 +++++++++++++++++------- localpost/http/server_h11.py | 10 ++++++ localpost/http/server_httptools.py | 15 +++++++- tests/hosting/hosted_service.py | 44 ++++++++++++++++++++++- tests/http/backend_parity.py | 49 ++++++++++++++++++++++++++ tests/http/router.py | 36 +++++++++++++++++++ 7 files changed, 238 insertions(+), 18 deletions(-) diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index a47ce36..8432cc3 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field from dataclasses import dataclass as define from functools import cached_property, wraps -from typing import Any, ClassVar, Literal, final, overload +from typing import Any, ClassVar, Literal, final, get_type_hints, overload from anyio import CancelScope, CapacityLimiter, create_task_group, get_cancelled_exc_class, to_thread from anyio.abc import TaskGroup @@ -303,6 +303,11 @@ def service[**P]() -> Callable[[Callable[P, Any]], Callable[P, _ResolvedService] def service(target: Callable[..., Any] | None = None): def decorator(func: Callable[..., Any]) -> Callable[..., _ResolvedService]: + if inspect.isasyncgenfunction(func): + return _service_cm(func) + if _is_direct_service(func): + return _service_direct(func) + @wraps(func) def wrapper(*args, **kwargs): raw_svc_f = func(*args, **kwargs) @@ -315,13 +320,58 @@ async def svc_f(lt: ServiceLifetime) -> None: await to_thread.run_sync(raw_svc_f, lt, limiter=limiter) return _ResolvedService(svc_f) - if inspect.isasyncgenfunction(func): - return _service_cm(func) return wrapper return decorator(target) if callable(target) else decorator +def _is_direct_service(func: Callable[..., Any]) -> bool: + """Return True for ``@service`` applied directly to ``svc(lt)``.""" + try: + sig = inspect.signature(func) + except (TypeError, ValueError): + return False + + params = [ + p + for p in sig.parameters.values() + if p.kind + in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + } + ] + if len(params) != 1 or params[0].default is not inspect.Parameter.empty: + return False + + annotation = params[0].annotation + if annotation is ServiceLifetime: + return True + + try: + return get_type_hints(func).get(params[0].name) is ServiceLifetime + except (NameError, TypeError, AttributeError): + return False + + +def _service_direct(func: Callable[[ServiceLifetime], Any]) -> Callable[[], _ResolvedService]: + """Decorator adapter for direct async/sync service functions.""" + + @wraps(func) + def wrapper() -> _ResolvedService: + if is_async_callable(func): + return _ResolvedService(func) + + limiter = CapacityLimiter(1) + + async def svc_f(lt: ServiceLifetime) -> None: + await to_thread.run_sync(func, lt, limiter=limiter) + + return _ResolvedService(svc_f) + + return wrapper + + def _service_cm(func: Callable[..., AsyncGenerator]): """Decorator to transform a generator function into a service factory.""" cm_f = asynccontextmanager(func) diff --git a/localpost/http/router.py b/localpost/http/router.py index d3228b9..32dff72 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -217,7 +217,7 @@ def from_routes(cls, routes: Routes) -> Self: for i, (tmpl, method_map) in enumerate(ordered): prefix = f"r{i}" - allow_header = ", ".join(m.value for m in sorted(method_map, key=lambda hm: hm.value)) + allow_header = _format_allow_header(method_map) compiled_routes.append( Route( template=tmpl, @@ -243,21 +243,31 @@ def _match(self, path: str, method_str: str) -> _MatchResult: if m is None: return _MATCH_NOT_FOUND + try: + method = HTTPMethod(method_str) + except ValueError: + method = None + + allowed_methods: set[HTTPMethod] = set() + matched_routes = 0 + single_route_405: tuple[_NativeResponse, bytes] | None = None + for route in self.routes: - if m.group(route._group_prefix) is None: + path_args = route.template.match(path) + if path_args is None: continue + matched_routes += 1 + allowed_methods.update(route.methods) + single_route_405 = route.method_not_allowed + tmpl = route.template method_map = route.methods - path_args = {v: m.group(f"{route._group_prefix}_{v}") or "" for v in tmpl.variable_names} - - try: - method = HTTPMethod(method_str) - except ValueError: - return _MatchMethodNotAllowed(route=route) + if method is None: + continue handler = method_map.get(method) if handler is None: - return _MatchMethodNotAllowed(route=route) + continue return _MatchOk( handler=handler, @@ -268,7 +278,13 @@ def _match(self, path: str, method_str: str) -> _MatchResult: ), ) - raise AssertionError("unreachable: regex matched but no outer group set") + if matched_routes == 1: + assert single_route_405 is not None + return _MatchMethodNotAllowed(*single_route_405) + if allowed_methods: + return _MatchMethodNotAllowed(*_build_method_not_allowed(_format_allow_header(allowed_methods))) + + raise AssertionError("unreachable: regex matched but no route template matched") def as_handler(self) -> NativeRequestHandler: """Return a :data:`localpost.http.RequestHandler` that dispatches via this router. @@ -293,8 +309,7 @@ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: ctx.complete(_NOT_FOUND_RESPONSE, _NOT_FOUND_BODY) return None if isinstance(match, _MatchMethodNotAllowed): - response, body = match.route.method_not_allowed - ctx.complete(response, body) + ctx.complete(match.response, match.body) return None ctx.attrs[RouteMatch] = match.match @@ -322,7 +337,8 @@ class _MatchNotFound: @final @dataclass(frozen=True, slots=True) class _MatchMethodNotAllowed: - route: Route + response: _NativeResponse + body: bytes _MatchResult = _MatchOk | _MatchNotFound | _MatchMethodNotAllowed @@ -361,6 +377,10 @@ def _find_template( return None +def _format_allow_header(methods: Mapping[HTTPMethod, object] | set[HTTPMethod]) -> str: + return ", ".join(m.value for m in sorted(methods, key=lambda hm: hm.value)) + + def _build_plain_response( status_code: int, body: bytes, diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index e541621..af6b15c 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -66,6 +66,10 @@ def _content_length(headers) -> int | None: return None +def _has_response_framing(headers) -> bool: + return any(name.lower() in {b"content-length", b"transfer-encoding"} for name, _ in headers) + + @final @dataclass(eq=False, slots=True) # TODO Rename to just HTTPConn @@ -399,6 +403,12 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: def start_response(self, response: Response | InformationalResponse, /) -> None: if isinstance(response, Response): self.response_status = response.status_code + if self.request.method == b"HEAD" and not _has_response_framing(response.headers): + response = Response( + status_code=response.status_code, + headers=[*response.headers, (b"content-length", b"0")], + reason=response.reason, + ) # Drive the h11 state machine, but buffer the bytes for a # coalesced ``sendall`` with the first body chunk. payload = self.conn.parser.send(_to_h11_response(response)) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 94e5403..5bbcd03 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -106,6 +106,10 @@ def _scan_response_headers(headers: Sequence[tuple[bytes, bytes]]) -> tuple[bool return has_close, has_framing +def _response_allows_body(request_method: bytes, status_code: int) -> bool: + return request_method != b"HEAD" and not (100 <= status_code < 200 or status_code in {204, 304}) + + @final @dataclass(eq=False, slots=True) # TODO Rename to HTTPConn @@ -486,6 +490,9 @@ class HTTPReqCtxHttptools: _pending_header_bytes: bytes | None = None """Buffered status line + headers, awaiting flush on first body chunk or ``finish_response``. ``None`` once flushed.""" + _body_allowed: bool = True + """False for HEAD / 1xx / 204 / 304 responses, where response body bytes + must not be written even if the handler passes a body to ``complete``.""" @property def borrowed(self) -> bool: @@ -611,10 +618,11 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: if isinstance(response, Response): self.response_status = response.status_code self.conn._response_started = True + self._body_allowed = _response_allows_body(self.request.method, response.status_code) has_close, has_framing = _scan_response_headers(response.headers) if has_close or not self._keep_alive: self.conn._close_after_response = True - if not has_framing: + if not has_framing and self._body_allowed: # Auto-frame: no Content-Length / Transfer-Encoding → chunked. # Without framing, an HTTP/1.1 client would wait for the # connection to close before considering the response done. @@ -632,6 +640,11 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: def send(self, chunk: Buffer, /) -> None: if not isinstance(chunk, bytes): chunk = bytes(chunk) + if not self._body_allowed: + if self._pending_header_bytes is not None: + _send_all(self.conn, self._pending_header_bytes) + self._pending_header_bytes = None + return if not chunk and self._pending_header_bytes is None: return framed = (f"{len(chunk):x}\r\n".encode("ascii") + chunk + b"\r\n") if self._chunked and chunk else chunk diff --git a/tests/hosting/hosted_service.py b/tests/hosting/hosted_service.py index 1b3a32f..b3d422e 100644 --- a/tests/hosting/hosted_service.py +++ b/tests/hosting/hosted_service.py @@ -1,4 +1,5 @@ -import anyio +import threading + import pytest from localpost.hosting import ServiceLifetime, service, serve @@ -24,6 +25,26 @@ async def svc(lt: ServiceLifetime): assert lt.exit_code == 0 +async def test_service_decorator_direct_async(): + stopped = False + + @service + async def direct(lt: ServiceLifetime): + nonlocal stopped + lt.set_started() + await lt.shutting_down.wait() + stopped = True + + resolved = direct() + async with serve(resolved) as lt: + await lt.started + lt.shutdown() + await lt.stopped + + assert stopped + assert lt.exit_code == 0 + + async def test_service_decorator_sync(): work_done = False @@ -46,6 +67,27 @@ def svc(lt: ServiceLifetime): assert work_done +async def test_service_decorator_direct_sync(): + worker_thread_id = None + + @service + def direct_sync(lt: ServiceLifetime): + nonlocal worker_thread_id + worker_thread_id = threading.get_ident() + lt.set_started() + lt.view.wait_shutting_down() + + resolved = direct_sync() + async with serve(resolved) as lt: + host_thread_id = threading.get_ident() + await lt.started + lt.shutdown() + await lt.stopped + + assert worker_thread_id is not None + assert worker_thread_id != host_thread_id + + async def test_service_decorator_context_manager(): entered = False exited = False diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index dd3e441..ca779d4 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -189,3 +189,52 @@ def handler(ctx: HTTPReqCtx) -> None: assert r.status_code == 200 assert r.content == b"chunk1chunk2" + + +def test_unframed_204_response_has_no_transfer_encoding(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.start_response(NativeResponse(status_code=204, headers=[])) + ctx.finish_response() + + with serve_backend_in_thread(handler) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert r.status_code == 204 + assert "transfer-encoding" not in r.headers + assert r.content == b"" + + +def test_unframed_304_response_has_no_transfer_encoding(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.start_response(NativeResponse(status_code=304, headers=[])) + ctx.finish_response() + + with serve_backend_in_thread(handler) as port: + r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) + + assert r.status_code == 304 + assert "transfer-encoding" not in r.headers + assert r.content == b"" + + +def test_unframed_head_response_has_no_body_or_transfer_encoding(serve_backend_in_thread): + def handler(ctx: HTTPReqCtx) -> None: + ctx.start_response(NativeResponse(status_code=200, headers=[])) + ctx.finish_response() + + with serve_backend_in_thread(handler) as port: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(b"HEAD / HTTP/1.1\r\nHost: x\r\n\r\n") + data = read_until(sock, b"\r\n\r\n", deadline=2) + headers, _, body = data.partition(b"\r\n\r\n") + + sock.settimeout(0.2) + try: + extra = sock.recv(16) + except TimeoutError: + extra = b"" + + assert b"HTTP/1.1 200" in headers + assert b"transfer-encoding:" not in headers.lower() + assert body == b"" + assert extra == b"" diff --git a/tests/http/router.py b/tests/http/router.py index ad1f740..ba78782 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -227,6 +227,42 @@ def test_unknown_method_returns_405(self, serve_in_thread): resp = httpx.request("PATCH", f"http://127.0.0.1:{port}/r", timeout=5) assert resp.status_code == 405 + def test_overlapping_templates_scan_for_matching_method(self, serve_in_thread): + routes = Routes() + routes.get("/users/{id}")(_ok_handler(b"get")) + routes.post("/users/{name}")(_ok_handler(b"post")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.post(f"http://127.0.0.1:{port}/users/42", timeout=5) + + assert resp.status_code == 200 + assert resp.text == "post" + + def test_overlapping_templates_no_matching_method_returns_405(self, serve_in_thread): + routes = Routes() + routes.get("/users/{id}")(_ok_handler(b"get")) + routes.post("/users/{name}")(_ok_handler(b"post")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.patch(f"http://127.0.0.1:{port}/users/42", timeout=5) + + assert resp.status_code == 405 + + def test_overlapping_templates_405_allow_header_is_sorted_union(self, serve_in_thread): + routes = Routes() + routes.post("/users/{name}")(_ok_handler(b"post")) + routes.delete("/users/{id}")(_ok_handler(b"delete")) + routes.get("/users/{slug}")(_ok_handler(b"get")) + router = routes.build() + + with serve_in_thread(router.as_handler()) as port: + resp = httpx.patch(f"http://127.0.0.1:{port}/users/42", timeout=5) + + assert resp.status_code == 405 + assert resp.headers.get("allow") == "DELETE, GET, POST" + class TestPathVariableEncoding: def test_url_encoded_path_arg_passed_through(self, serve_in_thread): From 96f68f9e06ffce6a327a4e28e732c3c2b9712d80 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 1 May 2026 02:32:31 +0400 Subject: [PATCH 151/286] chore: clean up examples, tests, docs, and pyproject after experimental drop Follow-up to 0edb99e: remove the leftover examples (consumers/openapi), experimental tests, and references in CLAUDE.md / AGENTS.md / README.md / CHANGELOG.md / http README / benchmark notes. Drop the broker / openapi extras and dep groups (sqs, kafka, nats, http-openapi, dev-consumers, tests-integration) from pyproject.toml and re-resolve the lock. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 +- CLAUDE.md | 57 +- README.md | 43 +- benchmarks/http/PERF_FINDINGS.md | 2 - benchmarks/http/_setup.py | 5 +- examples/consumers/__init__.py | 0 examples/consumers/kafka/__init__.py | 0 examples/consumers/sqs/__init__.py | 0 examples/consumers/sqs/app.py | 59 -- examples/consumers/sqs/app_lambda.py | 27 - examples/openapi/__init__.py | 0 examples/openapi/app.py | 80 --- localpost/http/README.md | 7 +- pypi_readme.md | 9 +- pyproject.toml | 43 +- tests/experimental/__init__.py | 0 tests/experimental/consumers/__init__.py | 0 tests/experimental/consumers/stream.py | 38 -- uv.lock | 786 +---------------------- 19 files changed, 52 insertions(+), 1124 deletions(-) delete mode 100644 examples/consumers/__init__.py delete mode 100644 examples/consumers/kafka/__init__.py delete mode 100644 examples/consumers/sqs/__init__.py delete mode 100755 examples/consumers/sqs/app.py delete mode 100755 examples/consumers/sqs/app_lambda.py delete mode 100644 examples/openapi/__init__.py delete mode 100644 examples/openapi/app.py delete mode 100644 tests/experimental/__init__.py delete mode 100644 tests/experimental/consumers/__init__.py delete mode 100644 tests/experimental/consumers/stream.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1c5aa..dab8774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,9 +116,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 output. Pythonic helpers (decorators, response conversion, param injection) move to the new `HttpApp`. See `PERF_FINDINGS.md` Phase 9 — restructure delivers another +35% RPS on the bench's hot path. -- **`localpost.experimental.openapi` is paused.** It was built on the - old Router shape; the new lean dispatcher leaves it broken at - import time. To be revived against `HttpApp` in a follow-up. - **`localpost.http` no longer leaks h11 types into the public API.** `HTTPReqCtx.request` is now `localpost.http.Request` (was `h11.Request`); `HTTPReqCtx.start_response` and `complete` accept @@ -138,11 +135,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 drop their `max_concurrency` kwarg for the same reason — wrap with `thread_pool_handler` if you need a pool (typical for blocking WSGI / Flask apps). -- **Experimental sub-packages moved.** `localpost.consumers` → - `localpost.experimental.consumers`; `localpost.openapi` → - `localpost.experimental.openapi`. The `experimental` segment in every - import path is the new stability marker — README notes alone were too - easy to miss. APIs themselves are unchanged. - Modernised typing throughout `localpost/_utils.py`, `localpost/scheduler/`, and `localpost/hosting/_host.py` to PEP 695 (`class Foo[T]`, `type Foo = ...`, inline function type parameters). @@ -152,13 +144,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 inside nested generic functions. - Dropped the `Programming Language :: Python :: 3.11` classifier (the project's `requires-python = ">=3.12"` since 0.6). -- Cleaner ruff/ty footprint across stable packages and shared infra +- Cleaner ruff/ty footprint across the package and shared infra (`localpost/__init__.py`, `_utils.py`, `threadtools.py`): 0 errors - from either tool. Remaining warnings live entirely in - `localpost/experimental/`. + from either tool. ### Removed +- **`localpost.experimental` is gone.** Both `experimental.consumers` + (channel / stream / queue / Pub/Sub) and `experimental.openapi` are + removed to keep the focus on the stable surface (`hosting`, `scheduler`, + `http`, `di`). The corresponding extras (`[sqs]`, `[kafka]`, `[nats]`, + `[http-openapi]`) and `examples/consumers/`, `examples/openapi/`, + `tests/experimental/` are removed too. May come back as a separate + package once the design settles. - Internal helpers that were unused everywhere: `localpost._utils.NO_OP_TS`, `AsyncContextManagerAdapter`, `Switch`, the `send_or_drop_from_thread` / `send_or_drop` methods on the now-trivial `MemorySendStream` (so diff --git a/CLAUDE.md b/CLAUDE.md index 4694419..7b0af09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,22 +5,17 @@ Guidance for Claude Code when working in this repository. ## Project overview LocalPost is an async Python framework (Python 3.12+) for building long-running -processes. Five pillars: +processes. Four pillars: -- **hosting** *(stable)* — service lifecycle + orchestration (start/stop/signals). -- **scheduler** *(stable)* — in-process, composable task scheduler. -- **http** *(stable)* — lightweight sync HTTP/1.1 server. -- **di** *(stable)* — small `.NET`-style IoC container with scopes. -- **experimental.consumers** *(experimental)* — message broker consumers (channel, stream, queue, Pub/Sub; more planned). -- **experimental.openapi** *(experimental)* — type-driven OpenAPI layer on top of `http`. +- **hosting** — service lifecycle + orchestration (start/stop/signals). +- **scheduler** — in-process, composable task scheduler. +- **http** — lightweight sync HTTP/1.1 server. +- **di** — small `.NET`-style IoC container with scopes. Built on AnyIO (runs on asyncio or Trio). -**Stability note:** "stable" modules have settled public APIs — avoid -breaking changes unless explicitly asked. Experimental modules live under -``localpost.experimental.`` so the import path itself flags them; they're -usable but their shape is still evolving — refactor freely, and flag -breaking changes in the CHANGELOG. +**Stability note:** all four modules have settled public APIs — avoid +breaking changes unless explicitly asked. ## Development commands @@ -74,29 +69,12 @@ localpost/ │ ├── flask.py # Flask integration (RequestContext per request) │ └── quart.py # Quart integration (stub) │ -├── http/ # Lightweight HTTP/1.1 server (h11-based, sync I/O loop) -│ ├── server.py # start_http_server, HTTPReqCtx, RequestHandler -│ ├── router.py # URITemplate (RFC 6570 L1), Router, RequestCtx -│ ├── wsgi.py # wrap_wsgi() -│ ├── config.py # ServerConfig -│ └── _service.py # @hosting.service wrappers (http_server, wsgi_server) -│ -└── experimental/ # Unstable APIs — wrapped in their own subpackage as a marker - ├── consumers/ # Message broker consumers - │ ├── channel.py # in-memory channel - │ ├── stream.py # AnyIO ObjectReceiveStream - │ ├── stdlib_queue.py # queue.Queue / SimpleQueue - │ ├── pubsub.py # Google Cloud Pub/Sub (in-progress; imports are broken) - │ └── _utils.py # SyncHandler / AsyncHandler / AnyHandler types - └── openapi/ # Type-driven OpenAPI framework - ├── app.py # HttpApp, FromPath/Query/Header/Body, OpResult hierarchy - ├── spec.py # OpenAPI spec dataclasses - ├── converters.py - ├── pydantic.py # Pydantic body/result converters - ├── msgspec.py # msgspec converters (stub) - ├── sse.py # Server-Sent Events - ├── _docs.py # Swagger UI / ReDoc / Scalar HTML templates - └── DESIGN.md # Deeper design notes +└── http/ # Lightweight HTTP/1.1 server (h11-based, sync I/O loop) + ├── server.py # start_http_server, HTTPReqCtx, RequestHandler + ├── router.py # URITemplate (RFC 6570 L1), Router, RequestCtx + ├── wsgi.py # wrap_wsgi() + ├── config.py # ServerConfig + └── _service.py # @hosting.service wrappers (http_server, wsgi_server) ``` Files prefixed with `_` are internal; public API is re-exported from each @@ -112,15 +90,14 @@ module's `__init__.py`. - **Errors as values** — `Result[T]` (Ok / failure) flows through scheduler tasks and arg resolvers. - **Ruff** with `line-length = 120`, `pyupgrade.keep-runtime-typing = true`. -- **Sync + async duality** — consumers and scheduler accept both; sync callables - are offloaded to threads via `anyio.to_thread`. +- **Sync + async duality** — the scheduler accepts both; sync callables are + offloaded to threads via `anyio.to_thread`. - Docstrings: Google convention (per ruff config). ## Testing - Unit tests: default `pytest` invocation, marker `not integration`. -- Integration tests: marked `@pytest.mark.integration`, use `testcontainers` - (LocalStack, NATS, Google Pub/Sub emulator), run in parallel via +- Integration tests: marked `@pytest.mark.integration`, run in parallel via `pytest-xdist` (`-n auto`). - `anyio_mode = "auto"` in `pyproject.toml` — async tests use `anyio[test]`. @@ -140,5 +117,3 @@ For more detail on each module, see: @localpost/scheduler/README.md @localpost/di/README.md @localpost/http/README.md -@localpost/experimental/consumers/README.md -@localpost/experimental/openapi/README.md diff --git a/README.md b/README.md index 3bb6bb2..cbd99a2 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ [![Code coverage](https://img.shields.io/sonar/coverage/alexeyshockov_localpost.py?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/project/overview?id=alexeyshockov_localpost.py) A small async Python framework for long-running processes: service hosting, -in-process task scheduling, message broker consumers, and a lightweight HTTP / -OpenAPI stack — all built on [AnyIO](https://anyio.readthedocs.io/) (runs on -asyncio **and** Trio). +in-process task scheduling, and a lightweight HTTP server — all built on +[AnyIO](https://anyio.readthedocs.io/) (runs on asyncio **and** Trio). LocalPost is not a monolith. Each module is usable on its own; pick what you need. @@ -18,11 +17,7 @@ need. ShuttingDown → Stopped`), signal handling, and composable middleware. - **Scheduler** with declarative triggers (`every`, `after`, `cron`) and operator-based composition (`every("1m") // delay((0, 10))`). -- **Consumers** for in-memory channels, AnyIO streams, `queue.Queue`, and - Google Cloud Pub/Sub — accepting both sync and async handlers. - **HTTP server** — sync, h11-based, ~400 LOC; wrap any WSGI app. -- **OpenAPI layer** — type-driven; OpenAPI 3.0 spec is inferred from your - function signatures, with Swagger UI / ReDoc / Scalar docs. - **IoC container** — `.NET`-style, scoped, with Flask integration. ## Install @@ -37,14 +32,8 @@ Optional extras: | ----------------- | ----------------------------------------------------------- | | `[cron]` | `croniter` — cron-expression trigger | | `[scheduler]` | `humanize`, `pytimeparse2` — string durations | -| `[http-server]` | `h11` — the HTTP server | -| `[http-openapi]` | `msgspec` — OpenAPI serialization | -| `[sqs]` | `botocore` — AWS SQS | -| `[kafka]` | `confluent-kafka` | -| `[nats]` | `nats-py` | -| `[pubsub]` | `google-cloud-pubsub` | -| `[azure-queue]` | `azure-storage-queue` + `azure-identity` | -| `[azure-servicebus]` | `azure-servicebus` + `azure-identity` | +| `[http]` | `h11` — the HTTP server | +| `[http-fast]` | `httptools` — alternative C-based parser | ## Quick start — scheduler @@ -75,19 +64,15 @@ parallel, and exits cleanly when they all stop. ## Modules -| Module | Status | Purpose | -| ------------------------------------------------------------------------------ | -------------- | ------------------------------------------------------- | -| [`hosting`](localpost/hosting/) | stable | Service lifecycle, signals, middleware, ASGI/gRPC adapters | -| [`scheduler`](localpost/scheduler/) | stable | Composable in-process task scheduler | -| [`di`](localpost/di/) | stable | Scoped IoC container | -| [`http`](localpost/http/) | stable | Small h11-based HTTP/1.1 server | -| [`experimental.consumers`](localpost/experimental/consumers/) | experimental | Message broker consumer services | -| [`experimental.openapi`](localpost/experimental/openapi/) | experimental | Type-driven OpenAPI framework | +| Module | Purpose | +| ----------------------------------- | ---------------------------------------------------------- | +| [`hosting`](localpost/hosting/) | Service lifecycle, signals, middleware, ASGI/gRPC adapters | +| [`scheduler`](localpost/scheduler/) | Composable in-process task scheduler | +| [`di`](localpost/di/) | Scoped IoC container | +| [`http`](localpost/http/) | Small h11-based HTTP/1.1 server | -"Stable" means the public API is not expected to break in a patch or minor -release. Experimental modules live under `localpost.experimental.`; -the import path itself is the marker — the API is still being shaped and -breaking changes may land before `1.0`. +All four modules have stable public APIs and are not expected to break in +patch or minor releases. Each subdirectory has its own README with a quickstart, key concepts, and extension points. @@ -100,8 +85,8 @@ extension points. operations; declarative middleware. - **Async-first** — built on AnyIO, so structured concurrency is the default, and you get Trio support for free. -- **Handle-both** — consumers and scheduler accept sync or async callables; - sync ones are offloaded to a thread pool. +- **Handle-both** — the scheduler accepts sync or async callables; sync ones + are offloaded to a thread pool. - **Small** — each module is focused and independently usable; take just the scheduler, just the hosting, or the full stack. diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index d4261f3..032e56e 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -891,8 +891,6 @@ return paths are still leaner than the old Router framework. ``(RequestCtx) -> Response`` shape) gone. Migrate to ``HttpApp`` or use the lean http-level :data:`localpost.http.RequestHandler` shape directly. -- ``localpost.experimental.openapi`` paused — it was built against - the old Router shape; revive against ``HttpApp`` in a future PR. ## Phase 10 (shipped): drop per-request settimeout (2026-04-30) diff --git a/benchmarks/http/_setup.py b/benchmarks/http/_setup.py index 557ff0d..8207c2f 100644 --- a/benchmarks/http/_setup.py +++ b/benchmarks/http/_setup.py @@ -6,9 +6,8 @@ Only the groups + extras the HTTP benchmark stacks actually import are installed — ``--all-groups --all-extras`` drags in native packages -(``grpcio-tools``, ``confluent-kafka``, ``google-cloud-pubsub``, -``opentelemetry-*``, …) that have spotty wheel coverage on free-threaded -Python (e.g. 3.14t). +(``grpcio``, ``opentelemetry-*``, …) that have spotty wheel coverage on +free-threaded Python (e.g. 3.14t). Invoked by ``just bench-deps`` and ``just bench-deps-upgrade``. """ diff --git a/examples/consumers/__init__.py b/examples/consumers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/consumers/kafka/__init__.py b/examples/consumers/kafka/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/consumers/sqs/__init__.py b/examples/consumers/sqs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/consumers/sqs/app.py b/examples/consumers/sqs/app.py deleted file mode 100755 index b2ad340..0000000 --- a/examples/consumers/sqs/app.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python - -import os - -from localpost import flow -from localpost.experimental.consumers import sqs_otel -from localpost.experimental.consumers.sqs import SqsMessage, sqs_queue_consumer - - -@sqs_queue_consumer("weather-forecasts") -@sqs_otel.trace() -@flow.handler -async def handle_message(message: SqsMessage): - import random - - import anyio - - with message: - print(f"Message received: {message.body}") - await anyio.sleep(random.uniform(0.01, 1.5)) # Simulate some work - - -def configure_otel(): - from opentelemetry import metrics, trace - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - - trace_provider = TracerProvider() - processor = BatchSpanProcessor(OTLPSpanExporter()) - trace_provider.add_span_processor(processor) - trace.set_tracer_provider(trace_provider) - - reader = PeriodicExportingMetricReader(OTLPMetricExporter()) - meter_provider = MeterProvider(metric_readers=[reader]) - metrics.set_meter_provider(meter_provider) - - -if __name__ == "__main__": - import logging - - import localpost - - os.environ["AWS_ENDPOINT_URL"] = "http://127.0.0.1:4566" # Localstack - os.environ["AWS_DEFAULT_REGION"] = "us-east-1" - os.environ["AWS_ACCESS_KEY_ID"] = "test" - os.environ["AWS_SECRET_ACCESS_KEY"] = "test" - os.environ["OTEL_SERVICE_NAME"] = "sample_sqs_consumer" - os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:18889" # Aspire Dashboard - os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "grpc" - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - exit(localpost.run(handle_message)) diff --git a/examples/consumers/sqs/app_lambda.py b/examples/consumers/sqs/app_lambda.py deleted file mode 100755 index e6f91d2..0000000 --- a/examples/consumers/sqs/app_lambda.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python - -from localpost.experimental.consumers.sqs import lambda_handler, sqs_queue_consumer - - -@sqs_queue_consumer("weather-forecasts") -@lambda_handler -async def handle_message(event, context): - import random - - import anyio - - messages = event["Records"] - print(f"Messages received: {messages}") - await anyio.sleep(random.uniform(0.01, 1.5)) # Simulate some work - - -if __name__ == "__main__": - import logging - - import localpost - - logging.basicConfig() - logging.getLogger().setLevel(logging.INFO) - logging.getLogger("localpost").setLevel(logging.DEBUG) - - exit(localpost.run(handle_message)) diff --git a/examples/openapi/__init__.py b/examples/openapi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/openapi/app.py b/examples/openapi/app.py deleted file mode 100644 index e3ed0b6..0000000 --- a/examples/openapi/app.py +++ /dev/null @@ -1,80 +0,0 @@ -import random -from collections.abc import Generator, Iterator -from dataclasses import dataclass -from typing import Annotated, Literal -from wsgiref.simple_server import make_server - -from localpost.http.openapi.app import BadRequest, FromPath, HttpApp, NotFound, TooManyRequests - - -@dataclass() -class Book: - id: str - title: str - author: str - - -@dataclass() -class BookPage: - book_id: str - number: int - content: str - - -@dataclass() -class User: - name: str - role: Literal["admin", "user"] - - -@op_filter -def limit_requests(client_id) -> None | TooManyRequests[str]: - if random.random() < 0.5: - return TooManyRequests("Too many requests", headers={"Retry-After": "1"}) - return None - - -def get_book(book_id: str, req, param) -> Book | NotFound[str]: - if book_id != "00a7a2d4-18e4-11f1-899b-d33838f3bef0": - return NotFound(f"Book not found: {book_id}") - return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien") - - -def main(): - app = HttpApp() - - @app.get("/hello/{name}") - def hello(name: str) -> str | BadRequest[str]: - if name.lower() == "donald": - return BadRequest("Sorry, you are not welcome here") - - return f"Hello, {name}!" - - @limit_requests() - @app.get("/books/{book_id}") - def get_book(book_id: str, page_number: int = 1) -> Book: - return Book(id=book_id, title="The Lord of the Rings", author="J.R.R. Tolkien") - - @app.get("/books/{book_id}/num_pages") - def count_pages(book_id: Annotated[Book, FromPath()]) -> int: - return 377 - - # A generator is automatically transformed into an EventStream response (SSE), - # so Generator[BookPage] = Ok(EventStream[BookPage]) - @app.get("/books/{book_id}/pages") - def get_book_pages(book_id: str) -> Generator[BookPage]: - for page_number in range(1, 378): - yield BookPage(book_id=book_id, number=page_number, content="...") - - print("Starting server on http://localhost:8000") - print("Try: curl http://localhost:8000/hello/world") - print("Try: curl http://localhost:8000/openapi.json") - print("Docs: http://localhost:8000/docs (Swagger UI)") - print("Docs: http://localhost:8000/docs/redoc (ReDoc)") - print("Docs: http://localhost:8000/docs/scalar (Scalar)") - with make_server("", 8000, app.wsgi) as server: - server.serve_forever() - - -if __name__ == "__main__": - main() diff --git a/localpost/http/README.md b/localpost/http/README.md index f4fc3a2..9f32e57 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -406,9 +406,9 @@ For perf context, see ## How is it different from… -- **Flask** — Flask is web-first (templates, Jinja, sessions) with no built-in - OpenAPI support. `localpost.http` is a low-level server; pair it with - `localpost.experimental.openapi` for type-driven OpenAPI. +- **Flask** — Flask is web-first (templates, Jinja, sessions). `localpost.http` + is a low-level server with a small `HttpApp` framework on top — handy for + JSON APIs, but no opinions on templating or serialization. - **FastAPI** — FastAPI is async, Pydantic-only, OpenAPI-only, and ships a dependency-injection system. `localpost.http` is sync, has no opinions on serialization, and is small enough to read in one sitting. @@ -416,6 +416,5 @@ For perf context, see ## See also - Examples: [`examples/http/`](../../examples/http/) -- OpenAPI on top: [`../experimental/openapi/README.md`](../experimental/openapi/README.md) - Server source: [`server.py`](server.py) - Router source: [`router.py`](router.py) diff --git a/pypi_readme.md b/pypi_readme.md index 6dde547..8dbfe91 100644 --- a/pypi_readme.md +++ b/pypi_readme.md @@ -1,9 +1,8 @@ # localpost A small async Python framework for long-running processes: service hosting, -in-process task scheduler, message broker consumers, and a lightweight HTTP / -OpenAPI stack. Built on [AnyIO](https://anyio.readthedocs.io/) — runs on -asyncio **and** Trio. +in-process task scheduler, and a lightweight HTTP server. Built on +[AnyIO](https://anyio.readthedocs.io/) — runs on asyncio **and** Trio. Python 3.12+ required. @@ -12,11 +11,7 @@ Python 3.12+ required. - **Hosting** — structured service lifecycle, signal handling, middleware. - **Scheduler** — declarative triggers (`every`, `after`, `cron`) composed with operators like `every("1m") // delay((0, 10))`. -- **Consumers** — in-memory channels, AnyIO streams, `queue.Queue`, Google - Cloud Pub/Sub (Kafka / SQS / NATS planned). - **HTTP** — small h11-based sync server; wrap any WSGI app. -- **OpenAPI** — OpenAPI 3.0 inferred from your function signatures, with - Swagger UI / ReDoc / Scalar docs built in. - **DI** — `.NET`-style scoped IoC container; optional Flask integration. ## Quick start diff --git a/pyproject.toml b/pyproject.toml index e67739a..0c4c4a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ Docs = 'https://alexeyshockov.github.io/localpost.py/' [project] name = "localpost" version = "0.6.0.dev0" -description = "Consumers framework for different message brokers & simple in-process task scheduler" +description = "Service hosting, in-process task scheduler, and a lightweight HTTP/1.1 server for long-running async Python processes" requires-python = ">=3.12" readme = "pypi_readme.md" license = "MIT" # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#pep-639-license-declaration @@ -19,8 +19,8 @@ authors = [ { name = "Alexey Shokov" }, ] keywords = [ - "scheduler", "cron", - "message brokers", "sqs", "kafka", "nats", "rabbitmq", + "hosting", "scheduler", "cron", + "http", "wsgi", "anyio", ] classifiers = [ "Development Status :: 4 - Beta", @@ -61,19 +61,6 @@ http-fast = [ # C-based HTTP/1.1 parser (llhttp); enables ``start_httptools_server``. "httptools >=0.6,<0.8", ] -http-openapi = [ - "msgspec ~=0.19", -] -sqs = [ - "botocore ~=1.38", # Sync SQS client (default) - "boto3 ~=1.38", # In case boto3 is available, the default session will be used -] -kafka = [ - "confluent-kafka ~=2.4", -] -nats = [ - "nats-py ~=2.8", -] [dependency-groups] dev = [ @@ -88,18 +75,8 @@ dev-hosting-services = [ "hypercorn ~=0.17", "grpcio ~=1.68", ] -dev-consumers = [ - "boto3 ~=1.38", - "aws-lambda-powertools ~=3.10", -# "aiobotocore ~=2.21", -# "aioboto3 >=14.1", - "confluent-kafka[schemaregistry,protobuf]", - "grpcio-tools ~=1.68", -] dev-http = [ "flask ~=3.1", -# "defspec ~=0.5", # Replaced by our own implementation - "pydantic ~=2.12", "httpx", ] dev-sentry = [ @@ -107,17 +84,10 @@ dev-sentry = [ ] dev-otel = [ "opentelemetry-exporter-otlp", # Both gRPC and HTTP - "opentelemetry-instrumentation-confluent-kafka >=0.60b1", -# "opentelemetry-instrumentation-aiokafka >=0.60b1", - "opentelemetry-instrumentation-botocore >=0.60b1", - "protobuf ~=6.29", # See https://github.com/open-telemetry/opentelemetry-python/issues/4639#issuecomment-2997003823 ] dev-types = [ "types-croniter", -# "types-confluent-kafka", # Many signatures do not match to the actual implementation - "types-protobuf ~=6.29", # Must be in sync with protobuf version, see above "types-grpcio", # Replaces grpc-stubs, see: https://github.com/python/typeshed/pull/11204 - "types-boto3-lite[sqs] ~=1.38", ] examples = [ "fastapi-slim ~=0.128", @@ -134,10 +104,6 @@ tests-unit = [ "coverage[toml] ~=7.6", "hypothesis ~=6.100", ] -tests-integration = [ - "boto3 ~=1.38", - "testcontainers[google,localstack,nats] ~=4.10", -] bench = [ # Macro (HTTP load): peer servers + ASGI app stack. "starlette ~=1.0", @@ -162,7 +128,6 @@ omit = ["tests/*"] # through reflection, etc.). The whitelist below names the 80+ confidence # false positives explicitly. paths = ["localpost"] -exclude = ["localpost/experimental/"] min_confidence = 80 sort_by_size = true ignore_names = [ @@ -175,7 +140,7 @@ ignore_names = [ ] [[tool.mypy.overrides]] -module = ["grpc.*", "pytimeparse2.*", "confluent_kafka.*"] +module = ["grpc.*", "pytimeparse2.*"] follow_untyped_imports = true [tool.ruff] diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/experimental/consumers/__init__.py b/tests/experimental/consumers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/experimental/consumers/stream.py b/tests/experimental/consumers/stream.py deleted file mode 100644 index 4e9a4c1..0000000 --- a/tests/experimental/consumers/stream.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest -from anyio import create_memory_object_stream, sleep - -from localpost.experimental.consumers.stream import stream_consumer -from localpost.hosting import serve - -pytestmark = [pytest.mark.anyio] - - -@pytest.fixture() -def local_queue(): - writer, reader = create_memory_object_stream(10) - return writer, reader - - -async def test_normal_case(local_queue): - writer, reader = local_queue - - # Arrange - sent = ["hello", "world", "!"] - for message in sent: - writer.send_nowait(message) - - # Act - received = [] - - async def handle(m: str): - received.append(m) - - consumer = stream_consumer(reader, handle) - - async with serve(consumer) as lt: - await sleep(0.5) - writer.close() - await lt.stopped - - # Assert - assert received == sent diff --git a/uv.lock b/uv.lock index 243f1dd..debe9d0 100644 --- a/uv.lock +++ b/uv.lock @@ -65,32 +65,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "authlib" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "joserfc" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, -] - -[[package]] -name = "aws-lambda-powertools" -version = "3.28.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/8f/006b11108d539ebcb0c2caa7cd85db0a83815abee54c22da907e3c14466a/aws_lambda_powertools-3.28.0.tar.gz", hash = "sha256:8849bc4fb01562aa13f1530a456626192256081ce2cfdf0cb0836ad6cba3c6b1", size = 782822, upload-time = "2026-04-14T10:34:17.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/ab/531e86d4a4306f9fedc78609259aa0ee7849d592da63168694ac5b96abb7/aws_lambda_powertools-3.28.0-py3-none-any.whl", hash = "sha256:6db5996784913b4b3a7ff04943cefaef4089440569ecbc2180ce07f08dd87659", size = 933148, upload-time = "2026-04-14T10:34:15.234Z" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -100,55 +74,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] -[[package]] -name = "boto3" -version = "1.43.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b7/65/47670987f2f9e181397872c7ee6415b7b95156d711b7eab6c55f66e575bc/boto3-1.43.0.tar.gz", hash = "sha256:80d44a943ef90aba7958ab31d30c155c198acc8a9581b5846b3878b2c8951086", size = 113143, upload-time = "2026-04-29T22:07:49.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/a0/3e6a0b1c1ea6bec76f71473727ef27abf3cd40e9709b3ebcbfbcfaae6f79/boto3-1.43.0-py3-none-any.whl", hash = "sha256:8ebe03754a4b73a5cb6ec2f14cca03ac33bd4760d0adea53da4724845130258b", size = 140497, upload-time = "2026-04-29T22:07:46.216Z" }, -] - -[[package]] -name = "botocore" -version = "1.43.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, -] - -[[package]] -name = "botocore-stubs" -version = "1.42.41" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "types-awscrt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/a8/a26608ff39e3a5866c6c79eda10133490205cbddd45074190becece3ff2a/botocore_stubs-1.42.41.tar.gz", hash = "sha256:dbeac2f744df6b814ce83ec3f3777b299a015cbea57a2efc41c33b8c38265825", size = 42411, upload-time = "2026-02-03T20:46:14.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/76/cab7af7f16c0b09347f2ebe7ffda7101132f786acb767666dce43055faab/botocore_stubs-1.42.41-py3-none-any.whl", hash = "sha256:9423110fb0e391834bd2ed44ae5f879d8cb370a444703d966d30842ce2bcb5f0", size = 66759, upload-time = "2026-02-03T20:46:13.02Z" }, -] - -[[package]] -name = "cachetools" -version = "7.0.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/7b/1755ed2c6bfabd1d98b37ae73152f8dcf94aa40fee119d163c19ed484704/cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24", size = 37526, upload-time = "2026-04-20T19:02:23.289Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/c4/cf76242a5da1410917107ff14551764aa405a5fd10cd10cf9a5ca8fa77f4/cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", size = 13976, upload-time = "2026-04-20T19:02:21.187Z" }, -] - [[package]] name = "certifi" version = "2026.4.22" @@ -167,49 +92,15 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, @@ -322,47 +213,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "confluent-kafka" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/52/2c71d8e0b2de51076f90cea05342dc9c20fa14ded11992827680db4bbdfa/confluent_kafka-2.14.0.tar.gz", hash = "sha256:34efddfd06766d1153d10a70c23a98f6035e253a906db8ed04cb0249fc3b0fd2", size = 287868, upload-time = "2026-04-02T11:28:57.862Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/05/f27091396c1e5fb98844e3e8b114ec7b896d1b54209e796e3946649de2cd/confluent_kafka-2.14.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:737b63f2389c9d63f3da0923681aa95abad1cb2f96b10f38192ef19ab727c883", size = 3650743, upload-time = "2026-04-02T11:28:07.697Z" }, - { url = "https://files.pythonhosted.org/packages/9e/49/b9de672412c4290b4719f99ac17b31ff35c64b221e4961a3047f6c1f334f/confluent_kafka-2.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1610aa31880c874bfa3351d898d6e6cdbfab2a0f9443598fd64425bbc815cb06", size = 3207894, upload-time = "2026-04-02T11:28:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/fb/b6/d892b50a48bbd95e8937d557baf89ffa07fc48bc27f792141476a004334d/confluent_kafka-2.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9cca8929bbc3d68a3299b21239c48def860f04e4661c7a59efe3104ecaea0e08", size = 3739440, upload-time = "2026-04-02T11:28:11.595Z" }, - { url = "https://files.pythonhosted.org/packages/f2/27/04d0f106820219e2621cf9e9a3ab49e910b7a19e55a72a21768b82031a85/confluent_kafka-2.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4d2e4718371c06579f649835239d1acf6ab5386a88f70e9cb9b839855c83c4a9", size = 3995763, upload-time = "2026-04-02T11:28:14.46Z" }, - { url = "https://files.pythonhosted.org/packages/64/d9/46258cefee841d65dda31d20ce61d12f7573e07ef8d26f49169edfd0b0fa/confluent_kafka-2.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:c37aff51512e817316edd6eafa8a2e59745052a7d1e61e09931b1caa11803266", size = 4112399, upload-time = "2026-04-02T11:28:16.264Z" }, - { url = "https://files.pythonhosted.org/packages/26/a3/13ca4b42c580cb8e8d4bc0711467c7c501573f0133dcaf1ed6d7e34abb42/confluent_kafka-2.14.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:a6dc0e49e8ac99854bd89ec7ac16c54af4488c7617baa633e615320dfbe44b25", size = 3212698, upload-time = "2026-04-02T11:28:18.351Z" }, - { url = "https://files.pythonhosted.org/packages/27/f6/3b4744a8d1b7714500e830a615671d27f76bf64c15966740cc6ee1c960f7/confluent_kafka-2.14.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:308c972b23f44e4d0eb3e76b987872c9a7d04148a5a4f29313bbbec3841d75b4", size = 3654148, upload-time = "2026-04-02T11:28:20.532Z" }, - { url = "https://files.pythonhosted.org/packages/48/9b/928775785983a2840c1944a689308e346badb2475765030f8e2a0db21f7a/confluent_kafka-2.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9b0acf2fffa19a6ffc2d6f0b82f3b7f1771f5d3943312438f3532ae69b6f2e83", size = 3739774, upload-time = "2026-04-02T11:28:22.283Z" }, - { url = "https://files.pythonhosted.org/packages/c7/37/c2d7a24f0c12673c763b25c2b32defe3b47b8458ad54befd842b6a3a0cde/confluent_kafka-2.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0023a941dbd8a2325e9e0d13ed1b2236c7d4ff3279b3d99cf06cf1409ab26d22", size = 3996169, upload-time = "2026-04-02T11:28:24.639Z" }, - { url = "https://files.pythonhosted.org/packages/be/fe/4c2e517a404110adbb5b560dafb5d0b3ba36c2af47d52b5508c90f65d5b0/confluent_kafka-2.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:3da898df3ebb866f61312365e9108cbadcfe74fb73af8d03add856542e715cfe", size = 4172080, upload-time = "2026-04-02T11:28:26.801Z" }, - { url = "https://files.pythonhosted.org/packages/f8/07/e217beea9a543c53484144164db337b33ec7f95912cc76f09f03fbc6ee7f/confluent_kafka-2.14.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:05bbf9745cadb1a6fd3b03508572d2cd5455d8d9960a437537ddac9d3f89ee49", size = 3212541, upload-time = "2026-04-02T11:28:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/cbb44df7afa3ac8746e0ebc37be5f457d0e91e32648c144226da26c5f682/confluent_kafka-2.14.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:32a72ff85d7b4428532aa477b8dfa4223a5c69f4e90fecaa64e1924cc99a06b6", size = 3653993, upload-time = "2026-04-02T11:28:31.042Z" }, - { url = "https://files.pythonhosted.org/packages/ae/49/49d9e62ff70a06e68c96dd65d8e621583e6b51682ccc08051ec585bfdf96/confluent_kafka-2.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:4fd75d53e0e36f7ff9c5454f7a3cf4a54790db3bfda169c3b582ddc97111f6f6", size = 3739535, upload-time = "2026-04-02T11:28:32.844Z" }, - { url = "https://files.pythonhosted.org/packages/33/6a/df467787418c24e063ed0c19e96aedf05c26eabc32d8adc75235d45d830b/confluent_kafka-2.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:eb17528ec7b177ec5e38214852f3dadb5d77172e0fb25c7c992c0cbc3dcfbaa2", size = 3995845, upload-time = "2026-04-02T11:28:34.538Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/c5ce2a48ece0ae2dd050ab28d4cd81b9efc610276a4e72f622582f5371d3/confluent_kafka-2.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:578afb532ded604cb98174a14a88847367191bcbe4f52a1661f5238dc5cf75dd", size = 4290326, upload-time = "2026-04-02T11:28:36.679Z" }, -] - -[package.optional-dependencies] -protobuf = [ - { name = "attrs" }, - { name = "authlib" }, - { name = "cachetools" }, - { name = "certifi" }, - { name = "googleapis-common-protos" }, - { name = "httpx" }, - { name = "protobuf" }, -] -schemaregistry = [ - { name = "attrs" }, - { name = "authlib" }, - { name = "cachetools" }, - { name = "certifi" }, - { name = "httpx" }, -] - [[package]] name = "coverage" version = "7.13.5" @@ -460,73 +310,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/f5/135d0e57e5bdd2f978388d77ee818f1ac5ac584eb48034362770001f4cad/croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0", size = 23220, upload-time = "2024-10-25T12:22:30.75Z" }, ] -[[package]] -name = "cryptography" -version = "47.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, -] - -[[package]] -name = "docker" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, -] - [[package]] name = "execnet" version = "2.1.2" @@ -590,91 +373,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] -[[package]] -name = "google-api-core" -version = "2.30.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, -] - -[[package]] -name = "google-auth" -version = "2.49.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, -] - -[[package]] -name = "google-cloud-core" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, -] - -[[package]] -name = "google-cloud-datastore" -version = "2.24.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/69/1389655f6949d0da0c1c5a359e64aece0a199ba177b33b7a070aa86bf672/google_cloud_datastore-2.24.0.tar.gz", hash = "sha256:f087c02a6aa4ac68bbf17f0c048ae3ee355856bf09c51439bfba193741387792", size = 266157, upload-time = "2026-03-30T22:49:52.688Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/88/357efc6b331fd29155dcb92a5dfb0030a8a6feddb7bbf8a6215defbed6c7/google_cloud_datastore-2.24.0-py3-none-any.whl", hash = "sha256:81f1d1c12c2906f59507f72742545ab04c38f62ed70b0542057e3cf04a53aa65", size = 207690, upload-time = "2026-03-30T22:47:49.48Z" }, -] - -[[package]] -name = "google-cloud-pubsub" -version = "2.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio", marker = "python_full_version < '3.14'" }, - { name = "grpcio-status" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/89/558c48382d6875335ea6cd7f6409acfbf256b9f7fbc2ad1c19976aabdb1f/google_cloud_pubsub-2.37.0.tar.gz", hash = "sha256:7c5ba9beb5236e2b83c091dd6171423dc7d6d0e989391bd09f60dbd242b29f10", size = 403391, upload-time = "2026-04-10T00:41:17.799Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/f1/bb7162ec50971b1d252e6837d05f64f185d5cfe4e08de8f706e363c305d9/google_cloud_pubsub-2.37.0-py3-none-any.whl", hash = "sha256:dd912422cf66e4ffb423b0d5391ca81bdfa408eb0f21f57adecdb6fb3b1e0bb1", size = 325136, upload-time = "2026-04-10T00:41:01.391Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.74.0" @@ -687,11 +385,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, ] -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, -] - [[package]] name = "granian" version = "2.7.4" @@ -753,20 +446,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/49/bcbaaeec0f68d3d1a3dd1fdd21e4a6963d303ae18027c42b2b53f87d6b89/granian-2.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:b9df8aead4d71562753788264db23d32db34147bb73294ddd90833bef1f4cf35", size = 3981107, upload-time = "2026-04-23T11:55:37.597Z" }, ] -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos", extra = ["grpc"] }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, -] - [[package]] name = "grpcio" version = "1.80.0" @@ -808,63 +487,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] -[[package]] -name = "grpcio-status" -version = "1.80.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, -] - -[[package]] -name = "grpcio-tools" -version = "1.80.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio" }, - { name = "protobuf" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856, upload-time = "2026-03-30T08:50:57.3Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883, upload-time = "2026-03-30T08:51:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004, upload-time = "2026-03-30T08:51:02.102Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568, upload-time = "2026-03-30T08:51:04.159Z" }, - { url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938, upload-time = "2026-03-30T08:51:06.126Z" }, - { url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814, upload-time = "2026-03-30T08:51:08.834Z" }, - { url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793, upload-time = "2026-03-30T08:51:11.094Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149, upload-time = "2026-03-30T08:51:13.066Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519, upload-time = "2026-03-30T08:51:14.87Z" }, - { url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407, upload-time = "2026-03-30T08:51:16.793Z" }, - { url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" }, - { url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" }, - { url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" }, - { url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" }, - { url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" }, - { url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" }, - { url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" }, - { url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" }, - { url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" }, - { url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" }, - { url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" }, - { url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" }, - { url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" }, -] - [[package]] name = "gunicorn" version = "25.3.0" @@ -1088,27 +710,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jmespath" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, -] - -[[package]] -name = "joserfc" -version = "1.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, -] - [[package]] name = "localpost" version = "0.6.0.dev0" @@ -1127,23 +728,10 @@ http = [ http-fast = [ { name = "httptools" }, ] -http-openapi = [ - { name = "msgspec" }, -] -kafka = [ - { name = "confluent-kafka" }, -] -nats = [ - { name = "nats-py" }, -] scheduler = [ { name = "humanize" }, { name = "pytimeparse2" }, ] -sqs = [ - { name = "boto3" }, - { name = "botocore" }, -] [package.dev-dependencies] bench = [ @@ -1161,12 +749,6 @@ dev = [ { name = "trio" }, { name = "vulture" }, ] -dev-consumers = [ - { name = "aws-lambda-powertools" }, - { name = "boto3" }, - { name = "confluent-kafka", extra = ["protobuf", "schemaregistry"] }, - { name = "grpcio-tools" }, -] dev-hosting-services = [ { name = "grpcio" }, { name = "hypercorn" }, @@ -1175,22 +757,16 @@ dev-hosting-services = [ dev-http = [ { name = "flask" }, { name = "httpx" }, - { name = "pydantic" }, ] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-instrumentation-botocore" }, - { name = "opentelemetry-instrumentation-confluent-kafka" }, - { name = "protobuf" }, ] dev-sentry = [ { name = "sentry-sdk" }, ] dev-types = [ - { name = "types-boto3-lite", extra = ["sqs"] }, { name = "types-croniter" }, { name = "types-grpcio" }, - { name = "types-protobuf" }, ] examples = [ { name = "fastapi-slim" }, @@ -1201,10 +777,6 @@ tests = [ { name = "pytest-xdist" }, { name = "setproctitle" }, ] -tests-integration = [ - { name = "boto3" }, - { name = "testcontainers", extra = ["google", "localstack", "nats"] }, -] tests-unit = [ { name = "coverage" }, { name = "hypothesis" }, @@ -1215,18 +787,13 @@ tests-unit = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = "~=4.12" }, - { name = "boto3", marker = "extra == 'sqs'", specifier = "~=1.38" }, - { name = "botocore", marker = "extra == 'sqs'", specifier = "~=1.38" }, - { name = "confluent-kafka", marker = "extra == 'kafka'", specifier = "~=2.4" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", specifier = ">=0.6,<0.8" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, - { name = "msgspec", marker = "extra == 'http-openapi'", specifier = "~=0.19" }, - { name = "nats-py", marker = "extra == 'nats'", specifier = "~=2.8" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http", "http-fast", "http-openapi", "sqs", "kafka", "nats"] +provides-extras = ["cron", "scheduler", "http", "http-fast"] [package.metadata.requires-dev] bench = [ @@ -1244,12 +811,6 @@ dev = [ { name = "trio", specifier = "~=0.32" }, { name = "vulture", specifier = "~=2.14" }, ] -dev-consumers = [ - { name = "aws-lambda-powertools", specifier = "~=3.10" }, - { name = "boto3", specifier = "~=1.38" }, - { name = "confluent-kafka", extras = ["schemaregistry", "protobuf"] }, - { name = "grpcio-tools", specifier = "~=1.68" }, -] dev-hosting-services = [ { name = "grpcio", specifier = "~=1.68" }, { name = "hypercorn", specifier = "~=0.17" }, @@ -1258,20 +819,12 @@ dev-hosting-services = [ dev-http = [ { name = "flask", specifier = "~=3.1" }, { name = "httpx" }, - { name = "pydantic", specifier = "~=2.12" }, -] -dev-otel = [ - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-instrumentation-botocore", specifier = ">=0.60b1" }, - { name = "opentelemetry-instrumentation-confluent-kafka", specifier = ">=0.60b1" }, - { name = "protobuf", specifier = "~=6.29" }, ] +dev-otel = [{ name = "opentelemetry-exporter-otlp" }] dev-sentry = [{ name = "sentry-sdk", specifier = "~=2.51" }] dev-types = [ - { name = "types-boto3-lite", extras = ["sqs"], specifier = "~=1.38" }, { name = "types-croniter" }, { name = "types-grpcio" }, - { name = "types-protobuf", specifier = "~=6.29" }, ] examples = [{ name = "fastapi-slim", specifier = "~=0.128" }] tests = [ @@ -1280,10 +833,6 @@ tests = [ { name = "pytest-xdist" }, { name = "setproctitle" }, ] -tests-integration = [ - { name = "boto3", specifier = "~=1.38" }, - { name = "testcontainers", extras = ["google", "localstack", "nats"], specifier = "~=4.10" }, -] tests-unit = [ { name = "coverage", extras = ["toml"], specifier = "~=7.6" }, { name = "hypothesis", specifier = "~=6.100" }, @@ -1363,55 +912,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] -[[package]] -name = "msgspec" -version = "0.21.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, - { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, - { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, - { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, - { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, - { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, - { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, - { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, - { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, - { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, - { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, - { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, - { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, - { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, - { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, - { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, - { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, -] - -[[package]] -name = "nats-py" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/f8/b956c4621ba88748ed707c52e69f95b7a50c8914e750edca59a5bef84a76/nats_py-2.14.0.tar.gz", hash = "sha256:4ed02cb8e3b55c68074a063aa2687087115d805d1513297da90cb2068fb07bed", size = 120751, upload-time = "2026-02-23T22:44:58.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/39/0e87753df1072254bac190b33ed34b264f28f6aa9bea0f01b7e818071756/nats_py-2.14.0-py3-none-any.whl", hash = "sha256:4116f5d2233ce16e63c3d5538fa40a5e207f75fcf42a741773929ddf1e29d19d", size = 82259, upload-time = "2026-02-23T22:45:00.152Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.41.1" @@ -1486,63 +986,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] -[[package]] -name = "opentelemetry-instrumentation" -version = "0.62b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-botocore" -version = "0.62b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-propagator-aws-xray" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/b8/47f6b6eb37f790f0de4339d6ca05b5222877c8327c1f0a967e6b443494d9/opentelemetry_instrumentation_botocore-0.62b1.tar.gz", hash = "sha256:2191ebfbb9cd6354ef1de6c0dd127c1cc847fabb7c9c25b8d000e766ce1b5671", size = 125970, upload-time = "2026-04-24T13:22:43.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/45/a2d8655d4f2b2c135943ca99fde9698685ef33f4229daf1263873f34bd0e/opentelemetry_instrumentation_botocore-0.62b1-py3-none-any.whl", hash = "sha256:7768cc2650b979649fe3475238baa7779e4194e25997d164b7eca276db559d43", size = 40517, upload-time = "2026-04-24T13:21:44.429Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-confluent-kafka" -version = "0.62b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/ed/4063731c1a44a5cce37e8ba88484857ca2aac59831bb4ba07dc49b23011f/opentelemetry_instrumentation_confluent_kafka-0.62b1.tar.gz", hash = "sha256:057f42de82470e59a1f4b406cb5b38caf9c277d5b9ef8e0fdcee1f255c22831c", size = 12107, upload-time = "2026-04-24T13:22:46.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/8a/a3d1cb30951cf9a0cf0eaf8d3e1f11bcf2f4ced5211d690f4eda016589c8/opentelemetry_instrumentation_confluent_kafka-0.62b1-py3-none-any.whl", hash = "sha256:9f3c518706797161e7ebd0bc036b906c5779d574e01c41ef942a30a4da2df732", size = 12801, upload-time = "2026-04-24T13:21:48.966Z" }, -] - -[[package]] -name = "opentelemetry-propagator-aws-xray" -version = "1.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f4/31/40004e9e55b1e5694ef3a7526f0b7637df44196fc68a8b7d248a3684680f/opentelemetry_propagator_aws_xray-1.0.2.tar.gz", hash = "sha256:6b2cee5479d2ef0172307b66ed2ed151f598a0fd29b3c01133ac87ca06326260", size = 10994, upload-time = "2024-08-05T17:45:57.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/89/849a0847871fd9745315896ad9e23d6479db84d90b8b36c4c26dc46e92b8/opentelemetry_propagator_aws_xray-1.0.2-py3-none-any.whl", hash = "sha256:1c99181ee228e99bddb638a0c911a297fa21f1c3a0af951f841e79919b5f1934", size = 10856, upload-time = "2024-08-05T17:45:56.492Z" }, -] - [[package]] name = "opentelemetry-proto" version = "1.41.1" @@ -1621,18 +1064,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, ] -[[package]] -name = "proto-plus" -version = "1.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, -] - [[package]] name = "protobuf" version = "6.33.6" @@ -1657,27 +1088,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] -[[package]] -name = "pyasn1" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1878,15 +1288,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "pytimeparse2" version = "1.7.1" @@ -1905,22 +1306,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - [[package]] name = "requests" version = "2.33.1" @@ -1936,18 +1321,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] -[[package]] -name = "s3transfer" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, -] - [[package]] name = "sentry-sdk" version = "2.58.0" @@ -2019,15 +1392,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, ] -[[package]] -name = "setuptools" -version = "82.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -2077,34 +1441,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] -[[package]] -name = "testcontainers" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docker" }, - { name = "python-dotenv" }, - { name = "typing-extensions" }, - { name = "urllib3" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, -] - -[package.optional-dependencies] -google = [ - { name = "google-cloud-datastore" }, - { name = "google-cloud-pubsub" }, -] -localstack = [ - { name = "boto3" }, -] -nats = [ - { name = "nats-py" }, -] - [[package]] name = "trio" version = "0.33.0" @@ -2122,42 +1458,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, ] -[[package]] -name = "types-awscrt" -version = "0.31.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/26/0aa563e229c269c528a3b8c709fc671ac2a5c564732fab0852ac6ee006cf/types_awscrt-0.31.3.tar.gz", hash = "sha256:09d3eaf00231e0f47e101bd9867e430873bc57040050e2a3bd8305cb4fc30865", size = 18178, upload-time = "2026-03-08T02:31:14.569Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" }, -] - -[[package]] -name = "types-boto3-lite" -version = "1.43.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore-stubs" }, - { name = "types-s3transfer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/9b/6244fe6177d64be2703e2060d74f03128f465a7b74072511cdc97a6ecc7c/types_boto3_lite-1.43.0.tar.gz", hash = "sha256:e4c6dec113e0f34fec71df5d43ca2b9c4d8e1eb1ad8f909966875f783f408f82", size = 74138, upload-time = "2026-04-29T23:08:19.956Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/cf/9bc74452d627d74900ef8e0abaae15ceb8564dca0ac9783f4896f344d30b/types_boto3_lite-1.43.0-py3-none-any.whl", hash = "sha256:491df290ed18355ab44eeaddd6314d0d3d8a86fd6cb855df36249d753c489afe", size = 42987, upload-time = "2026-04-29T23:08:13.508Z" }, -] - -[package.optional-dependencies] -sqs = [ - { name = "types-boto3-sqs" }, -] - -[[package]] -name = "types-boto3-sqs" -version = "1.43.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/be/353e9d04a06a48227af527d7e1da5c580f562b87d8b400532e8fe4eac498/types_boto3_sqs-1.43.0.tar.gz", hash = "sha256:a699170d774fb5dfab8d7881e02f102e07706cbfc44874825d4d6ddee77edf61", size = 23183, upload-time = "2026-04-29T23:07:09.111Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/c6/6e7daea77680793a1410f134869008f8199ef3c3277c1d6144460cbfd536/types_boto3_sqs-1.43.0-py3-none-any.whl", hash = "sha256:c23e43a13822208d26a946c991ebf9b4ba7194dec5b5537fd4eb850a2a5c25a7", size = 33398, upload-time = "2026-04-29T23:07:06.97Z" }, -] - [[package]] name = "types-croniter" version = "6.2.2.20260408" @@ -2176,24 +1476,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/b2/965d99a23d94e4697a76c88c17afcaf8013332583087551697a391b972bd/types_grpcio-1.0.0.20260408-py3-none-any.whl", hash = "sha256:256f2738a4a3eb2ebabd6d027f4fc7eace024822afb3b629e82811e0b661fc35", size = 15209, upload-time = "2026-04-08T04:27:50.02Z" }, ] -[[package]] -name = "types-protobuf" -version = "6.32.1.20260221" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, -] - -[[package]] -name = "types-s3transfer" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/64/42689150509eb3e6e82b33ee3d89045de1592488842ddf23c56957786d05/types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443", size = 13557, upload-time = "2025-12-08T08:13:09.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -2258,70 +1540,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] -[[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/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 = "wsproto" version = "1.3.2" From d4d2f1a1ae7effba51ca085d13b2b4e52a209156 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 1 May 2026 12:15:41 +0400 Subject: [PATCH 152/286] chore: apply just format-all fixes across the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the auto-fixes (`ruff check --fix` + `ruff format`) plus a handful of manual ones that were waiting: - examples/host/channel.py: `sys.exit(...)` instead of bare `exit(...)`. - tests/hosting/_signal_app.py: keep the test-protocol prints, mark them `# noqa: T201`. - tests/hosting/host.py: switch `Exception` → `RuntimeError` in two failing service fixtures. - tests/utils/core.py: tighten `pytest.raises(ValueError)` with `match=`, and `# type: ignore[arg-type]` for the intentional bad-type call. - tests/utils/events.py: split a compound assert into two. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/host/channel.py | 4 +++- examples/host/finite_service.py | 1 - localpost/_utils.py | 4 +--- localpost/hosting/_host.py | 1 + localpost/http/_pool.py | 4 +--- tests/hosting/_signal_app.py | 4 ++-- tests/hosting/host.py | 6 +++--- tests/hosting/hosted_service.py | 2 +- tests/hosting/multi_service.py | 3 +-- tests/hosting/signal_handling.py | 2 +- tests/threadtools/channel_props.py | 13 +++++++------ tests/utils/core.py | 6 +++--- tests/utils/events.py | 3 ++- 13 files changed, 26 insertions(+), 27 deletions(-) diff --git a/examples/host/channel.py b/examples/host/channel.py index 5805c59..456d6d5 100755 --- a/examples/host/channel.py +++ b/examples/host/channel.py @@ -1,5 +1,7 @@ #!/usr/bin/env python +import sys + import anyio from localpost.hosting import ServiceLifetime, run_app, service @@ -36,4 +38,4 @@ async def writer(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - exit(run_app(channel_example())) + sys.exit(run_app(channel_example())) diff --git a/examples/host/finite_service.py b/examples/host/finite_service.py index 26a42ef..caf8fc6 100755 --- a/examples/host/finite_service.py +++ b/examples/host/finite_service.py @@ -1,6 +1,5 @@ #!/usr/bin/env python import sys - import time from localpost.hosting import ServiceLifetime, run_app, service diff --git a/localpost/_utils.py b/localpost/_utils.py index cfab388..6af0989 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -150,9 +150,7 @@ def is_async_callable[**P](obj: Callable[P, Any], /) -> TypeGuard[Callable[P, Aw @overload -def is_async_callable[**P, R]( - obj: Callable[P, Any], ret_t: type[R], / -) -> TypeGuard[Callable[P, Awaitable[R]]]: ... +def is_async_callable[**P, R](obj: Callable[P, Any], ret_t: type[R], /) -> TypeGuard[Callable[P, Awaitable[R]]]: ... # See also: https://docs.python.org/3/library/inspect.html#inspect.markcoroutinefunction diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 8432cc3..d3b9aa4 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -318,6 +318,7 @@ def wrapper(*args, **kwargs): async def svc_f(lt: ServiceLifetime) -> None: await to_thread.run_sync(raw_svc_f, lt, limiter=limiter) + return _ResolvedService(svc_f) return wrapper diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index c3aa056..eea4037 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -342,8 +342,6 @@ def upload(ctx: HTTPReqCtx) -> None: @asynccontextmanager -async def _streaming_pool_handler( - inner: _WorkFn, max_concurrency: int, backlog: int -) -> AsyncGenerator[RequestHandler]: +async def _streaming_pool_handler(inner: _WorkFn, max_concurrency: int, backlog: int) -> AsyncGenerator[RequestHandler]: async with _pool_context(max_concurrency, backlog) as pool: yield pool.dispatch_streaming(inner) diff --git a/tests/hosting/_signal_app.py b/tests/hosting/_signal_app.py index 9ab845a..adc6078 100644 --- a/tests/hosting/_signal_app.py +++ b/tests/hosting/_signal_app.py @@ -13,10 +13,10 @@ async def long_running(lt: ServiceLifetime) -> None: - print("READY", flush=True) + print("READY", flush=True) # noqa: T201 lt.set_started() await lt.shutting_down.wait() - print("STOPPED", flush=True) + print("STOPPED", flush=True) # noqa: T201 if __name__ == "__main__": diff --git a/tests/hosting/host.py b/tests/hosting/host.py index 7579d72..9a19033 100644 --- a/tests/hosting/host.py +++ b/tests/hosting/host.py @@ -1,7 +1,7 @@ import anyio import pytest -from localpost.hosting import ServiceLifetime, ServiceLifetimeView, run, serve, Starting, Running, ShuttingDown, Stopped +from localpost.hosting import Running, ServiceLifetime, ServiceLifetimeView, ShuttingDown, Starting, Stopped, run, serve pytestmark = pytest.mark.anyio @@ -41,7 +41,7 @@ async def test_service(lt: ServiceLifetime): async def test_exit_code_on_error(): async def failing_service(lt: ServiceLifetime): lt.set_started() - raise Exception("Test error") + raise RuntimeError("Test error") async with serve(failing_service) as lt: await lt.stopped @@ -71,7 +71,7 @@ async def finite_service(lt: ServiceLifetime): async def test_run_failing(): async def failing_service(lt: ServiceLifetime): lt.set_started() - raise Exception("boom") + raise RuntimeError("boom") exit_code = await run(failing_service) assert exit_code == 1 diff --git a/tests/hosting/hosted_service.py b/tests/hosting/hosted_service.py index b3d422e..a0a043f 100644 --- a/tests/hosting/hosted_service.py +++ b/tests/hosting/hosted_service.py @@ -2,7 +2,7 @@ import pytest -from localpost.hosting import ServiceLifetime, service, serve +from localpost.hosting import ServiceLifetime, serve, service pytestmark = pytest.mark.anyio diff --git a/tests/hosting/multi_service.py b/tests/hosting/multi_service.py index d3eefaf..c2adcf7 100644 --- a/tests/hosting/multi_service.py +++ b/tests/hosting/multi_service.py @@ -79,8 +79,7 @@ async def b(lt: ServiceLifetime) -> None: assert a_crashed.is_set() # Today: b ran to natural completion despite a's crash. assert b_finished_naturally.is_set(), ( - "If this fails, _run_many learned to cancel siblings on child error — " - "flip the assertion." + "If this fails, _run_many learned to cancel siblings on child error — flip the assertion." ) # exit_code reflects the composed parent, which didn't itself raise. assert exit_code == 0 diff --git a/tests/hosting/signal_handling.py b/tests/hosting/signal_handling.py index e56bba4..deaea26 100644 --- a/tests/hosting/signal_handling.py +++ b/tests/hosting/signal_handling.py @@ -17,7 +17,7 @@ def _spawn() -> subprocess.Popen: - return subprocess.Popen( # noqa: S603 + return subprocess.Popen( [sys.executable, "-u", "-m", "tests.hosting._signal_app"], env={**os.environ}, stdout=subprocess.PIPE, diff --git a/tests/threadtools/channel_props.py b/tests/threadtools/channel_props.py index e524e6c..4223227 100644 --- a/tests/threadtools/channel_props.py +++ b/tests/threadtools/channel_props.py @@ -103,8 +103,7 @@ def clone_receiver(self, r): # --- put_nowait variants ------------------------------------------------ @precondition( - lambda self: self.model_open_receivers > 0 - and (self.capacity is None or len(self.model_buffer) < self.capacity) + lambda self: self.model_open_receivers > 0 and (self.capacity is None or len(self.model_buffer) < self.capacity) ) @rule(s=senders, item=st.integers()) def put_nowait_ok(self, s, item): @@ -114,10 +113,12 @@ def put_nowait_ok(self, s, item): self.model_sent.append(item) @precondition( - lambda self: self.capacity is not None - and self.capacity > 0 - and len(self.model_buffer) >= self.capacity - and self.model_open_receivers > 0 + lambda self: ( + self.capacity is not None + and self.capacity > 0 + and len(self.model_buffer) >= self.capacity + and self.model_open_receivers > 0 + ) ) @rule(s=senders, item=st.integers()) def put_nowait_full_blocks(self, s, item): diff --git a/tests/utils/core.py b/tests/utils/core.py index 6b9c749..82cde35 100644 --- a/tests/utils/core.py +++ b/tests/utils/core.py @@ -31,8 +31,8 @@ def test_fixed_delay(): delay = FixedDelay.create(None) assert delay() == timedelta(0) - with pytest.raises(ValueError): - FixedDelay.create("invalid") # noqa + with pytest.raises(ValueError, match="Invalid delay"): + FixedDelay.create("invalid") # type: ignore[arg-type] def test_random_delay(): @@ -77,7 +77,7 @@ def test_ensure_td(): assert ensure_td("1h") == timedelta(hours=1) # Test with invalid input - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid time period"): ensure_td("invalid") diff --git a/tests/utils/events.py b/tests/utils/events.py index b612daf..d003764 100644 --- a/tests/utils/events.py +++ b/tests/utils/events.py @@ -33,7 +33,8 @@ async def set_soon(event, delay): await wait_all([event1, event2]) tg.cancel_scope.cancel() - assert event1.is_set() and event2.is_set() + assert event1.is_set() + assert event2.is_set() assert tg.cancel_scope.cancel_called From 6a9d4ff74f27a28c433ac7d0d7d1ab7b243f6ca8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 00:05:24 +0400 Subject: [PATCH 153/286] refactor(http): unify start_http_server, select backend via ServerConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One public entry point — `start_http_server(config, handler)` — and one hosted-service wrapper — `http_server(config, handler)` — now select the HTTP parser from the new `ServerConfig.backend` field (`"h11"` default, `"httptools"` opt-in). Drops the parallel `start_httptools_server` / `httptools_server` functions and the `HttpApp.service(*, backend=...)` kwarg. Concrete connection classes are renamed `HTTPConnH11` / `HTTPConnHttptools` → `HTTPConn` in each backend module; the shared base `BaseHTTPConn` is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 +++ benchmarks/http/apps/localpost_httptools.py | 4 +- .../http/apps/localpost_httptools_diag.py | 6 +- .../http/apps/localpost_httptools_inline.py | 4 +- benchmarks/http/apps/localpost_native.py | 4 +- localpost/http/README.md | 36 ++++-- localpost/http/__init__.py | 4 +- localpost/http/_base.py | 35 +++++- localpost/http/_service.py | 116 +++++++----------- localpost/http/app.py | 18 +-- localpost/http/config.py | 6 +- localpost/http/server.py | 7 +- localpost/http/server_h11.py | 24 +--- localpost/http/server_httptools.py | 24 +--- tests/http/app.py | 44 +++---- tests/http/conftest.py | 45 +++---- 16 files changed, 185 insertions(+), 212 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dab8774..dedc701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +### Changed + +- **HTTP backend selection moved to `ServerConfig.backend`** (BREAKING). + A single entry point — `start_http_server(config, handler)` — and a + single hosted-service wrapper — `http_server(config, handler)` — + now pick between h11 and httptools based on the new + `ServerConfig.backend: Literal["h11", "httptools"] = "h11"` field + (default unchanged: pure-Python h11). Renames / removals: + - `start_httptools_server` removed → use + `start_http_server(ServerConfig(backend="httptools"), handler)`. + - `httptools_server` hosted-service wrapper removed → use + `http_server(ServerConfig(backend="httptools"), handler)`. + - `HttpApp.service(cfg, *, backend=…)` kwarg removed — set + `backend` on the `ServerConfig` instead. +- Concrete connection classes lose their backend suffix: each backend + module exposes its own `HTTPConn` (was `HTTPConnH11` / + `HTTPConnHttptools`). The Protocol `HTTPReqCtx` and the ABC + `BaseHTTPConn` are unchanged. Internal-only — no external callers + referenced the old names. + ### Added - **Per-request ``settimeout`` calls dropped from the borrow boundary.** diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index fd44a03..7ecb698 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -67,8 +67,8 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): # Decorators are no-op-returns of the original; suppress unused warnings. _ = (ping, hello, echo, profile_update) - cfg = ServerConfig(host="127.0.0.1", port=args.port) - return run_app(app.service(cfg, backend="httptools", selectors=args.selectors)) + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") + return run_app(app.service(cfg, selectors=args.selectors)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_httptools_diag.py b/benchmarks/http/apps/localpost_httptools_diag.py index 982eb25..7d21859 100644 --- a/benchmarks/http/apps/localpost_httptools_diag.py +++ b/benchmarks/http/apps/localpost_httptools_diag.py @@ -34,7 +34,7 @@ NativeResponse, Routes, ServerConfig, - httptools_server, + http_server, route_match, ) @@ -111,14 +111,14 @@ def main() -> int: routes.post("/echo")(_echo) routes.post("/users/{user_id}/profile")(_profile_update) handler = routes.build().as_handler() - cfg = ServerConfig(host="127.0.0.1", port=args.port) + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") atexit.register(_print_distribution) signal.signal(signal.SIGTERM, lambda *_: (_print_distribution(), sys.exit(0))) @service async def app(): - async with httptools_server(cfg, handler, selectors=args.selectors): + async with http_server(cfg, handler, selectors=args.selectors): yield return run_app(app()) diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index 20e949e..8f49cec 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -52,8 +52,8 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): ), body _ = (ping, hello, echo, profile_update) - cfg = ServerConfig(host="127.0.0.1", port=args.port) - return run_app(app.service(cfg, backend="httptools", selectors=args.selectors)) + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") + return run_app(app.service(cfg, selectors=args.selectors)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index 4299472..25fd52c 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -48,8 +48,8 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): ), body _ = (ping, hello, echo, profile_update) - cfg = ServerConfig(host="127.0.0.1", port=args.port) - return run_app(app.service(cfg, backend="h11", selectors=args.selectors)) + cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="h11") + return run_app(app.service(cfg, selectors=args.selectors)) if __name__ == "__main__": diff --git a/localpost/http/README.md b/localpost/http/README.md index 9f32e57..0565972 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -6,9 +6,9 @@ A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), plus a URI-template router, a WSGI bridge, and a small framework (`HttpApp`) on top. Three layers, each usable on its own: -- **Server**: `start_http_server` (h11) / `start_httptools_server` - (httptools) accept connections, parse HTTP, dispatch to a - `RequestHandler`. ~540 lines of sync code. +- **Server**: `start_http_server` accepts connections, parses HTTP + (h11 by default; httptools opt-in via `ServerConfig.backend`), + dispatches to a `RequestHandler`. ~540 lines of sync code. - **Router**: thin URI-template dispatcher. Matches the request, attaches a `RouteMatch` to `ctx.attrs[RouteMatch]`, delegates to the registered handler. 404 / 405 inline. @@ -375,17 +375,29 @@ pull-based variant collapses to two states and one syscall per ## Server backends -Two implementations live side-by-side. They share the listening socket, -selector loop, op queue, stale-conn sweep, and shutdown coordination -(everything in `_base.py`). They differ in how they drive the parser: +Two parser implementations live side-by-side. They share the listening +socket, selector loop, op queue, stale-conn sweep, and shutdown +coordination (everything in `_base.py`). They differ only in how they +drive the parser: -| Entry point | Parser | Extra | Notes | -| ---------------------------- | ----------- | ------------ | ------------------------------- | -| `start_http_server` | h11 | `[http-server]` | default; pure Python, readable | -| `start_httptools_server` | httptools | `[http-fast]` | C-based llhttp; faster header parsing | +| `ServerConfig.backend` | Parser | Extra | Notes | +| ---------------------- | ----------- | ---------------- | ------------------------------- | +| `"h11"` *(default)* | h11 | `[http-server]` | pure Python, readable | +| `"httptools"` | httptools | `[http-fast]` | C-based llhttp; faster header parsing | -Hosted-service equivalents: `http_server(...)` / `httptools_server(...)` -(both decorated with `@hosting.service`). +There is one entry point — `start_http_server(config, handler)` — and +one hosted-service wrapper — `http_server(config, handler)`. The parser +is selected via `ServerConfig.backend`: + +```python +from localpost.http import ServerConfig, start_http_server + +with start_http_server( + ServerConfig(backend="httptools"), my_handler +) as server: + while True: + server.run() +``` Pick whichever fits — handler code is identical. Both populate the same neutral `Request` / `NativeResponse` types from `localpost.http`. The diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index a7dc396..12f9412 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,6 +1,6 @@ from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._pool import streaming_pool_handler, thread_pool_handler -from localpost.http._service import http_server, httptools_server, wsgi_server +from localpost.http._service import http_server, wsgi_server from localpost.http._types import BodyTooLarge, InformationalResponse, Request from localpost.http._types import Response as NativeResponse from localpost.http.app import HttpApp @@ -27,8 +27,6 @@ "BodyHandler", "Middleware", "compose", - # backend selection - "httptools_server", # neutral wire types (used directly with HTTPReqCtx) "Request", "NativeResponse", diff --git a/localpost/http/_base.py b/localpost/http/_base.py index c2d840d..ce63433 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -38,6 +38,7 @@ "Middleware", "RequestHandler", "compose", + "start_http_server", "start_http_server_base", ] @@ -143,8 +144,8 @@ class HTTPReqCtx(Protocol): to the underlying server / connection for advanced use cases — e.g. :func:`thread_pool_handler` reaches into them to borrow the connection for a worker. They're declared as read-only properties so covariant - subtypes (concrete ``HTTPConnH11`` / ``HTTPConnHttptools``) satisfy - the Protocol. + subtypes (each backend's concrete ``HTTPConn`` from ``server_h11`` / + ``server_httptools``) satisfy the Protocol. The ``body`` attribute is empty when a :data:`RequestHandler` runs (pre-body phase). It is populated by the selector with the fully @@ -714,9 +715,9 @@ def start_http_server_base( ) -> Iterator[BaseServer]: """Open a listening socket and yield a :class:`BaseServer` driving ``conn_factory``. - Each public entry point (``start_http_server``, ``start_httptools_server``) - is a thin wrapper supplying its own conn factory. The handler is fixed - for the lifetime of the server. + Internal helper. Public callers use :func:`start_http_server`, which + selects the per-backend ``HTTPConn`` factory based on + :attr:`ServerConfig.backend`. """ logger = logging.getLogger(LOGGER_NAME) server_sock = socket.create_server( @@ -736,3 +737,27 @@ def start_http_server_base( yield server finally: server._shutdown_active_connections() + + +def start_http_server( + config: ServerConfig, handler: RequestHandler, / +) -> AbstractContextManager[BaseServer]: + """Open a listening socket and yield a server driving the configured backend. + + Backend is read from ``config.backend``. ``"h11"`` (default) is the + pure-Python parser shipped with the core install; ``"httptools"`` is + the C-based llhttp wrapper and requires the ``[http-fast]`` extra. + """ + backend = config.backend + if backend == "h11": + from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 + elif backend == "httptools": + try: + from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" + ) from e + else: + raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") + return start_http_server_base(config, handler, HTTPConn) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 53eeb1d..e0e0596 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -2,23 +2,18 @@ import dataclasses import socket -from collections.abc import Awaitable, Callable -from contextlib import AbstractContextManager +from collections.abc import Awaitable from wsgiref.types import WSGIApplication from anyio import Event, create_task_group, from_thread, to_thread from localpost import hosting from localpost.hosting import ServiceLifetime -from localpost.http._base import BaseServer from localpost.http.config import ServerConfig from localpost.http.server import RequestHandler, start_http_server from localpost.http.wsgi import wrap_wsgi -__all__ = ["http_server", "httptools_server", "wsgi_server"] - - -_StartFn = Callable[[ServerConfig, RequestHandler], AbstractContextManager[BaseServer]] +__all__ = ["http_server", "wsgi_server"] def _resolve_ephemeral_port(host: str) -> int: @@ -34,12 +29,46 @@ def _resolve_ephemeral_port(host: str) -> int: return probe.getsockname()[1] -def _serve( +@hosting.service +def http_server( config: ServerConfig, handler: RequestHandler, - start: _StartFn, - selectors: int, + /, + *, + selectors: int = 1, ): + """Run an HTTP server inside a hosted service. + + Backend (``h11`` / ``httptools``) is selected via + :attr:`ServerConfig.backend`. ``handler`` is invoked on the + **selector thread** for every accepted request — synchronous handlers + (e.g. a :class:`Router` answering 404 / 405 inline) complete without + leaving that thread. Handlers that need a worker pool should be + wrapped with :func:`localpost.http.thread_pool_handler` *before* + being passed in. + + The service has no request-cancellation machinery of its own. + Per-request cancellation lives in :func:`localpost.http.check_cancelled` + and is wired up by :func:`thread_pool_handler` (the only place that + needs it — selector-thread handlers run to completion synchronously + and have no thread to signal). + + Args: + config: Listening / timeouts / buffer-size / backend config. + handler: Sync request handler. Shared across selector threads when + ``selectors > 1`` — make sure any state it captures is + thread-safe (``thread_pool_handler`` already is). + selectors: Number of selector threads. Default 1. With ``> 1``, + each selector binds its own listening socket on the same + address via ``SO_REUSEPORT``; the kernel distributes incoming + connections. ``config.port == 0`` is resolved to an ephemeral + port once and shared by all selectors. Measured impact on + standard CPython / macOS is flat (GIL holds during parser + callbacks + dispatch + channel handoff; macOS + ``SO_REUSEPORT`` doesn't load-balance like Linux). Useful on + Linux (kernel-level distribution) and on free-threaded + builds; see ``benchmarks/http/PERF_FINDINGS.md`` Phase 7. + """ if selectors < 1: raise ValueError(f"selectors must be >= 1 (got {selectors})") @@ -47,7 +76,7 @@ def _serve( def run_single(lt: ServiceLifetime) -> Awaitable[None]: def run_server() -> None: - with start(config, handler) as server: + with start_http_server(config, handler) as server: lt.set_started() while not lt.shutting_down.is_set(): from_thread.check_cancelled() @@ -72,7 +101,7 @@ async def run_multi(lt: ServiceLifetime) -> None: started: list[Event] = [Event() for _ in range(selectors)] def run_one(idx: int) -> None: - with start(actual_config, handler) as server: + with start_http_server(actual_config, handler) as server: from_thread.run_sync(started[idx].set) while not lt.shutting_down.is_set(): from_thread.check_cancelled() @@ -91,69 +120,6 @@ async def wait_started() -> None: return run_multi -@hosting.service -def http_server( - config: ServerConfig, - handler: RequestHandler, - /, - *, - selectors: int = 1, -): - """Run an HTTP server inside a hosted service. - - ``handler`` is invoked on the **selector thread** for every accepted - request — synchronous handlers (e.g. a :class:`Router` answering 404 / - 405 inline) complete without leaving that thread. Handlers that need - a worker pool should be wrapped with - :func:`localpost.http.thread_pool_handler` *before* being passed in. - - The service has no request-cancellation machinery of its own. - Per-request cancellation lives in :func:`localpost.http.check_cancelled` - and is wired up by :func:`thread_pool_handler` (the only place that - needs it — selector-thread handlers run to completion synchronously - and have no thread to signal). - - Args: - config: Listening / timeouts / buffer-size config. - handler: Sync request handler. Shared across selector threads when - ``selectors > 1`` — make sure any state it captures is - thread-safe (``thread_pool_handler`` already is). - selectors: Number of selector threads. Default 1. With ``> 1``, - each selector binds its own listening socket on the same - address via ``SO_REUSEPORT``; the kernel distributes incoming - connections. ``config.port == 0`` is resolved to an ephemeral - port once and shared by all selectors. Measured impact on - standard CPython / macOS is flat (GIL holds during parser - callbacks + dispatch + channel handoff; macOS - ``SO_REUSEPORT`` doesn't load-balance like Linux). Useful on - Linux (kernel-level distribution) and on free-threaded - builds; see ``benchmarks/http/PERF_FINDINGS.md`` Phase 7. - """ - return _serve(config, handler, start_http_server, selectors=selectors) - - -@hosting.service -def httptools_server( - config: ServerConfig, - handler: RequestHandler, - /, - *, - selectors: int = 1, -): - """Same as :func:`http_server`, but using the httptools backend. - - Requires the ``[http-fast]`` extra. See - :func:`localpost.http.start_httptools_server` for backend differences. - See :func:`http_server` for the ``selectors`` knob. - """ - # Lazy: importing server_httptools at module top would require httptools to - # be installed for users that only need ``http_server`` (h11). The extra - # ``[http-fast]`` is opt-in. - from localpost.http.server_httptools import start_httptools_server # noqa: PLC0415 - - return _serve(config, handler, start_httptools_server, selectors=selectors) - - def wsgi_server( config: ServerConfig, app: WSGIApplication, diff --git a/localpost/http/app.py b/localpost/http/app.py index ad359d5..e9383a9 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -59,11 +59,11 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from collections.abc import Callable, Sequence from dataclasses import dataclass from http import HTTPMethod -from typing import Any, Literal, get_type_hints +from typing import Any, get_type_hints from localpost import hosting from localpost.http._pool import _Pool, _pool_context -from localpost.http._service import http_server, httptools_server +from localpost.http._service import http_server from localpost.http._types import Response as NativeResponse from localpost.http.config import ServerConfig from localpost.http.router import Routes, URITemplate, route_match @@ -166,9 +166,6 @@ def _build_response(status: int, content_type: bytes, body: bytes) -> NativeResp ) -_BACKEND_FACTORIES = {"h11": http_server, "httptools": httptools_server} - - @dataclass(slots=True) class _Route: """One registered route — the inputs needed to wire it at service time.""" @@ -358,17 +355,14 @@ def service( self, config: ServerConfig, *, - backend: Literal["h11", "httptools"] = "h11", selectors: int = 1, ): """Return a :func:`localpost.hosting.service` that runs the app. - Composes worker pool + chosen backend's server. Use with + Composes worker pool + the HTTP server (backend selected via + :attr:`ServerConfig.backend`). Use with :func:`localpost.hosting.run_app` or :func:`localpost.hosting.serve`. """ - if backend not in _BACKEND_FACTORIES: - raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") - server_fn = _BACKEND_FACTORIES[backend] max_concurrency = self.max_concurrency backlog = self.backlog @@ -376,12 +370,12 @@ def service( async def _app_service(): if max_concurrency == 0: inner = self._build_router_handler(None) - async with server_fn(config, inner, selectors=selectors): + async with http_server(config, inner, selectors=selectors): yield return async with _pool_context(max_concurrency, backlog) as pool: inner = self._build_router_handler(pool) - async with server_fn(config, inner, selectors=selectors): + async with http_server(config, inner, selectors=selectors): yield return _app_service() diff --git a/localpost/http/config.py b/localpost/http/config.py index b90092f..4ba386b 100644 --- a/localpost/http/config.py +++ b/localpost/http/config.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final, final +from typing import Final, Literal, final __all__ = [ "LOGGER_NAME", @@ -18,6 +18,10 @@ class ServerConfig: host: str = "0.0.0.0" # noqa: S104 — listen on all interfaces by default; explicit choice for a server lib port: int = 8000 + backend: Literal["h11", "httptools"] = "h11" + """HTTP parser backend. ``"h11"`` (default) is pure-Python and ships with + the core install. ``"httptools"`` is the C-based llhttp wrapper; requires + the ``[http-fast]`` extra.""" backlog: int = 1024 """Maximum number of queued (in the kernel) connections.""" rw_timeout: float = 1.0 diff --git a/localpost/http/server.py b/localpost/http/server.py index 04d4315..8f406a6 100644 --- a/localpost/http/server.py +++ b/localpost/http/server.py @@ -16,13 +16,10 @@ RequestHandler, compose, emit_handler_error, + start_http_server, ) from localpost.http._types import BodyTooLarge -from localpost.http.server_h11 import HTTPConnH11 as HTTPConn -from localpost.http.server_h11 import HTTPReqCtxH11, start_http_server - -# Historic alias — concrete type, used in some test type hints. -HTTPReqCtxConcrete = HTTPReqCtxH11 +from localpost.http.server_h11 import HTTPConn, HTTPReqCtxH11 __all__ = [ "start_http_server", diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index af6b15c..25f1ef5 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -40,12 +40,11 @@ RequestHandler, _send_all, emit_handler_error, - start_http_server_base, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response -from localpost.http.config import DEFAULT_BUFFER_SIZE, ServerConfig +from localpost.http.config import DEFAULT_BUFFER_SIZE -__all__ = ["start_http_server", "HTTPConnH11", "HTTPReqCtxH11"] +__all__ = ["HTTPConn", "HTTPReqCtxH11"] def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11.InformationalResponse: @@ -72,8 +71,7 @@ def _has_response_framing(headers) -> bool: @final @dataclass(eq=False, slots=True) -# TODO Rename to just HTTPConn -class HTTPConnH11(BaseHTTPConn): +class HTTPConn(BaseHTTPConn): server: BaseServer sock: socket.socket addr: tuple[str, int] @@ -315,7 +313,7 @@ class HTTPReqCtxH11: """ server: BaseServer - conn: HTTPConnH11 + conn: HTTPConn request: Request body: bytes = b"" @@ -463,17 +461,3 @@ def finish_response(self) -> None: def _sock_sendall(self, payload: bytes) -> None: _send_all(self.conn, payload) - - -def start_http_server(config: ServerConfig, handler: RequestHandler, /): - """Open a listening socket and yield a server driving the h11 backend. - - Default HTTP server: pure-Python parser, no C dependencies. For the - httptools backend (faster, opt-in via ``[http-fast]``), use - :func:`localpost.http.start_httptools_server`. - """ - - def _factory(server, sock, addr) -> HTTPConnH11: - return HTTPConnH11(server, sock, addr) - - return start_http_server_base(config, handler, _factory) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 5bbcd03..5a5e8d6 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -43,12 +43,11 @@ RequestHandler, _send_all, emit_handler_error, - start_http_server_base, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response -from localpost.http.config import DEFAULT_BUFFER_SIZE, ServerConfig +from localpost.http.config import DEFAULT_BUFFER_SIZE -__all__ = ["start_httptools_server", "HTTPConnHttptools", "HTTPReqCtxHttptools"] +__all__ = ["HTTPConn", "HTTPReqCtxHttptools"] # RFC 7231 §6.1 reason phrases for the codes the server-side actually emits. @@ -112,8 +111,7 @@ def _response_allows_body(request_method: bytes, status_code: int) -> bool: @final @dataclass(eq=False, slots=True) -# TODO Rename to HTTPConn -class HTTPConnHttptools(BaseHTTPConn): +class HTTPConn(BaseHTTPConn): server: BaseServer sock: socket.socket addr: tuple[str, int] @@ -474,7 +472,7 @@ class HTTPReqCtxHttptools: """ server: BaseServer - conn: HTTPConnHttptools + conn: HTTPConn request: Request _expect_100_continue: bool = False _keep_alive: bool = True @@ -665,17 +663,3 @@ def finish_response(self) -> None: elif terminator: _send_all(self.conn, terminator) self._maybe_give_back() - - -def start_httptools_server(config: ServerConfig, handler: RequestHandler, /): - """Open a listening socket and yield a server driving the httptools backend. - - Faster than the default h11 backend for header parsing. Requires the - ``[http-fast]`` extra. For the default backend see - :func:`localpost.http.start_http_server`. - """ - - def _factory(server: BaseServer, sock: socket.socket, addr: tuple[str, int]) -> HTTPConnHttptools: - return HTTPConnHttptools(server, sock, addr) - - return start_http_server_base(config, handler, _factory) diff --git a/tests/http/app.py b/tests/http/app.py index 59371ec..fb7e05f 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -13,7 +13,7 @@ import time from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Any, Literal, cast +from typing import Any, cast import anyio import httpx @@ -29,7 +29,8 @@ NativeResponse, RequestHandler, ServerConfig, - httptools_server, + http_server, + start_http_server, ) from tests.http._helpers import drain_socket @@ -43,17 +44,11 @@ async def _serve_app( app: HttpApp, cfg: ServerConfig, - *, - backend: Literal["h11", "httptools"] = "h11", ) -> AsyncGenerator[ServiceLifetimeView]: - async with serve(app.service(cfg, backend=backend)) as lt: + async with serve(app.service(cfg)) as lt: yield lt -def _backend_name(http_backend) -> Literal["h11", "httptools"]: - return cast(Literal["h11", "httptools"], http_backend.name) - - async def _wait_ready(port: int, deadline: float = 5.0) -> bool: def probe(): end = time.monotonic() + deadline @@ -485,8 +480,8 @@ async def test_explicit_backend_basic_response(self, free_port, http_backend): def hello(name: str): return f"hi {name}" - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_app(app, cfg, backend=_backend_name(http_backend)) as lt: + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend=http_backend) + async with _serve_app(app, cfg) as lt: await lt.started await _wait_ready(free_port) r = await _get(f"http://127.0.0.1:{free_port}/world") @@ -495,11 +490,10 @@ def hello(name: str): lt.shutdown() await lt.stopped - async def test_invalid_backend(self): - app = HttpApp() - cfg = ServerConfig(host="127.0.0.1", port=0) + def test_invalid_backend(self): + cfg = ServerConfig(host="127.0.0.1", port=0, backend=cast(Any, "bogus")) with pytest.raises(ValueError, match="unknown backend"): - app.service(cfg, backend=cast(Any, "bogus")) + start_http_server(cfg, lambda ctx: None) class TestStreamingRoutes: @@ -523,8 +517,8 @@ def upload(ctx: HTTPReqCtx): captured["thread"] = threading.get_ident() return f"got {len(full)} bytes" - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_app(app, cfg, backend=_backend_name(http_backend)) as lt: + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend=http_backend) + async with _serve_app(app, cfg) as lt: await lt.started await _wait_ready(free_port) payload = b"a" * 1024 @@ -547,8 +541,8 @@ def upload(ctx: HTTPReqCtx): pass return "ok" - cfg = ServerConfig(host="127.0.0.1", port=free_port, max_body_size=8) - async with _serve_app(app, cfg, backend=_backend_name(http_backend)) as lt: + cfg = ServerConfig(host="127.0.0.1", port=free_port, max_body_size=8, backend=http_backend) + async with _serve_app(app, cfg) as lt: await lt.started await _wait_ready(free_port) @@ -586,8 +580,8 @@ def upload(ctx: HTTPReqCtx): captured["body"] = b"".join(chunks) return f"got {len(captured['body'])} bytes" - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(app.service(cfg, backend="httptools")) as lt: + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(app.service(cfg)) as lt: await lt.started await _wait_ready(free_port) @@ -639,8 +633,8 @@ def dispatch(req_ctx: HTTPReqCtx) -> None: cast(Any, ctx)._defer_streaming_dispatch(dispatch) - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(httptools_server(cfg, handler)) as lt: + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(http_server(cfg, handler)) as lt: await lt.started await _wait_ready(free_port) @@ -692,8 +686,8 @@ def ping(): captured.append("ping") return "pong" - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(app.service(cfg, backend="httptools")) as lt: + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(app.service(cfg)) as lt: await lt.started await _wait_ready(free_port) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 0a52d5b..1697121 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,18 +1,15 @@ from __future__ import annotations +import dataclasses import socket import threading from collections.abc import Callable, Iterator -from contextlib import AbstractContextManager -from dataclasses import dataclass -from typing import Protocol +from typing import Literal, Protocol import pytest from localpost.http import RequestHandler, ServerConfig, start_http_server -from localpost.http._base import BaseServer -StartServer = Callable[[ServerConfig, RequestHandler], AbstractContextManager[BaseServer]] ServeInThread = Callable[[RequestHandler], "_ServerCM"] @@ -20,24 +17,18 @@ class ServeBackendInThread(Protocol): def __call__(self, handler: RequestHandler, config: ServerConfig | None = None) -> _ServerCM: ... -@dataclass(frozen=True, slots=True) -class HTTPBackend: - name: str - start_server: StartServer +Backend = Literal["h11", "httptools"] @pytest.fixture(params=("h11", "httptools")) -def http_backend(request) -> HTTPBackend: - name = request.param - if name == "h11": - return HTTPBackend(name="h11", start_server=start_http_server) - - try: - from localpost.http.server_httptools import start_httptools_server # noqa: PLC0415 - except ImportError as e: - pytest.skip(str(e)) - - return HTTPBackend(name="httptools", start_server=start_httptools_server) +def http_backend(request) -> Backend: + name: Backend = request.param + if name == "httptools": + try: + import httptools # noqa: F401, PLC0415 + except ImportError as e: + pytest.skip(str(e)) + return name @pytest.fixture @@ -67,12 +58,12 @@ class _ServerCM: sets ``expect_loop_error = True`` (used to characterize crash paths). """ - def __init__(self, config: ServerConfig, handler: RequestHandler, start_server: StartServer) -> None: + def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: self._config = config self._handler = handler self._stop = threading.Event() self._thread: threading.Thread | None = None - self._cm = start_server(config, handler) + self._cm = start_http_server(config, handler) self.loop_error: BaseException | None = None self.expect_loop_error: bool = False @@ -132,7 +123,7 @@ def handler(ctx): ... active: list[_ServerCM] = [] def make(handler: RequestHandler) -> _ServerCM: - cm = _ServerCM(server_config, handler, start_http_server) + cm = _ServerCM(server_config, handler) active.append(cm) return cm @@ -147,11 +138,15 @@ def make(handler: RequestHandler) -> _ServerCM: @pytest.fixture -def serve_backend_in_thread(server_config: ServerConfig, http_backend: HTTPBackend) -> Iterator[ServeBackendInThread]: +def serve_backend_in_thread(server_config: ServerConfig, http_backend: Backend) -> Iterator[ServeBackendInThread]: active: list[_ServerCM] = [] + backend_config = dataclasses.replace(server_config, backend=http_backend) def make(handler: RequestHandler, config: ServerConfig | None = None) -> _ServerCM: - cm = _ServerCM(config or server_config, handler, http_backend.start_server) + cm = _ServerCM( + dataclasses.replace(config, backend=http_backend) if config is not None else backend_config, + handler, + ) active.append(cm) return cm From 8748c66a052f4b35fb97496f4e38e527e09b8a90 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 00:41:25 +0400 Subject: [PATCH 154/286] refactor(http): split Selector from BaseServer; introduce ConnHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple the dispatch chain into Selector → ConnHandler → RequestHandler → BodyHandler. Each link knows only the next; in particular Selector no longer carries RequestHandler. The handler is owned by ConnHandler and threaded into the conn at construction. Selector becomes a dumb fd→callback dispatcher (op queue + wakeup pipe + stale-conn sweep). The run loop is now uniform — every fd in the map holds a SelectorCallback, no isinstance/sentinel branches. All callbacks are spelled as callable dataclasses (not closures) so their state is repr-able for debugging: _DrainWakeup, _AcceptListener, TrackHere, RoundRobinAcceptor; BaseHTTPConn itself satisfies SelectorCallback. BaseServer becomes a thin composition (Selector + listen socket + ConnHandler). Default ConnHandler is TrackHere — same behaviour as before. http_server/wsgi_server gain an acceptor=False knob; with acceptor=True, a single acceptor thread accepts and round-robins conns to N worker Selectors via the cross-thread op queue, covering the macOS / free-threaded case where SO_REUSEPORT doesn't distribute evenly. BaseHTTPConn.server → BaseHTTPConn.selector throughout. HTTPReqCtx Protocol's server property renamed to selector. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/README.md | 58 ++- localpost/http/__init__.py | 15 + localpost/http/_base.py | 638 +++++++++++++++++++++-------- localpost/http/_pool.py | 2 +- localpost/http/_service.py | 155 ++++++- localpost/http/server_h11.py | 48 +-- localpost/http/server_httptools.py | 46 +-- localpost/http/wsgi.py | 4 +- tests/http/app.py | 2 +- tests/http/server.py | 4 +- tests/http/service.py | 107 ++++- 11 files changed, 821 insertions(+), 258 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index 0565972..3809cc3 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -150,6 +150,38 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian / etc.). Build via `routes.build()` or `Router.from_routes(routes)`. +### Dispatch chain + +The internals are a four-link, loose-coupled chain: + +``` +Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ +ConnHandler ── after-accept policy; owns RequestHandler + ConnFactory; + │ decides which Selector tracks the new conn + ▼ +RequestHandler ── pre-body dispatch; returns BodyHandler|None + │ + ▼ +BodyHandler ── post-body continuation +``` + +Each link knows only the next. `Selector` doesn't carry a `RequestHandler` — +the handler is owned by the `ConnHandler` and threaded into the conn at +construction. This factoring is what makes the acceptor topology (1 acceptor +thread + N worker selectors) drop in without touching the request hot path. + +- **`Selector`** — dumb fd→callback dispatcher. Built-in callbacks: + `_DrainWakeup` (wakeup pipe), `_AcceptListener` (listen socket), and + `BaseHTTPConn` itself (a conn *is* its own per-fd callback). +- **`ConnHandler = Callable[[Selector, socket.socket, tuple[str, int]], None]`** — + after-accept policy. Two built-ins: `TrackHere` (default — track on the + selector that accepted) and `RoundRobinAcceptor` (acceptor topology — + spread conns across worker selectors via `Selector.post_track`). +- All callbacks are spelled as **callable dataclasses** (not closures), so + their state is `repr`-able for debugging. + ## Public API ### `localpost.http.server` @@ -160,6 +192,17 @@ sys.exit(run_app(http_server(ServerConfig(), simple_app))) | `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`) | | `RequestHandler` | `Callable[[HTTPReqCtx], None]` | +### Selector / accept-side topology + +| Symbol | Notes | +| ------------------------- | ------------------------------------------ | +| `Selector` | Dumb fd→callback dispatcher (op queue + wakeup pipe + stale-conn sweep). One per selector thread. | +| `SelectorCallback` | `Callable[[Selector], None]` — registered per-fd, invoked when readable | +| `ConnHandler` | `Callable[[Selector, sock, addr], None]` — after-accept policy | +| `ConnFactory` | `Callable[[Selector, sock, addr, RequestHandler], BaseHTTPConn]` | +| `TrackHere` | Default `ConnHandler`: build conn for accepting selector and `track()` it. Reproduces the all-in-one behaviour. | +| `RoundRobinAcceptor` | `ConnHandler` for the acceptor topology — spreads new conns across a tuple of worker `Selector`s via `post_track` (cross-thread op queue). | + ### Cancellation | Symbol | Notes | @@ -255,11 +298,22 @@ on the documented public Flask API. | Symbol | Module | Notes | | ------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | -| `http_server(config, handler)` | `localpost.http._service` | `@hosting.service` — runs the server loop with `handler`. No thread pool. | -| `wsgi_server(config, app)` | `localpost.http._service` | Same, for a generic WSGI app. | +| `http_server(config, handler, *, selectors=1, acceptor=False)` | `localpost.http._service` | `@hosting.service` — runs the server loop with `handler`. See **Threading topologies** below. | +| `wsgi_server(config, app, *, selectors=1, acceptor=False)` | `localpost.http._service` | Same, for a generic WSGI app. | | `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask`. | | `thread_pool_handler(inner, *, max_concurrency, backlog=0)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a worker thread. Admission cap = `max_concurrency + backlog`; default `backlog=0` means exactly `max_concurrency` in flight. | +#### Threading topologies + +`http_server` supports three accept-side shapes, all sharing the same +`RequestHandler` / `BodyHandler` chain: + +| Configuration | Threads | When to use | +| ------------------------------------- | --------------------------------------------- | ----------- | +| Default (`selectors=1`) | 1 thread accepts, parses, and dispatches | Simple deployments; thread-pool wrapper handles concurrency on the request side | +| `selectors=N` | N independent selectors, each with its own listening socket via `SO_REUSEPORT` | Linux kernel-level connection load-balancing; free-threaded builds | +| `selectors=N, acceptor=True` | 1 acceptor thread + N worker selector threads | macOS / free-threaded targets where `SO_REUSEPORT` doesn't distribute evenly. Acceptor accepts each conn and round-robins it to a worker via the cross-thread op queue. | + The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. The server hosts a single handler; whether that handler runs synchronously on the diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 12f9412..fdbb776 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,3 +1,11 @@ +from localpost.http._base import ( + ConnFactory, + ConnHandler, + RoundRobinAcceptor, + Selector, + SelectorCallback, + TrackHere, +) from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, wsgi_server @@ -27,6 +35,13 @@ "BodyHandler", "Middleware", "compose", + # selector / accept-side topology + "Selector", + "SelectorCallback", + "ConnHandler", + "ConnFactory", + "TrackHere", + "RoundRobinAcceptor", # neutral wire types (used directly with HTTPReqCtx) "Request", "NativeResponse", diff --git a/localpost/http/_base.py b/localpost/http/_base.py index ce63433..cb7b9da 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -5,6 +5,19 @@ stale-connection sweep, and shutdown. Each concrete backend (h11, httptools) provides a ``BaseHTTPConn`` subclass driven by its parser's natural idioms. +The dispatch chain is loose-coupled: + + Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ + ConnHandler ── after-accept policy; owns RequestHandler + conn_factory; + │ decides which Selector tracks the new conn + ▼ + RequestHandler ── pre-body dispatch; returns BodyHandler|None + │ + ▼ + BodyHandler ── post-body continuation + ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. """ @@ -20,7 +33,7 @@ import time from abc import ABC, abstractmethod from collections.abc import Callable, Iterator -from contextlib import AbstractContextManager, closing, contextmanager +from contextlib import AbstractContextManager, closing, contextmanager, suppress from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, final @@ -31,21 +44,27 @@ from collections.abc import Buffer __all__ = [ - "BaseServer", "BaseHTTPConn", + "BaseServer", "BodyHandler", + "ConnFactory", + "ConnHandler", "HTTPReqCtx", "Middleware", "RequestHandler", + "RoundRobinAcceptor", + "Selector", + "SelectorCallback", + "TrackHere", "compose", "start_http_server", "start_http_server_base", ] -# Selector data-tag for ``BaseServer._wakeup_r`` — distinguishable in the for-event -# loop so the selector thread knows to drain the wakeup pipe + op queue. -_WAKEUP_SENTINEL: object = object() +# -------------------------------------------------------------------------- +# Op queue ops (cross-thread → selector-thread mutations) +# -------------------------------------------------------------------------- @final @@ -72,12 +91,15 @@ class _OpClose: _Op = _OpTrack | _OpClose -# Canned protocol-error responses, expressed as neutral types. Each backend -# serialises them with its own writer (h11.Connection.send for the h11 impl, -# the hand-written serialiser for the httptools impl). The httptools backend -# also has access to a fully pre-serialised wire form per canned response — -# see ``_PRESERIALIZED`` below — so error paths skip ``_serialize_response`` -# entirely. +# -------------------------------------------------------------------------- +# Canned protocol-error responses +# -------------------------------------------------------------------------- + +# Each backend serialises them with its own writer (h11.Connection.send for the +# h11 impl, the hand-written serialiser for the httptools impl). The httptools +# backend also has access to a fully pre-serialised wire form per canned +# response — see ``_PRESERIALIZED`` below — so error paths skip +# ``_serialize_response`` entirely. # Reason phrases used by the pre-serialised wire form below. Kept here (not # in the httptools backend) so it stays parser-agnostic and the constants @@ -136,12 +158,17 @@ def _build_canned(status_code: int, body: bytes) -> tuple[Response, bytes]: SERVICE_UNAVAILABLE_RESPONSE, SERVICE_UNAVAILABLE_WIRE = _build_canned(503, SERVICE_UNAVAILABLE_BODY) +# -------------------------------------------------------------------------- +# HTTPReqCtx Protocol + handler types +# -------------------------------------------------------------------------- + + class HTTPReqCtx(Protocol): """Per-request context handed to a :data:`RequestHandler`. Both server backends populate concrete implementations of this Protocol - with the same observable surface. ``server`` and ``conn`` give access - to the underlying server / connection for advanced use cases — e.g. + with the same observable surface. ``selector`` and ``conn`` give access + to the underlying selector / connection for advanced use cases — e.g. :func:`thread_pool_handler` reaches into them to borrow the connection for a worker. They're declared as read-only properties so covariant subtypes (each backend's concrete ``HTTPConn`` from ``server_h11`` / @@ -163,7 +190,7 @@ class HTTPReqCtx(Protocol): attrs: dict[Any, Any] @property - def server(self) -> BaseServer: ... + def selector(self) -> Selector: ... @property def conn(self) -> BaseHTTPConn: ... @property @@ -235,6 +262,11 @@ def wrap(handler: RequestHandler) -> RequestHandler: return wrap +# -------------------------------------------------------------------------- +# I/O helpers +# -------------------------------------------------------------------------- + + def _send_all(conn: BaseHTTPConn, payload: bytes | bytearray | memoryview) -> None: """Send all of ``payload`` to ``conn.sock``. @@ -261,7 +293,7 @@ def _send_all(conn: BaseHTTPConn, payload: bytes | bytearray | memoryview) -> No # calls in the same request skip the BlockingIOError dance; # the give-back path resets it. On the selector thread we # must restore non-blocking before returning. - sock.settimeout(conn.server.config.rw_timeout) + sock.settimeout(conn.selector.config.rw_timeout) try: sock.sendall(view[sent:]) finally: @@ -292,19 +324,30 @@ def emit_handler_error(ctx: HTTPReqCtx) -> None: ctx.conn.close() +# -------------------------------------------------------------------------- +# BaseHTTPConn — abstract per-connection surface +# -------------------------------------------------------------------------- + + class BaseHTTPConn(ABC): - """Abstract per-connection surface used by :class:`BaseServer`. + """Abstract per-connection surface used by :class:`Selector`. - Subclasses own the parser instance and per-connection state. The base - server only observes the small surface declared here: tracking flag, + Subclasses own the parser instance and per-connection state. The + selector only observes the small surface declared here: tracking flag, socket, fd, idle / close-at timestamps, ``__call__`` entry point, ``close()``, and the stale-408 emission hook. + + The ``__call__`` signature matches :data:`SelectorCallback` — a conn + *is* the per-fd callback for its own socket. The :data:`RequestHandler` + is captured as the ``handler`` field at construction time (by the + :data:`ConnFactory`). """ - server: BaseServer + selector: Selector sock: socket.socket addr: tuple[str, int] fd: int + handler: RequestHandler tracked: bool """``True`` iff this conn is registered in the selector. ``False`` while a worker has borrowed it (between ``stop_tracking`` and the next ``track``).""" @@ -317,10 +360,13 @@ class BaseHTTPConn(ABC): from a stalled mid-request (emit 408 Request Timeout).""" @abstractmethod - def __call__(self, h: RequestHandler, /) -> None: + def __call__(self, sel: Selector, /) -> None: """Drive the connection: read, parse, dispatch, write. Returns when the worker should be released (handler took the conn / connection closed / - more data needed from selector).""" + more data needed from selector). + + Invoked by :meth:`Selector.run` whenever this conn's fd is readable. + """ def close(self) -> None: """Tear down the connection. @@ -342,20 +388,21 @@ def close(self) -> None: pass if not was_tracked: return - if threading.get_ident() == self.server._selector_thread_id: + sel = self.selector + if threading.get_ident() == sel._selector_thread_id: try: - self.server.selector.unregister(self.fd) + sel._sel.unregister(self.fd) except (KeyError, ValueError): pass else: - self.server._ops.append(_OpClose(self.fd)) - self.server._wake() + sel._ops.append(_OpClose(self.fd)) + sel._wake() @abstractmethod def emit_stale_408(self) -> None: """Best-effort 408 emission for a stalled mid-request connection. - Called by :meth:`BaseServer._cleanup_stale` for conns that received + Called by :meth:`Selector._cleanup_stale` for conns that received bytes but never produced a complete request. The h11 impl sends via the parser; the httptools impl writes raw bytes. Implementations no-op when no response is appropriate (idle keep-alive, response @@ -364,48 +411,195 @@ def emit_stale_408(self) -> None: """ -_ConnFactory = Callable[["BaseServer", socket.socket, tuple[str, int]], BaseHTTPConn] +# -------------------------------------------------------------------------- +# Type aliases for the dispatch chain +# -------------------------------------------------------------------------- + + +SelectorCallback = Callable[["Selector"], None] +"""Per-fd callback invoked by :meth:`Selector.run` when an fd it owns is +readable. The selector is passed in so the callback can register or +unregister fds on it. + +Implementations are dataclasses with ``__call__`` (not closures), so their +state is repr-able for debugging. :class:`BaseHTTPConn` itself satisfies +this signature — a conn *is* its own per-fd callback. Other built-in +implementations: :class:`_DrainWakeup` (wakeup pipe), :class:`_AcceptListener` +(listen socket). +""" + +ConnFactory = Callable[ + ["Selector", socket.socket, tuple[str, int], RequestHandler], + BaseHTTPConn, +] +"""Builds a per-connection :class:`BaseHTTPConn`. Backend-specific — +:class:`localpost.http.server_h11.HTTPConn` and +:class:`localpost.http.server_httptools.HTTPConn` both satisfy this shape. +""" + +ConnHandler = Callable[["Selector", socket.socket, tuple[str, int]], None] +"""After-accept policy. Receives the just-accepted raw client socket and +addr (selector that did the accept is passed for reference). + +Default behaviour (:class:`TrackHere`): build a conn for the receiving +selector and track it locally. + +Acceptor mode (:class:`RoundRobinAcceptor`): build a conn bound to a +worker selector and ``post_track`` it across threads. +""" + + +# -------------------------------------------------------------------------- +# Built-in selector callbacks (callable dataclasses, not closures) +# -------------------------------------------------------------------------- @final -class BaseServer: - """Parser-agnostic server: owns the listening socket, selector, and op queue. +@dataclass(eq=False, slots=True, frozen=True) +class _DrainWakeup: + """Selector callback for the wakeup pipe fd. Drains the byte(s) and + pulls any pending ops onto the selector thread. + """ + + def __call__(self, sel: Selector, /) -> None: + sel._drain_wakeup() + sel._drain_ops() - A :class:`BaseHTTPConn` factory is injected at construction time — each - accepted connection is built by ``conn_factory(server, sock, addr)``. - All parser-specific behaviour lives behind that interface. + +@final +@dataclass(eq=False, slots=True, frozen=True) +class _AcceptListener: + """Selector callback for a listen socket. Accepts one connection and + delegates to the configured :data:`ConnHandler`. + + We do **not** loop over ``accept`` here — the listening socket stays + edge-readable until drained, but the selector polls level-triggered + by default; a single accept per readable event keeps fairness with + other fds and matches the existing behaviour. """ - def __init__( - self, - config: ServerConfig, - handler: RequestHandler, - logger: logging.Logger, - server_sock: socket.socket, - selector: selectors.BaseSelector, - conn_factory: _ConnFactory, - ) -> None: - self.sock = server_sock - self.port: int = server_sock.getsockname()[1] - """ - Actual port the server is listening on. + listen_sock: socket.socket + conn_handler: ConnHandler + logger: logging.Logger + + def __call__(self, sel: Selector, /) -> None: + client_sock, addr = self.listen_sock.accept() + # Linux 2.6.28+ inherits ``O_NONBLOCK`` from the listening socket; + # macOS / BSD do not. Set explicitly. While the conn is tracked + # the socket is non-blocking; the send/recv paths may flip it to + # blocking-with-timeout on a borrowed conn (``_send_all`` and the + # per-backend receive helpers) and the give-back path resets it + # before re-tracking. + client_sock.setblocking(False) + try: + self.conn_handler(sel, client_sock, addr) + except Exception: + self.logger.exception("ConnHandler raised; closing %s", addr) + with suppress(Exception): + client_sock.close() - Can be useful when port 0 is specified to auto-assign a free port. - """ - self.selector = selector + +# -------------------------------------------------------------------------- +# Built-in ConnHandler implementations +# -------------------------------------------------------------------------- + + +@final +@dataclass(eq=False, slots=True, frozen=True) +class TrackHere: + """Default :data:`ConnHandler`. Builds a conn for the accepting selector + and tracks it locally. This is the behaviour the server has always had — + a single thread accepts, parses, and dispatches on the same selector. + """ + + handler: RequestHandler + conn_factory: ConnFactory + + def __call__(self, sel: Selector, sock: socket.socket, addr: tuple[str, int]) -> None: + conn = self.conn_factory(sel, sock, addr, self.handler) + sel.track(conn) + + +@final +@dataclass(eq=False, slots=True) +class RoundRobinAcceptor: + """:data:`ConnHandler` for the acceptor topology. Spreads new conns + across a tuple of worker :class:`Selector` instances using a simple + monotonic counter. + + The worker selectors must be running their own ``run()`` loop on a + separate thread; this handler enqueues ``_OpTrack`` via + :meth:`Selector.post_track`, which is cross-thread safe (op queue + + wakeup pipe). + """ + + workers: tuple[Selector, ...] + handler: RequestHandler + conn_factory: ConnFactory + _next: int = 0 + + def __call__(self, _sel: Selector, sock: socket.socket, addr: tuple[str, int]) -> None: + if not self.workers: + with suppress(Exception): + sock.close() + return + target = self.workers[self._next % len(self.workers)] + self._next += 1 + conn = self.conn_factory(target, sock, addr, self.handler) + target.post_track(conn) + + +# -------------------------------------------------------------------------- +# Selector primitive +# -------------------------------------------------------------------------- + + +@final +class Selector: + """Dumb fd→callback dispatcher with op queue + wakeup pipe. + + A :class:`Selector` is HTTP-agnostic. It owns: + + - a :class:`selectors.BaseSelector` (fd → :data:`SelectorCallback` map) + - a self-pipe wakeup so worker threads can ask the selector thread to + apply ``_OpTrack`` / ``_OpClose`` ops + - the stale-connection sweep over registered :class:`BaseHTTPConn`s + - a ``shutting_down`` flag + + The selector thread is the **single writer** to the underlying + ``selectors.BaseSelector``. ``register`` / ``unregister`` must be + called from the selector thread; the cross-thread mutation paths + (``track`` from a worker, ``close`` from any thread) enqueue ops and + wake the selector. + + Use cases: + + - **All-in-one** (default): a :class:`BaseServer` owns a Selector, + registers its listen socket on it, and runs the loop on one + thread. Same behaviour as before this refactor. + - **Worker-only**: a Selector with no listen socket. The acceptor + topology spawns N of these, each on its own thread; conns are + delivered via :meth:`post_track` from a separate acceptor thread. + """ + + def __init__(self, config: ServerConfig, *, port: int, logger: logging.Logger) -> None: self.config = config - self.handler = handler + self.port: int = port + """Bound port the *server* is listening on. Threaded through to + worker selectors so :data:`HTTPReqCtx`-consumers (e.g. the WSGI + bridge) can populate ``SERVER_PORT`` without reaching for the + listen socket.""" self.logger = logger - self._conn_factory = conn_factory + self._sel: selectors.BaseSelector = selectors.DefaultSelector() self.shutting_down: bool = False """Set to True on context-manager exit. Once set, ``track`` rejects new registrations and ``_maybe_give_back`` closes connections instead of returning them to the selector.""" # Lock-free op queue + self-pipe wakeup. The selector thread is the - # single writer to ``self.selector``; cross-thread mutations from + # single writer to ``self._sel``; cross-thread mutations from # workers (``track`` from ``_maybe_give_back``, ``close`` from error # paths) enqueue ops and write a wakeup byte. The selector drains at - # the top of every iteration and on wakeup-sentinel events. + # the top of every iteration and on wakeup-callback events. self._ops: collections.deque[_Op] = collections.deque() r, w = os.pipe() os.set_blocking(r, False) @@ -415,13 +609,13 @@ def __init__( fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) self._wakeup_r: int = r self._wakeup_w: int = w - selector.register(r, selectors.EVENT_READ, data=_WAKEUP_SENTINEL) + self._sel.register(r, selectors.EVENT_READ, data=_DrainWakeup()) self._selector_thread_id: int | None = None """Cached on the first ``run()`` call. Used to route ``track`` / ``close`` calls inline when invoked from the selector thread itself - (e.g. from the accept branch of ``run``).""" + (e.g. from the accept callback).""" # Stale-connection cleanup runs lazily — at most once per - # ``_cleanup_interval``. Avoids walking ``selector.get_map()`` on + # ``_cleanup_interval``. Avoids walking the selector map on # every iteration when nothing's actually stale (which is the # common case under load: keep_alive_timeout=15s, rw_timeout=1s). # Floor at 100 ms so users with degenerate tiny timeouts don't @@ -432,59 +626,26 @@ def __init__( ) self._last_cleanup_at: float = 0.0 - def _find_stale(self): - now = time.monotonic() - for key in self.selector.get_map().values(): - if (conn := key.data) and isinstance(conn, BaseHTTPConn) and conn.close_at and now > conn.close_at: - yield conn + # ----- Public registration API (selector-thread only) ----- - def _cleanup_stale(self): - # Selector-thread only. Lock-free: ``self.selector`` and - # ``conn.tracked`` are owned by the selector thread. - # - # We deliberately keep this on the selector thread rather than a - # dedicated cleanup thread. With the lazy gate below the common - # case is an O(1) timestamp compare (no walk); the actual O(N) - # sweep runs at most every ``_cleanup_interval``, ~twice a second - # by default. A separate thread would either need a lock around - # ``selector.get_map()`` (losing the lock-free property of the - # current op-queue model) or maintain a parallel data structure - # — both add machinery without buying anything visible. - # - # Lazy: skip the O(N) walk over registered conns when not enough - # time has passed since the last sweep. Worst-case extra detection - # latency is ``_cleanup_interval`` (default 0.5 s) on top of the - # configured ``rw_timeout`` / ``keep_alive_timeout`` — both already - # measure non-fatal conditions. - now = time.monotonic() - if now - self._last_cleanup_at < self._cleanup_interval: - return - self._last_cleanup_at = now - stale = list(self._find_stale()) - for conn in stale: - try: - self.selector.unregister(conn.sock) - except (KeyError, ValueError): - pass - conn.tracked = False - for conn in stale: - # Stalled mid-request gets a 408; idle keep-alive gets silently - # dropped. The decision and the bytes-on-wire are the conn's - # job — backends differ. - try: - conn.emit_stale_408() - except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway - pass - try: - conn.sock.close() - except OSError: - pass + def register(self, fd: int, callback: SelectorCallback, /) -> None: + """Register ``fd`` with ``callback``. Selector-thread only.""" + self._sel.register(fd, selectors.EVENT_READ, data=callback) + + def unregister(self, fd: int, /) -> None: + """Unregister ``fd``. Selector-thread only. Swallows missing-fd errors.""" + try: + self._sel.unregister(fd) + except (KeyError, ValueError): + pass + + # ----- Conn tracking ----- def track(self, conn: BaseHTTPConn) -> None: """Register ``conn`` in the selector for normal HTTP processing. Safe from any thread. When called from a worker, the actual - ``selector.register`` happens on the selector thread (drained from + ``selectors.register`` happens on the selector thread (drained from ``self._ops`` at the top of the next iteration). ``conn.tracked`` is flipped optimistically so concurrent readers see the intended state. @@ -524,8 +685,8 @@ def track(self, conn: BaseHTTPConn) -> None: def stop_tracking(self, conn: BaseHTTPConn) -> None: """Unregister ``conn`` from the selector; the worker becomes the sole I/O owner. - Selector-thread only (called from the dispatcher inside ``BaseServer.run``'s - for-event loop). Socket stays non-blocking — the worker's send + Selector-thread only (called from the dispatcher inside the conn's + ``__call__``). Socket stays non-blocking — the worker's send path (:func:`_send_all`) handles partial writes and falls back to blocking-with-timeout only when the kernel buffer fills. Client-disconnect detection while the conn is borrowed lives in @@ -538,11 +699,34 @@ def stop_tracking(self, conn: BaseHTTPConn) -> None: docstring on each backend for the full invariant. """ try: - self.selector.unregister(conn.sock) + self._sel.unregister(conn.sock) except (KeyError, ValueError): pass conn.tracked = False + def post_track(self, conn: BaseHTTPConn) -> None: + """Cross-thread :meth:`track`. Always enqueues ``_OpTrack`` and wakes + the selector — never applies inline. Used by the acceptor + topology to deliver fresh conns from the acceptor thread to a + worker selector. + """ + if self.shutting_down: + with suppress(OSError): + conn.sock.close() + conn.tracked = False + return + conn.tracked = True + self._ops.append(_OpTrack(conn)) + self._wake() + + def post_close(self, fd: int) -> None: + """Cross-thread close-cleanup. Enqueues a ``_OpClose`` so the + selector thread cleans its ``_fd_to_key`` map after the worker + already closed the socket. + """ + self._ops.append(_OpClose(fd)) + self._wake() + # ----- Op queue + self-pipe helpers ----- def _wake(self) -> None: @@ -575,9 +759,9 @@ def _drain_ops(self) -> None: try: if isinstance(op, _OpTrack): self._apply_track(op.conn) - elif isinstance(op, _OpClose) and op.fd in self.selector.get_map(): + elif isinstance(op, _OpClose) and op.fd in self._sel.get_map(): try: - self.selector.unregister(op.fd) + self._sel.unregister(op.fd) except (KeyError, ValueError): pass except Exception: @@ -586,14 +770,14 @@ def _drain_ops(self) -> None: def _apply_track(self, conn: BaseHTTPConn) -> None: """Selector-thread handler for ``_OpTrack``. - Probes ``selector.get_map()`` (an O(1) dict-``in`` check) to choose + Probes ``self._sel.get_map()`` (an O(1) dict-``in`` check) to choose ``modify`` vs ``register``, instead of catching ``KeyError`` from ``modify``. The error path inside ``selectors.modify`` builds the exception message as ``f"{fileobj!r}"`` — and ``socket.__repr__`` is surprisingly expensive (~25 µs/call). Per-request overhead. """ sock = conn.sock - sel = self.selector + sel = self._sel try: if conn.fd in sel.get_map(): sel.modify(sock, selectors.EVENT_READ, data=conn) @@ -606,11 +790,91 @@ def _apply_track(self, conn: BaseHTTPConn) -> None: except OSError: pass - def _shutdown_active_connections(self) -> None: - """Set the shutdown flag and close any connections still in the selector. + # ----- Stale-conn sweep ----- + + def _find_stale(self) -> Iterator[BaseHTTPConn]: + now = time.monotonic() + for key in self._sel.get_map().values(): + data = key.data + if isinstance(data, BaseHTTPConn) and data.close_at and now > data.close_at: + yield data + + def _cleanup_stale(self) -> None: + # Selector-thread only. Lock-free: ``self._sel`` and ``conn.tracked`` + # are owned by the selector thread. + # + # We deliberately keep this on the selector thread rather than a + # dedicated cleanup thread. With the lazy gate below the common + # case is an O(1) timestamp compare (no walk); the actual O(N) + # sweep runs at most every ``_cleanup_interval``, ~twice a second + # by default. A separate thread would either need a lock around + # ``self._sel.get_map()`` (losing the lock-free property of the + # current op-queue model) or maintain a parallel data structure + # — both add machinery without buying anything visible. + # + # Lazy: skip the O(N) walk over registered conns when not enough + # time has passed since the last sweep. Worst-case extra detection + # latency is ``_cleanup_interval`` (default 0.5 s) on top of the + # configured ``rw_timeout`` / ``keep_alive_timeout`` — both already + # measure non-fatal conditions. + now = time.monotonic() + if now - self._last_cleanup_at < self._cleanup_interval: + return + self._last_cleanup_at = now + stale = list(self._find_stale()) + for conn in stale: + try: + self._sel.unregister(conn.sock) + except (KeyError, ValueError): + pass + conn.tracked = False + for conn in stale: + # Stalled mid-request gets a 408; idle keep-alive gets silently + # dropped. The decision and the bytes-on-wire are the conn's + # job — backends differ. + try: + conn.emit_stale_408() + except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway + pass + try: + conn.sock.close() + except OSError: + pass + + # ----- Run loop ----- + + def run(self, *, timeout: float | None = None) -> None: + """One iteration of the selector loop. Should be called repeatedly until the selector is stopped. + + ``timeout`` bounds the underlying ``selectors.select`` call — it caps + how long this method blocks before returning to the caller, giving + the caller a chance to check for shutdown / cancellation. Defaults to + ``config.select_timeout``. + + Uniform dispatch — every fd in the map has a :data:`SelectorCallback` + as ``key.data``. No isinstance branches, no sentinel comparisons. + """ + if self._selector_thread_id is None: + self._selector_thread_id = threading.get_ident() + if timeout is None: + timeout = self.config.select_timeout + self._drain_ops() + self._cleanup_stale() + for key, _ in self._sel.select(timeout=timeout): + cb: SelectorCallback = key.data + try: + cb(self) + except Exception: + self.logger.exception("Selector callback raised: %r", cb) + + # ----- Shutdown ----- + + def shutdown(self) -> None: + """Set the shutdown flag, drain residual ops, close registered conns, + and close the wakeup pipe. - Called from ``start_http_server.__exit__`` *after* the selector loop - has stopped. Workers calling ``track`` / ``close`` post-shutdown + Called from the outer context manager *after* the selector loop has + stopped. Workers calling ``track`` / ``close`` post-shutdown self-clean via the ``shutting_down`` check. """ self.shutting_down = True @@ -630,87 +894,107 @@ def _shutdown_active_connections(self) -> None: op.conn.tracked = False # _OpClose just cleans _fd_to_key — handled by the walk below. - for key in list(self.selector.get_map().values()): - if key.fileobj is self.sock: - continue # listening socket — closed by the outer CM - if key.fileobj == self._wakeup_r: - continue # wakeup pipe — closed below - if not isinstance(key.data, BaseHTTPConn): + for key in list(self._sel.get_map().values()): + data = key.data + if not isinstance(data, BaseHTTPConn): continue - conn = key.data try: - self.selector.unregister(conn.sock) + self._sel.unregister(data.sock) except (KeyError, ValueError): pass try: - conn.sock.close() + data.sock.close() except OSError: pass - conn.tracked = False + data.tracked = False # Close the wakeup pipe. - try: - self.selector.unregister(self._wakeup_r) - except (KeyError, ValueError): - pass + with suppress(KeyError, ValueError): + self._sel.unregister(self._wakeup_r) for fd in (self._wakeup_r, self._wakeup_w): try: os.close(fd) except OSError: pass + def close(self) -> None: + """Close the underlying ``selectors.BaseSelector``. Idempotent.""" + with suppress(Exception): + self._sel.close() + + +# -------------------------------------------------------------------------- +# BaseServer — listen socket + Selector + ConnHandler composition +# -------------------------------------------------------------------------- + + +@final +class BaseServer: + """Listening server: composes a :class:`Selector`, a listen socket, and + a :data:`ConnHandler`. + + Parser-agnostic; the conn factory lives inside the :data:`ConnHandler` + (so ``BaseServer`` itself doesn't know whether you're using the h11 or + httptools backend). + + The default :class:`TrackHere` ``ConnHandler`` reproduces today's + behaviour: each accepted connection is built and tracked on the same + selector that accepted it. Use :class:`RoundRobinAcceptor` to + distribute conns across worker selectors. + """ + + def __init__( + self, + config: ServerConfig, + conn_handler: ConnHandler, + logger: logging.Logger, + server_sock: socket.socket, + selector: Selector, + ) -> None: + self.sock = server_sock + self.port: int = server_sock.getsockname()[1] + """Actual port the server is listening on (useful when port 0 is + specified to auto-assign a free port).""" + self.selector = selector + self.config = config + self.conn_handler = conn_handler + self.logger = logger + # Listen socket is registered with an _AcceptListener callback. + server_sock.settimeout(0) + selector.register( + server_sock.fileno(), + _AcceptListener(server_sock, conn_handler, logger), + ) + def run(self, *, timeout: float | None = None) -> None: - """One iteration of the server loop. Should be called repeatedly until the server is stopped. + """Forwarder to :meth:`Selector.run`. Convenience for the common + all-in-one shape (BaseServer owns its selector and the caller + drives one loop). + """ + self.selector.run(timeout=timeout) - ``timeout`` bounds the underlying ``selector.select`` call — it caps how long this - method blocks before returning to the caller, giving the caller a chance to check - for shutdown / cancellation. Defaults to ``config.select_timeout``. + @property + def shutting_down(self) -> bool: + return self.selector.shutting_down + + def shutdown(self) -> None: + """Tear down: shut down the selector (which closes registered conns + and the wakeup pipe). The listening socket is closed by the outer + context manager. """ - if self._selector_thread_id is None: - self._selector_thread_id = threading.get_ident() - if timeout is None: - timeout = self.config.select_timeout - server_sock = self.sock - h = self.handler - self._drain_ops() - self._cleanup_stale() - for key, _ in self.selector.select(timeout=timeout): - data = key.data - if data is _WAKEUP_SENTINEL: - self._drain_wakeup() - self._drain_ops() - continue - if key.fileobj is server_sock: - client_sock, client_addr = server_sock.accept() - # Linux 2.6.28+ inherits ``O_NONBLOCK`` from the listening - # socket; macOS / BSD do not. Set explicitly. While the - # conn is tracked the socket is non-blocking; the send / - # recv paths may flip it to blocking-with-timeout on a - # borrowed conn (``_send_all`` and the per-backend - # receive helpers) and the give-back path resets it - # before re-tracking. - client_sock.setblocking(False) - conn = self._conn_factory(self, client_sock, client_addr) - self.track(conn) - continue - if isinstance(data, BaseHTTPConn): - try: - data(h) - except Exception: - self.logger.exception("Unhandled exception from connection %s", data.addr) - try: - data.close() - except Exception: # noqa: BLE001, S110 - pass - else: - raise RuntimeError(f"Unexpected selector key: {key!r}") # noqa: TRY004 + self.selector.shutdown() + + +# -------------------------------------------------------------------------- +# Public entry points +# -------------------------------------------------------------------------- @contextmanager def start_http_server_base( config: ServerConfig, handler: RequestHandler, - conn_factory: _ConnFactory, + conn_factory: ConnFactory, /, ) -> Iterator[BaseServer]: """Open a listening socket and yield a :class:`BaseServer` driving ``conn_factory``. @@ -725,18 +1009,18 @@ def start_http_server_base( backlog=config.backlog, reuse_port=True, ) - selector = selectors.DefaultSelector() - - server_sock.settimeout(0) - selector.register(server_sock, selectors.EVENT_READ) + port = server_sock.getsockname()[1] + selector = Selector(config, port=port, logger=logger) + conn_handler = TrackHere(handler, conn_factory) - with closing(server_sock), closing(selector): - server = BaseServer(config, handler, logger, server_sock, selector, conn_factory) + with closing(server_sock): + server = BaseServer(config, conn_handler, logger, server_sock, selector) logger.info("Serving on %s:%d", config.host, server.port) try: yield server finally: - server._shutdown_active_connections() + server.shutdown() + selector.close() def start_http_server( diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index eea4037..0eb0111 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -106,7 +106,7 @@ def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: shutdown_event = self._shutdown_event def dispatched(ctx: HTTPReqCtx) -> None: - ctx.server.stop_tracking(ctx.conn) + ctx.conn.selector.stop_tracking(ctx.conn) if not admission.acquire(blocking=False): _reject_overloaded(ctx) return diff --git a/localpost/http/_service.py b/localpost/http/_service.py index e0e0596..2372116 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -1,16 +1,24 @@ from __future__ import annotations import dataclasses +import logging import socket from collections.abc import Awaitable +from contextlib import closing from wsgiref.types import WSGIApplication from anyio import Event, create_task_group, from_thread, to_thread from localpost import hosting from localpost.hosting import ServiceLifetime -from localpost.http.config import ServerConfig -from localpost.http.server import RequestHandler, start_http_server +from localpost.http._base import ( + BaseServer, + RoundRobinAcceptor, + Selector, + start_http_server, +) +from localpost.http.config import LOGGER_NAME, ServerConfig +from localpost.http.server import RequestHandler from localpost.http.wsgi import wrap_wsgi __all__ = ["http_server", "wsgi_server"] @@ -29,6 +37,25 @@ def _resolve_ephemeral_port(host: str) -> int: return probe.getsockname()[1] +def _pick_conn_factory(backend: str): + """Match :func:`start_http_server`'s backend selection — used by the + acceptor topology, where we wire :class:`RoundRobinAcceptor` directly + instead of going through ``start_http_server``.""" + if backend == "h11": + from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 + + return HTTPConn + if backend == "httptools": + try: + from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" + ) from e + return HTTPConn + raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") + + @hosting.service def http_server( config: ServerConfig, @@ -36,6 +63,7 @@ def http_server( /, *, selectors: int = 1, + acceptor: bool = False, ): """Run an HTTP server inside a hosted service. @@ -58,20 +86,36 @@ def http_server( handler: Sync request handler. Shared across selector threads when ``selectors > 1`` — make sure any state it captures is thread-safe (``thread_pool_handler`` already is). - selectors: Number of selector threads. Default 1. With ``> 1``, - each selector binds its own listening socket on the same - address via ``SO_REUSEPORT``; the kernel distributes incoming - connections. ``config.port == 0`` is resolved to an ephemeral - port once and shared by all selectors. Measured impact on - standard CPython / macOS is flat (GIL holds during parser - callbacks + dispatch + channel handoff; macOS - ``SO_REUSEPORT`` doesn't load-balance like Linux). Useful on - Linux (kernel-level distribution) and on free-threaded - builds; see ``benchmarks/http/PERF_FINDINGS.md`` Phase 7. + selectors: Number of selector threads. Default 1. + + With ``acceptor=False`` (default), each selector binds its own + listening socket on the same address via ``SO_REUSEPORT``; + the kernel distributes incoming connections. ``config.port == 0`` + is resolved to an ephemeral port once and shared by all + selectors. Measured impact on standard CPython / macOS is + flat (GIL holds during parser callbacks + dispatch + channel + handoff; macOS ``SO_REUSEPORT`` doesn't load-balance like + Linux). Useful on Linux (kernel-level distribution) and on + free-threaded builds; see ``benchmarks/http/PERF_FINDINGS.md`` + Phase 7. + + With ``acceptor=True``, ``selectors`` is the number of + **worker** selectors fed by a single acceptor thread. See + ``acceptor`` below. + acceptor: When ``True``, run a dedicated acceptor thread that + accepts every connection on the listen socket and round-robins + it to one of ``selectors`` worker selectors via the cross-thread + op queue. Useful when ``SO_REUSEPORT`` doesn't distribute + evenly (macOS, free-threaded builds). The acceptor thread does + no parsing; worker selectors do all I/O for the conns assigned + to them. """ if selectors < 1: raise ValueError(f"selectors must be >= 1 (got {selectors})") + if acceptor: + return _run_acceptor(config, handler, workers=selectors) + if selectors == 1: def run_single(lt: ServiceLifetime) -> Awaitable[None]: @@ -120,12 +164,97 @@ async def wait_started() -> None: return run_multi +def _run_acceptor(config: ServerConfig, handler: RequestHandler, *, workers: int): + """Acceptor topology: 1 acceptor thread + N worker-selector threads. + + The acceptor's :class:`BaseServer` runs a :class:`RoundRobinAcceptor` + as its :data:`ConnHandler`; each accepted client socket is wrapped in + a :class:`BaseHTTPConn` bound to the next worker :class:`Selector` and + delivered via :meth:`Selector.post_track` (cross-thread op queue + + wakeup pipe). + """ + conn_factory = _pick_conn_factory(config.backend) + + async def run(lt: ServiceLifetime) -> None: + logger = logging.getLogger(LOGGER_NAME) + # Bind the listen socket once on the calling (anyio) thread so we + # can compute ``port`` deterministically before any worker spins + # up. This mirrors what ``start_http_server_base`` does internally. + listen_sock = socket.create_server( + (config.host, config.port), + backlog=config.backlog, + reuse_port=True, + ) + port = listen_sock.getsockname()[1] + logger.info("Serving on %s:%d (acceptor + %d workers)", config.host, port, workers) + + # Build worker selectors first so the acceptor's ConnHandler can + # capture them. Each worker has its own Selector with its own + # wakeup pipe / op queue. ``shutting_down`` is per-selector — we + # toggle them on shutdown. + worker_selectors = tuple( + Selector(config, port=port, logger=logger) for _ in range(workers) + ) + acceptor_selector = Selector(config, port=port, logger=logger) + round_robin = RoundRobinAcceptor( + workers=worker_selectors, + handler=handler, + conn_factory=conn_factory, + ) + acceptor_server = BaseServer( + config, round_robin, logger, listen_sock, acceptor_selector + ) + + worker_started: list[Event] = [Event() for _ in range(workers)] + acceptor_started = Event() + + def run_worker(idx: int) -> None: + sel = worker_selectors[idx] + try: + from_thread.run_sync(worker_started[idx].set) + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + sel.run() + finally: + sel.shutdown() + sel.close() + + def run_acceptor() -> None: + try: + from_thread.run_sync(acceptor_started.set) + while not lt.shutting_down.is_set(): + from_thread.check_cancelled() + acceptor_server.run() + finally: + acceptor_server.shutdown() + acceptor_selector.close() + + async def wait_started() -> None: + await acceptor_started.wait() + for ev in worker_started: + await ev.wait() + lt.set_started() + + try: + async with create_task_group() as tg: + tg.start_soon(wait_started) + tg.start_soon(to_thread.run_sync, run_acceptor) + for i in range(workers): + tg.start_soon(to_thread.run_sync, run_worker, i) + finally: + with closing(listen_sock): + pass + + return run + + def wsgi_server( config: ServerConfig, app: WSGIApplication, /, *, selectors: int = 1, + acceptor: bool = False, ): """Same as :func:`http_server`, but for a WSGI application. @@ -137,4 +266,4 @@ def wsgi_server( async with http_server(config, h): ... """ - return http_server(config, wrap_wsgi(app), selectors=selectors) + return http_server(config, wrap_wsgi(app), selectors=selectors, acceptor=acceptor) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 25f1ef5..f7dc4b2 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -35,9 +35,9 @@ REQUEST_TIMEOUT_BODY, REQUEST_TIMEOUT_RESPONSE, BaseHTTPConn, - BaseServer, BodyHandler, RequestHandler, + Selector, _send_all, emit_handler_error, ) @@ -72,9 +72,10 @@ def _has_response_framing(headers) -> bool: @final @dataclass(eq=False, slots=True) class HTTPConn(BaseHTTPConn): - server: BaseServer + selector: Selector sock: socket.socket addr: tuple[str, int] + handler: RequestHandler fd: int = field(init=False) """The integer file descriptor captured at construction time. Used to clean up ``selector._fd_to_key`` after ``sock.close()`` (where @@ -89,7 +90,7 @@ class HTTPConn(BaseHTTPConn): ``__call__`` entry until ``stop_tracking`` (in the :data:`BodyHandler` dispatcher); the worker owns it from then until ``track`` re-registers the conn. The op-queue / wakeup-pipe - handoff in :class:`localpost.http._base.BaseServer` is the + handoff in :class:`localpost.http._base.Selector` is the synchronisation edge — `os.write` to the wakeup pipe is a full memory barrier across threads. The parser is **never** touched concurrently from two threads. @@ -129,26 +130,27 @@ def send(self, event: h11.InformationalResponse | h11.Response | h11.Data | h11. return _send_all(self, payload) - def __call__(self, h: RequestHandler, /) -> None: + def __call__(self, _sel: Selector, /) -> None: try: - self._loop(h) + self._loop() except h11.RemoteProtocolError as e: - self.server.logger.warning("Bad client input from %s: %s", self.addr, e) + self.selector.logger.warning("Bad client input from %s: %s", self.addr, e) self._try_send_status(BAD_REQUEST_RESPONSE, BAD_REQUEST_BODY) self.close() except h11.LocalProtocolError: - self.server.logger.exception("Local protocol error from %s", self.addr) + self.selector.logger.exception("Local protocol error from %s", self.addr) self._try_send_status(INTERNAL_ERROR_RESPONSE, INTERNAL_ERROR_BODY) self.close() except BodyTooLarge: - self.server.logger.warning( - "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size + self.selector.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.selector.config.max_body_size ) self._try_send_status(PAYLOAD_TOO_LARGE_RESPONSE, PAYLOAD_TOO_LARGE_BODY) self.close() - def _loop(self, h: RequestHandler) -> None: + def _loop(self) -> None: parser = self.parser + h = self.handler while self.tracked: if parser.our_state is h11.MUST_CLOSE: @@ -158,7 +160,7 @@ def _loop(self, h: RequestHandler) -> None: parser.start_next_cycle() self.body_bytes_received = 0 self.idle = True - self.close_at = time.monotonic() + self.server.config.keep_alive_timeout + self.close_at = time.monotonic() + self.selector.config.keep_alive_timeout # Per-request state from previous cycle is no longer relevant. self._ctx = None self._continuation = None @@ -180,10 +182,10 @@ def _loop(self, h: RequestHandler) -> None: self.receive() except BlockingIOError: return # Wait for it in the selector - self.close_at = time.monotonic() + self.server.config.rw_timeout + self.close_at = time.monotonic() + self.selector.config.rw_timeout elif isinstance(event, h11.Data): self.body_bytes_received += len(event.data) - if self.body_bytes_received > self.server.config.max_body_size: + if self.body_bytes_received > self.selector.config.max_body_size: raise BodyTooLarge(self.body_bytes_received) if self._continuation is not None: self._body_buf += event.data @@ -201,7 +203,7 @@ def _loop(self, h: RequestHandler) -> None: except BodyTooLarge: raise except Exception: - self.server.logger.exception( + self.selector.logger.exception( "Body handler raised for %s %r", ctx.request.method, ctx.request.target ) emit_handler_error(ctx) @@ -211,7 +213,7 @@ def _loop(self, h: RequestHandler) -> None: return elif isinstance(event, h11.Request): cl = _content_length(event.headers) - if cl is not None and cl > self.server.config.max_body_size: + if cl is not None and cl > self.selector.config.max_body_size: raise BodyTooLarge(cl) # h11 hands us ``bytes`` for method/target/version and a list # of ``(bytes, bytes)`` tuples for headers. ``bytes(b)`` for an @@ -241,7 +243,7 @@ def _loop(self, h: RequestHandler) -> None: headers=list(event.headers), http_version=event.http_version, ) - ctx = HTTPReqCtxH11(self.server, self, req) + ctx = HTTPReqCtxH11(self.selector, self, req) self._ctx = ctx self._body_buf = bytearray() self._continuation = None @@ -250,7 +252,7 @@ def _loop(self, h: RequestHandler) -> None: except BodyTooLarge: raise except Exception: - self.server.logger.exception("Handler raised for %s %r", event.method, event.target) + self.selector.logger.exception("Handler raised for %s %r", event.method, event.target) emit_handler_error(ctx) result = None if result is not None: @@ -260,7 +262,7 @@ def _loop(self, h: RequestHandler) -> None: if not self.tracked: return # connection was closed during error recovery elif isinstance(event, h11.ConnectionClosed): - self.server.logger.debug("Client closed connection") + self.selector.logger.debug("Client closed connection") self.close() return else: @@ -312,7 +314,7 @@ class HTTPReqCtxH11: chunk in a single ``sendall``. """ - server: BaseServer + selector: Selector conn: HTTPConn request: Request @@ -329,7 +331,7 @@ def borrowed(self) -> bool: def borrow(self) -> Iterator[HTTPReqCtxH11]: """Switch the conn out of selector tracking for the duration of the block.""" assert not self.borrowed - self.server.stop_tracking(self.conn) + self.selector.stop_tracking(self.conn) try: yield self finally: @@ -351,7 +353,7 @@ def _maybe_give_back(self) -> None: sock.settimeout(0) except OSError: pass - self.server.track(self.conn) + self.selector.track(self.conn) def complete(self, response: Response, body: bytes | None = None) -> None: self.start_response(response) @@ -382,7 +384,7 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: try: self.conn.receive(size) except BlockingIOError: - sock.settimeout(self.server.config.rw_timeout) + sock.settimeout(self.selector.config.rw_timeout) try: self.conn.receive(size) finally: @@ -390,7 +392,7 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: sock.settimeout(0) elif isinstance(event, h11.Data): self.conn.body_bytes_received += len(event.data) - if self.conn.body_bytes_received > self.server.config.max_body_size: + if self.conn.body_bytes_received > self.selector.config.max_body_size: raise BodyTooLarge(self.conn.body_bytes_received) return bytes(event.data) elif isinstance(event, h11.EndOfMessage): diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 5a5e8d6..b6ffae6 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -38,9 +38,9 @@ PAYLOAD_TOO_LARGE_WIRE, REQUEST_TIMEOUT_WIRE, BaseHTTPConn, - BaseServer, BodyHandler, RequestHandler, + Selector, _send_all, emit_handler_error, ) @@ -112,9 +112,10 @@ def _response_allows_body(request_method: bytes, status_code: int) -> bool: @final @dataclass(eq=False, slots=True) class HTTPConn(BaseHTTPConn): - server: BaseServer + selector: Selector sock: socket.socket addr: tuple[str, int] + handler: RequestHandler fd: int = field(init=False) parser: httptools.HttpRequestParser = field(init=False) """The httptools (llhttp) parser — parse-only on our side; the @@ -127,7 +128,7 @@ class HTTPConn(BaseHTTPConn): the worker also calls ``parser.feed_data`` from inside ``ctx.receive`` to drain remaining body bytes. The op-queue / wakeup-pipe handoff in - :class:`localpost.http._base.BaseServer` is the synchronisation + :class:`localpost.http._base.Selector` is the synchronisation edge — `os.write` to the wakeup pipe is a full memory barrier. The parser is **never** touched concurrently from two threads; ownership is strict (selector → worker on ``stop_tracking``; @@ -176,9 +177,6 @@ class HTTPConn(BaseHTTPConn): _close_after_response: bool = False _response_started: bool = False - # Set by ``__call__`` so parser callbacks can dispatch the pre-body handler. - _handler: RequestHandler | None = None - def __post_init__(self) -> None: self.fd = self.sock.fileno() self.parser = httptools.HttpRequestParser(self) @@ -228,7 +226,7 @@ def on_headers_complete(self) -> None: except ValueError: cl = None break - if cl is not None and cl > self.server.config.max_body_size: + if cl is not None and cl > self.selector.config.max_body_size: self._body_too_large = cl self._cur_oversize = True return @@ -259,7 +257,7 @@ def on_headers_complete(self) -> None: http_version=version, ) ctx = HTTPReqCtxHttptools( - self.server, + self.selector, self, req, _expect_100_continue=self._cur_expect_100, @@ -274,13 +272,12 @@ def on_headers_complete(self) -> None: # thread; it can call ``ctx.complete(...)`` (response sent inline, # returns None), ``ctx.borrow()`` (escape to worker, returns None), # or return a :data:`BodyHandler` continuation to receive the body. - assert self._handler is not None try: - result = self._handler(ctx) + result = self.handler(ctx) except BodyTooLarge: raise except Exception: - self.server.logger.exception("Handler raised for %s %r", req.method, req.target) + self.selector.logger.exception("Handler raised for %s %r", req.method, req.target) emit_handler_error(ctx) result = None @@ -312,7 +309,7 @@ def on_body(self, data: bytes) -> None: # of which thread is currently inside ``feed_data``. ``ctx.receive`` # drains it (and pulls more bytes from the socket if needed). new_total = len(self._streaming_body_buf) + len(data) - if new_total > self.server.config.max_body_size: + if new_total > self.selector.config.max_body_size: self._body_too_large = new_total return self._streaming_body_buf += data @@ -320,7 +317,7 @@ def on_body(self, data: bytes) -> None: if self._continuation is None or self._cur_oversize: return # no body wanted (handler done) or already oversize new_total = len(self._body_buf) + len(data) - if new_total > self.server.config.max_body_size: + if new_total > self.selector.config.max_body_size: self._body_too_large = new_total return self._body_buf += data @@ -341,23 +338,22 @@ def on_message_complete(self) -> None: except BodyTooLarge: raise except Exception: - self.server.logger.exception("Body handler raised for %s %r", ctx.request.method, ctx.request.target) + self.selector.logger.exception("Body handler raised for %s %r", ctx.request.method, ctx.request.target) emit_handler_error(ctx) self._message_complete = True # ----- BaseHTTPConn surface ----- - def __call__(self, h: RequestHandler, /) -> None: - self._handler = h + def __call__(self, _sel: Selector, /) -> None: try: self._loop() except _ProtocolError as e: - self.server.logger.warning("Bad client input from %s: %s", self.addr, e) + self.selector.logger.warning("Bad client input from %s: %s", self.addr, e) self._try_send_wire(BAD_REQUEST_WIRE) self.close() except BodyTooLarge: - self.server.logger.warning( - "Request body from %s exceeds max_body_size=%d", self.addr, self.server.config.max_body_size + self.selector.logger.warning( + "Request body from %s exceeds max_body_size=%d", self.addr, self.selector.config.max_body_size ) self._try_send_wire(PAYLOAD_TOO_LARGE_WIRE) self.close() @@ -382,7 +378,7 @@ def _feed(self, data: bytes) -> None: dispatch() def _loop(self) -> None: - config = self.server.config + config = self.selector.config while self.tracked: # Did the previous request just complete? Either roll into the # next one (keep-alive) or close. @@ -429,7 +425,7 @@ def _reset_for_next_request(self) -> None: self._streaming_eom = False self._deferred_streaming_dispatch = None self.idle = True - self.close_at = time.monotonic() + self.server.config.keep_alive_timeout + self.close_at = time.monotonic() + self.selector.config.keep_alive_timeout def _try_send_wire(self, wire: bytes) -> None: """Best-effort: write a pre-serialised status+headers+body block. @@ -471,7 +467,7 @@ class HTTPReqCtxHttptools: chunk together; one per subsequent chunk). """ - server: BaseServer + selector: Selector conn: HTTPConn request: Request _expect_100_continue: bool = False @@ -499,7 +495,7 @@ def borrowed(self) -> bool: @contextmanager def borrow(self) -> Iterator[HTTPReqCtxHttptools]: assert not self.borrowed - self.server.stop_tracking(self.conn) + self.selector.stop_tracking(self.conn) try: yield self finally: @@ -533,7 +529,7 @@ def _maybe_give_back(self) -> None: sock.settimeout(0) except OSError: pass - self.server.track(self.conn) + self.selector.track(self.conn) def complete(self, response: Response, body: bytes | None = None) -> None: self.start_response(response) @@ -561,7 +557,7 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: self._send_continue() conn = self.conn - rw = self.server.config.rw_timeout + rw = self.selector.config.rw_timeout if conn._streaming_active: while not conn._streaming_body_buf and not conn._streaming_eom: diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 8767c4c..5edf4f4 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -139,8 +139,8 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: "QUERY_STRING": query_string, "CONTENT_TYPE": "", "CONTENT_LENGTH": "", - "SERVER_NAME": ctx.server.config.host, - "SERVER_PORT": str(ctx.server.port), + "SERVER_NAME": ctx.selector.config.host, + "SERVER_PORT": str(ctx.selector.port), "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", "wsgi.version": (1, 0), "wsgi.url_scheme": "http", diff --git a/tests/http/app.py b/tests/http/app.py index fb7e05f..9653381 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -618,7 +618,7 @@ async def test_httptools_deferred_streaming_dispatch_sees_current_feed_body(self def handler(ctx: HTTPReqCtx): def dispatch(req_ctx: HTTPReqCtx) -> None: - req_ctx.server.stop_tracking(req_ctx.conn) + req_ctx.conn.selector.stop_tracking(req_ctx.conn) conn = cast(Any, req_ctx.conn) captured["buffer_before_receive"] = bytes(conn._streaming_body_buf) captured["eom_before_receive"] = conn._streaming_eom diff --git a/tests/http/server.py b/tests/http/server.py index d220724..9547b71 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -38,7 +38,9 @@ def test_server_socket_closed_after_context(self): def test_handler_stored_on_server(self): with start_http_server(ServerConfig(host="127.0.0.1", port=0), _noop_handler) as server: - assert server.handler is _noop_handler + # ConnHandler owns the RequestHandler in the new chain layout + # (Selector → ConnHandler → RequestHandler → BodyHandler). + assert server.conn_handler.handler is _noop_handler # type: ignore[attr-defined] # --- Request / response basics ----------------------------------------------- diff --git a/tests/http/service.py b/tests/http/service.py index 543c268..93bb7b8 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -546,6 +546,86 @@ async def test_shutdown_stops_all_selectors(self, free_port): assert lt.exit_code == 0 +class TestAcceptorTopology: + """``acceptor=True`` runs a dedicated acceptor thread that round-robins + new conns to N worker selectors via the cross-thread op queue.""" + + async def test_serves_requests(self, free_port): + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(8): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_distributes_conns_across_workers(self, free_port): + """N concurrent fresh conns should land on multiple worker selectors + (round-robin). We sample the worker thread id per request — with + N=4 workers and 8 fresh conns we expect at least 2 distinct workers + (loose bound to keep this stable under scheduling jitter).""" + threads_seen: set[int] = set() + lock = threading.Lock() + + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + with lock: + threads_seen.add(threading.get_ident()) + ctx.complete( + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + b"hi", + ) + return None + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, handler, selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + + # Use fresh connections — keep-alive would pin every request to + # the same worker. ``httpx.Client(... headers={"Connection": + # "close"})`` is the simplest way. + results: list[httpx.Response | None] = [None] * 8 + + async def fire(i: int) -> None: + async with httpx.AsyncClient(timeout=5) as client: + results[i] = await client.get( + f"http://127.0.0.1:{free_port}/", + headers={"Connection": "close"}, + ) + + async with anyio.create_task_group() as tg: + for i in range(8): + tg.start_soon(fire, i) + + for r in results: + assert r is not None + assert r.status_code == 200 + + assert len(threads_seen) >= 2, f"expected round-robin across workers, got {threads_seen}" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_shutdown_clean(self, free_port): + """Acceptor + workers shut down cleanly on lt.shutdown().""" + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve(http_server(cfg, _handler_200(b"x"), selectors=3, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + class TestSelectorThreadFastPath: """When the Router is passed directly to ``http_server`` (no ``thread_pool_handler`` wrapping), every route — including the 404 / 405 paths — runs on the selector @@ -597,13 +677,7 @@ def hit(ctx: HTTPReqCtx) -> BodyHandler | None: await lt.stopped -class _FakeConn: - def __init__(self, sock: socket.socket) -> None: - self.sock = sock - self.tracked = True - - -class _FakeServer: +class _FakeSelector: def __init__(self) -> None: self.stopped = False @@ -612,9 +686,16 @@ def stop_tracking(self, conn: _FakeConn) -> None: conn.tracked = False +class _FakeConn: + def __init__(self, sock: socket.socket, selector: _FakeSelector) -> None: + self.sock = sock + self.tracked = True + self.selector = selector + + class _DeferringCtx: - def __init__(self, server: _FakeServer, conn: _FakeConn) -> None: - self.server = server + def __init__(self, selector: _FakeSelector, conn: _FakeConn) -> None: + self.selector = selector self.conn = conn self.request = Request(method=b"POST", target=b"/upload", path=b"/upload", query_string=b"", headers=[]) self.body = b"" @@ -629,9 +710,9 @@ def _defer_streaming_dispatch(self, dispatcher: Callable[[HTTPReqCtx], None]) -> class TestServiceRobustness: async def test_streaming_pool_defers_dispatch_when_context_requests_it(self): """Backends with parser callbacks can delay worker start until parsing unwinds.""" - server = _FakeServer() + selector = _FakeSelector() sock, peer = socket.socketpair() - ctx = _DeferringCtx(server, _FakeConn(sock)) + ctx = _DeferringCtx(selector, _FakeConn(sock, selector)) ran = threading.Event() def work(_ctx: HTTPReqCtx) -> None: @@ -641,12 +722,12 @@ def work(_ctx: HTTPReqCtx) -> None: async with streaming_pool_handler(work, max_concurrency=1) as wrapped: assert wrapped(cast(HTTPReqCtx, ctx)) is None assert ctx.deferred is not None - assert server.stopped is False + assert selector.stopped is False assert ran.wait(0.05) is False ctx.deferred() assert await to_thread.run_sync(lambda: ran.wait(2.0)) - assert server.stopped is True + assert selector.stopped is True assert ctx.conn.tracked is False finally: sock.close() From 408bd808df6f5fc48d7dfc847df4ff2f980b3c60 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 01:10:45 +0400 Subject: [PATCH 155/286] test(http): add Hypothesis property tests for parser parity and Router dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parser_parity_props.py fuzzes HTTP/1.1 wire bytes through h11 and httptools in parallel and asserts identical parsed Request shapes — locks the "backends are not unified behind a Protocol; parity is the contract" invariant. router_props.py drives Router.as_handler() in-process via a minimal HTTPReqCtx stand-in and locks the 200/404/405 outcome predicate, the longest-literal-prefix sort, and the 405 Allow-header union. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/parser_parity_props.py | 235 ++++++++++++++++++++++++++++ tests/http/router_props.py | 244 ++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 tests/http/parser_parity_props.py create mode 100644 tests/http/router_props.py diff --git a/tests/http/parser_parity_props.py b/tests/http/parser_parity_props.py new file mode 100644 index 0000000..d12a574 --- /dev/null +++ b/tests/http/parser_parity_props.py @@ -0,0 +1,235 @@ +"""Property-based parity tests across the h11 and httptools parser backends. + +The README explicitly does NOT unify the two backends behind a Protocol — they +are separate implementations populating the same neutral ``Request`` shape. +Parity is the contract; these tests fuzz wire bytes through both backends and +assert each backend parses the same input into an identical ``Request``. + +Companion to ``tests/http/backend_parity.py``, which holds the example-based +parity coverage (lifecycle, framing, error paths). Here we exercise the +shape of the parsed request itself across a much broader input space. +""" + +from __future__ import annotations + +import socket +import threading +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from typing import Literal + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from localpost.http import ( + HTTPReqCtx, + NativeResponse, + Request, + RequestHandler, + ServerConfig, + start_http_server, +) +from tests.http._helpers import read_http_response + +# --- Strategies for HTTP/1.1 wire bytes ------------------------------------- + +# httptools rejects lowercase methods at parse time (server_httptools.py:249); +# h11 case-folds inline (server_h11.py:236-237). Keep methods uppercase so the +# property compares the post-normalisation shape rather than this divergence. +_method = st.sampled_from([b"GET", b"POST", b"PUT", b"DELETE", b"PATCH", b"HEAD"]) + +# Chars that obscure path/query splitting or trigger pct-decode questions +# neither parser promises to normalise. Both backends accept them, but we +# strip them from the path strategy to keep the property tractable. +_PATH_UNSAFE = "/{}?#&= %" + +_path_segment = st.text( + st.characters(min_codepoint=0x21, max_codepoint=0x7E, blacklist_characters=_PATH_UNSAFE), + min_size=1, + max_size=8, +).map(lambda s: s.encode("ascii")) + +_query_token = st.text( + st.characters(min_codepoint=0x21, max_codepoint=0x7E, blacklist_characters=_PATH_UNSAFE), + min_size=1, + max_size=8, +).map(lambda s: s.encode("ascii")) + +_header_name = st.from_regex(r"\A[a-zA-Z][a-zA-Z0-9\-]{0,15}\Z").map(lambda s: s.encode("ascii")) + +_header_value = st.text( + st.characters(min_codepoint=0x20, max_codepoint=0x7E, blacklist_characters=":"), + min_size=0, + max_size=20, +).map(lambda s: s.encode("ascii")) + +# Headers that affect framing or connection lifecycle are excluded — they have +# their own example-based parity coverage in ``backend_parity.py``. +_FRAMING_HEADERS = frozenset( + (b"host", b"content-length", b"transfer-encoding", b"connection", b"expect", b"upgrade", b"trailer", b"te"), +) + + +@st.composite +def _path(draw) -> bytes: + n = draw(st.integers(min_value=1, max_value=4)) + return b"/" + b"/".join(draw(_path_segment) for _ in range(n)) + + +@st.composite +def _query(draw) -> bytes: + n = draw(st.integers(min_value=0, max_value=3)) + if n == 0: + return b"" + pairs = [draw(_query_token) + b"=" + draw(_query_token) for _ in range(n)] + return b"?" + b"&".join(pairs) + + +@st.composite +def _extra_headers(draw) -> list[tuple[bytes, bytes]]: + n = draw(st.integers(min_value=0, max_value=4)) + out: list[tuple[bytes, bytes]] = [] + seen: set[bytes] = set() + for _ in range(n): + name = draw(_header_name) + key = name.lower() + if key in _FRAMING_HEADERS or key in seen: + continue + seen.add(key) + out.append((name, draw(_header_value))) + return out + + +@st.composite +def _request_wire(draw) -> tuple[bytes, dict]: + """Build (wire_bytes, expected_shape) for a fuzzed HTTP/1.1 request. + + The expected shape is the normalised ``Request`` projection both backends + must produce — methods uppercase, header names lowercase, path/query + pre-split on the first ``?``. + """ + method = draw(_method) + path = draw(_path()) + query = draw(_query()) + extras = draw(_extra_headers()) + + target = path + query + headers = [(b"Host", b"example.com"), *extras] + request_line = method + b" " + target + b" HTTP/1.1\r\n" + header_block = b"".join(name + b": " + value + b"\r\n" for name, value in headers) + wire = request_line + header_block + b"\r\n" + + expected = { + "method": method, + "target": target, + "path": path, + "query_string": query[1:] if query else b"", + "headers": [(name.lower(), value) for name, value in headers], + "http_version": b"1.1", + } + return wire, expected + + +# --- Server scaffolding ----------------------------------------------------- + + +@contextmanager +def _serve(handler: RequestHandler, backend: Literal["h11", "httptools"]) -> Generator[int]: + """Start an HTTP server on ``backend`` in a background thread; yield its port.""" + cfg = ServerConfig(host="127.0.0.1", port=0, backend=backend) + cm = start_http_server(cfg, handler) + server = cm.__enter__() + stop = threading.Event() + + def loop() -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + + t = threading.Thread(target=loop, daemon=True) + t.start() + try: + yield server.port + finally: + stop.set() + t.join(timeout=5) + cm.__exit__(None, None, None) + + +@pytest.fixture +def parity_servers() -> Iterator[tuple[int, int, list[Request], list[Request]]]: + """Run h11 + httptools servers in parallel; both capture their parsed requests.""" + pytest.importorskip("httptools") + + h11_captured: list[Request] = [] + ht_captured: list[Request] = [] + + def make_handler(captured: list[Request]) -> RequestHandler: + def handler(ctx: HTTPReqCtx): + captured.append(ctx.request) + ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + + return handler + + with ( + _serve(make_handler(h11_captured), "h11") as h11_port, + _serve(make_handler(ht_captured), "httptools") as ht_port, + ): + yield h11_port, ht_port, h11_captured, ht_captured + + +def _send_one(port: int, wire: bytes) -> bytes: + with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: + sock.sendall(wire) + return read_http_response(sock) + + +def _shape(req: Request) -> dict: + return { + "method": req.method, + "target": req.target, + "path": req.path, + "query_string": req.query_string, + "headers": list(req.headers), + "http_version": req.http_version, + } + + +# --- Properties ------------------------------------------------------------- + + +class TestParserParity: + @given(payload=_request_wire()) + @settings( + max_examples=80, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], + ) + def test_request_shape_parity(self, parity_servers, payload): + """Both backends parse the same wire bytes into equal ``Request`` shapes.""" + h11_port, ht_port, h11_captured, ht_captured = parity_servers + h11_captured.clear() + ht_captured.clear() + + wire, expected = payload + h11_resp = _send_one(h11_port, wire) + ht_resp = _send_one(ht_port, wire) + + # Both backends must respond — confirms the wire was accepted. + assert b"HTTP/1.1 200" in h11_resp, h11_resp + assert b"HTTP/1.1 200" in ht_resp, ht_resp + + assert len(h11_captured) == 1, h11_captured + assert len(ht_captured) == 1, ht_captured + + h11_shape = _shape(h11_captured[0]) + ht_shape = _shape(ht_captured[0]) + + # Cross-backend parity. + assert h11_shape == ht_shape + # And both match the spec we built the wire from — locks intent, not + # just agreement-on-a-bug. + assert h11_shape == expected diff --git a/tests/http/router_props.py b/tests/http/router_props.py new file mode 100644 index 0000000..f6b3f30 --- /dev/null +++ b/tests/http/router_props.py @@ -0,0 +1,244 @@ +"""Property-based tests for ``Router`` dispatch behavior. + +Mirrors the spec in :func:`localpost.http.router.Router._match` and locks in: + +- 200 / 404 / 405 outcome predicate — see ``router.py:241-287`` +- longest-literal-prefix sort — see ``router.py:209-213`` +- 405 ``Allow`` header is the sorted union of matching templates' methods + — see ``router.py:281-285`` + +Companion to ``tests/http/router.py``, which holds example-based coverage +exercised through the full HTTP wire. Here we drive the dispatcher +in-process (no socket, no httpx) so the property suite stays fast. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from http import HTTPMethod +from typing import Any, cast + +from hypothesis import assume, given, settings +from hypothesis import strategies as st + +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + NativeResponse, + Request, + RouteMatch, + Router, + Routes, + URITemplate, +) +from tests.http.uri_template_props import _literal_segment, _template_with_values + +# --- Strategies ------------------------------------------------------------- + +_method = st.sampled_from(list(HTTPMethod)) + + +@st.composite +def _route_set(draw) -> list[tuple[str, frozenset[HTTPMethod], str]]: + """Draw a list of ``(template, methods, concrete_uri)`` triples. + + Templates are unique by string. ``concrete_uri`` is one valid URI built from + the template — used to drive the matching path through the router. + """ + n = draw(st.integers(min_value=1, max_value=5)) + triples: list[tuple[str, frozenset[HTTPMethod], str]] = [] + seen: set[str] = set() + for _ in range(n): + template, _values, concrete = draw(_template_with_values()) + if template in seen: + continue + seen.add(template) + methods = frozenset(draw(st.sets(_method, min_size=1, max_size=5))) + triples.append((template, methods, concrete)) + return triples + + +@st.composite +def _scenario(draw) -> tuple[list[tuple[str, frozenset[HTTPMethod], str]], HTTPMethod, str]: + """Draw ``(routes, method, path)``. + + Half the time ``path`` is a concrete URI from one of the routes (likely 200 + or 405); the other half it is a random path (typically 404, occasionally + matches a fully-variable template). + """ + routes = draw(_route_set()) + if draw(st.booleans()): + _tmpl, _methods, concrete = draw(st.sampled_from(routes)) + path = concrete + else: + n = draw(st.integers(min_value=1, max_value=4)) + path = "/" + "/".join(draw(_literal_segment) for _ in range(n)) + method = draw(_method) + return routes, method, path + + +# --- Synthetic dispatch ----------------------------------------------------- + + +@dataclass(eq=False, slots=True) +class _FakeCtx: + """Minimal :class:`HTTPReqCtx` stand-in for in-process router dispatch. + + The router's ``as_handler()`` only touches ``ctx.request``, ``ctx.attrs``, + and ``ctx.complete(...)``. We capture ``complete`` calls so the test can + inspect the response that would have been sent. + """ + + request: Request + body: bytes = b"" + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + completed_response: NativeResponse | None = None + completed_body: bytes = b"" + + def complete(self, response: NativeResponse, body: bytes | None = None) -> None: + self.response_status = response.status_code + self.completed_response = response + self.completed_body = body or b"" + + +@dataclass(frozen=True, slots=True) +class _Outcome: + status: int + route_match: RouteMatch | None + response: NativeResponse | None + + +def _route_handler(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + return None + + +def _build_router(routes_list: list[tuple[str, frozenset[HTTPMethod], str]]) -> Router: + routes = Routes() + for template, methods, _concrete in routes_list: + for m in methods: + routes.add(m, template, _route_handler) + return routes.build() + + +def _dispatch(router: Router, method: HTTPMethod, path: str) -> _Outcome: + request = Request( + method=method.value.encode("ascii"), + target=path.encode("iso-8859-1"), + path=path.encode("iso-8859-1"), + query_string=b"", + headers=[], + ) + ctx = _FakeCtx(request=request) + handler = router.as_handler() + handler(cast(HTTPReqCtx, ctx)) + return _Outcome( + status=ctx.response_status or 0, + route_match=ctx.attrs.get(RouteMatch), + response=ctx.completed_response, + ) + + +# --- Helpers mirroring the source-of-truth (router.py) ---------------------- + +_VAR_PATTERN = re.compile(r"\{([^}]+)\}") + + +def _literal_prefix_len(template: str) -> int: + """Mirror of :func:`localpost.http.router._literal_prefix_len`.""" + m = _VAR_PATTERN.search(template) + return len(template) if m is None else m.start() + + +def _matches(template: str, path: str) -> bool: + return URITemplate.parse(template).match(path) is not None + + +def _allow_header(response: NativeResponse) -> str | None: + for name, value in response.headers: + if name.lower() == b"allow": + return value.decode("ascii") + return None + + +# --- Properties ------------------------------------------------------------- + + +class TestRouterDispatchProperties: + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_dispatch_outcome_matches_spec(self, scenario): + """200/404/405 outcome must match the spec at router.py:241-287.""" + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + + matching = [(t, m) for t, m, _ in routes if _matches(t, path)] + any_method_matches = any(method in methods for _, methods in matching) + + if not matching: + assert outcome.status == 404 + elif any_method_matches: + assert outcome.status == 200 + else: + assert outcome.status == 405 + + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_chosen_template_has_max_literal_prefix(self, scenario): + """On a 200, the chosen template has maximal literal prefix among method-supporting matchers. + + Mirrors the sort at router.py:209-213. Stable-sort means ties resolve + to insertion order; we assert ``==`` on the prefix length, which is + the only spec-level property without depending on insertion order. + """ + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + + if outcome.status != 200: + assume(False) + + assert outcome.route_match is not None + chosen = outcome.route_match.matched_template.template + method_supporting = [t for t, m, _ in routes if _matches(t, path) and method in m] + assert chosen in method_supporting + assert _literal_prefix_len(chosen) == max(_literal_prefix_len(t) for t in method_supporting) + + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_405_allow_header_is_sorted_method_union(self, scenario): + """On a 405, the Allow header is the sorted union of all matching templates' methods. + + Mirrors router.py:281-285 (single-route 405 uses the route's pre-built + Allow; multi-route 405 unions methods on the fly). + """ + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + + if outcome.status != 405: + assume(False) + + assert outcome.response is not None + allow = _allow_header(outcome.response) + assert allow is not None + + union: set[HTTPMethod] = set() + for t, m, _ in routes: + if _matches(t, path): + union |= m + expected = ", ".join(hm.value for hm in sorted(union, key=lambda hm: hm.value)) + assert allow == expected + + @given(scenario=_scenario()) + @settings(max_examples=300, deadline=None) + def test_404_iff_no_template_matches(self, scenario): + """404 ⇔ no template's regex matches the path. Bidirectional.""" + routes, method, path = scenario + router = _build_router(routes) + outcome = _dispatch(router, method, path) + any_match = any(_matches(t, path) for t, _, _ in routes) + assert (outcome.status == 404) == (not any_match) From 1c6d34e52cbd2dc12a2d11e4f348ae0702e83917 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 01:15:04 +0400 Subject: [PATCH 156/286] docs(http): add connection state diagram to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the prose-only "Two-state connection model" subsection with an ASCII diagram showing TRACKED ↔ BORROWED transitions and a transition table covering both single-selector and acceptor + multi-selector topologies. Documents the op-queue + wakeup-pipe sync edge as the only cross-thread handoff for parser ownership. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/README.md | 56 +++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index 3809cc3..268ec0d 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -404,12 +404,56 @@ matched routes run on workers. ### Two-state connection model -A connection is either **tracked** (registered in the selector for normal -HTTP processing) or **borrowed** (a worker thread holds it for the duration -of one request). The dispatcher unregisters before handing off to a worker -(`stop_tracking`); `finish_response` re-registers via `_maybe_give_back`. -Two states, no third "watchdog" mode, no shared mode field for threads to -race on. +A connection is either **TRACKED** (registered in the selector — selector +owns the fd, parser, and I/O) or **BORROWED** (a worker thread holds the +parser + socket; fd is unregistered). The dispatcher unregisters before +handing off to a worker (`stop_tracking`); `finish_response` re-registers +via `_maybe_give_back`. Two states, no third "watchdog" mode, no shared +mode field for threads to race on. + +``` + accept() + │ + ▼ + ConnHandler builds conn, + routes to a selector via + track() or post_track() + │ + ▼ + ┌──────────────────────┐ ┌──────────┐ + │ TRACKED │ close │ │ + │ fd registered ├────────▶│ CLOSED │ + │ selector owns I/O │ │ │ + └──┬───────────────────┘ └──────────┘ + │ ▲ ▲ + borrow │ │ _maybe_give_back │ close + ▼ │ │ + ┌──────────────────────┐ │ + │ BORROWED │ │ + │ fd unregistered ├───────────────┘ + │ worker owns I/O │ + └──────────────────────┘ +``` + +Both topologies (single selector and acceptor + N worker selectors) +funnel through the same diagram; only the entry edge differs: + +| From → to | Method | Caller thread | Mechanism | +| ------------------------ | -------------------- | ----------------- | ------------------------------------------------ | +| accept → TRACKED | `Selector.track` | selector | inline `selectors.register` (TrackHere topology) | +| accept → TRACKED | `Selector.post_track`| acceptor | op queue + wakeup pipe (RoundRobinAcceptor) | +| TRACKED → BORROWED | `stop_tracking` | selector | inline `selectors.unregister` | +| BORROWED → TRACKED | `Selector.track` | worker | op queue + wakeup pipe | +| TRACKED → CLOSED | `BaseHTTPConn.close` | selector | inline (idle / keep-alive / error) | +| BORROWED → CLOSED | `close` + `post_close` | worker | op queue + wakeup pipe (`_fd_to_key` cleanup) | + +The op queue + wakeup pipe is the only cross-thread synchronisation edge +(the `os.write` to the wakeup pipe is a full memory barrier). After +`track()` returns, anything the worker did to the conn's parser +(`h11.Connection.send` / `httptools.HttpRequestParser.feed_data`) is +visible to the selector. After `stop_tracking()` returns, anything the +selector did is visible to the worker. The parser is never touched +concurrently from two threads. ### Pull-based client-disconnect detection From 7882493f5b4408296a6b212a29b4d0a9d5d31c23 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 01:21:07 +0400 Subject: [PATCH 157/286] test(http): add acceptor + multi-selector stress suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushes harder on the recent acceptor refactor: - exact round-robin distribution: N=4 workers, 40 fresh conns; each worker sees exactly 10 (single-threaded counter is deterministic) - 64-conn burst: every request returns 200, no silent loss or hang - keep-alive pinning: 5 requests on one socket all served by the same worker selector (locks the parser-handoff invariant — exactly one selector owns each parser) - listening-fd release: same port re-bindable after lt.stopped (stronger leak check than exit_code==0) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/acceptor_stress.py | 223 ++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 tests/http/acceptor_stress.py diff --git a/tests/http/acceptor_stress.py b/tests/http/acceptor_stress.py new file mode 100644 index 0000000..e978835 --- /dev/null +++ b/tests/http/acceptor_stress.py @@ -0,0 +1,223 @@ +"""Stress tests for the acceptor + multi-selector topology. + +The recent acceptor refactor (``selectors=N, acceptor=True``) introduced a +dedicated acceptor thread that round-robins fresh conns onto N worker +selectors via ``Selector.post_track`` (cross-thread op queue + wakeup pipe). +Existing example tests in ``service.py`` cover the basic happy paths; +these tests push harder on: + +- exact distribution under burst (counter is single-threaded → deterministic) +- no conn loss when many clients connect concurrently +- keep-alive conns stay pinned to their initial worker +- clean shutdown leaves no orphan listening socket (port can be re-bound) +""" + +from __future__ import annotations + +import socket +import threading +import time +from collections import Counter +from concurrent.futures import ThreadPoolExecutor + +import pytest +from anyio import to_thread + +from localpost.hosting import serve +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + NativeResponse, + ServerConfig, + http_server, +) +from tests.http._helpers import read_http_response + +pytestmark = pytest.mark.anyio + + +def _capturing_handler(seen: Counter, lock: threading.Lock): + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + with lock: + seen[threading.get_ident()] += 1 + ctx.complete( + NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + return None + + return handler + + +def _wait_ready(port: int, deadline: float = 5.0) -> None: + end = time.monotonic() + deadline + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return + except OSError: + time.sleep(0.05) + raise RuntimeError(f"server on port {port} not ready within {deadline}s") + + +def _request_close(port: int) -> bytes: + """Open a fresh TCP conn, send GET / with Connection: close, return response bytes.""" + with socket.create_connection(("127.0.0.1", port), timeout=5.0) as sock: + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + return read_http_response(sock, deadline=5.0) + + +class TestRoundRobinDistribution: + async def test_exact_round_robin_with_fresh_conns(self, free_port): + """N=4 workers, K=N*10 fresh conns: each worker handles exactly 10. + + ``RoundRobinAcceptor`` runs single-threaded on the acceptor thread, + so its ``_next`` counter increments deterministically. With every + client opening a brand-new TCP conn, each ``accept()`` is a fresh + round-robin assignment. + """ + seen: Counter = Counter() + lock = threading.Lock() + n_workers = 4 + n_conns = n_workers * 10 + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=n_workers, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def fire_all() -> list[bytes]: + with ThreadPoolExecutor(max_workers=n_conns) as ex: + futures = [ex.submit(_request_close, free_port) for _ in range(n_conns)] + return [f.result() for f in futures] + + responses = await to_thread.run_sync(fire_all) + + for r in responses: + assert b"HTTP/1.1 200" in r, r + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + # Every worker selector thread must have served exactly n_conns/n_workers requests. + assert len(seen) == n_workers, seen + assert all(count == n_conns // n_workers for count in seen.values()), seen + + +class TestBurst: + async def test_no_conn_loss_under_burst(self, free_port): + """64 concurrent fresh conns through a 4-worker acceptor: every one returns 200. + + No assertion on distribution — the kernel ``accept()`` queue can + reorder under heavy concurrent connect()s, and SYN-flood mitigation + on the loopback may drop conns under extreme load. The point is no + request silently disappears or hangs. + """ + seen: Counter = Counter() + lock = threading.Lock() + n_conns = 64 + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=4, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def fire_all() -> list[bytes]: + with ThreadPoolExecutor(max_workers=n_conns) as ex: + futures = [ex.submit(_request_close, free_port) for _ in range(n_conns)] + return [f.result() for f in futures] + + responses = await to_thread.run_sync(fire_all) + + for r in responses: + assert b"HTTP/1.1 200" in r, r + + assert sum(seen.values()) == n_conns, seen + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + +class TestKeepAlivePinning: + async def test_keepalive_conn_stays_on_one_worker(self, free_port): + """A single keep-alive conn issuing N requests is served by ONE worker. + + A conn is round-robined once on accept; afterwards it lives on that + worker's selector for its whole lifetime. Verifying this matters + because a stray re-routing would corrupt the parser handoff + invariant (parser owned by exactly one selector). + """ + seen: Counter = Counter() + lock = threading.Lock() + n_requests = 5 + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=4, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def hammer_one_socket() -> list[bytes]: + with socket.create_connection(("127.0.0.1", free_port), timeout=5.0) as sock: + out = [] + for _ in range(n_requests): + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + out.append(read_http_response(sock, deadline=5.0)) + return out + + responses = await to_thread.run_sync(hammer_one_socket) + for r in responses: + assert b"HTTP/1.1 200" in r, r + + lt.shutdown() + await lt.stopped + + # All N requests landed on the same worker selector thread. + assert len(seen) == 1, seen + assert next(iter(seen.values())) == n_requests, seen + + +class TestShutdownReleasesPort: + async def test_port_re_bindable_after_shutdown(self, free_port): + """After ``lt.stopped``, the listening socket is fully released — the + same port can be re-bound immediately. This is a stronger leak check + than ``exit_code == 0``: it proves the kernel-level fd is freed.""" + seen: Counter = Counter() + lock = threading.Lock() + + cfg = ServerConfig(host="127.0.0.1", port=free_port) + async with serve( + http_server(cfg, _capturing_handler(seen, lock), selectors=2, acceptor=True), + ) as lt: + await lt.started + await to_thread.run_sync(_wait_ready, free_port) + + def fire() -> bytes: + return _request_close(free_port) + + for _ in range(3): + resp = await to_thread.run_sync(fire) + assert b"HTTP/1.1 200" in resp, resp + + lt.shutdown() + await lt.stopped + + assert lt.exit_code == 0 + + # Re-bind the same port to confirm the listening fd is released. + # ``SO_REUSEADDR`` clears noise from per-conn sockets that are still + # in ``TIME_WAIT`` (server actively closed them via ``Connection: + # close``); it does NOT bypass an active bind, so the test still + # fails if we leaked the listening fd. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind(("127.0.0.1", free_port)) + probe.listen(1) From 0f249356fb1a157726bb4183065fffe7c92450c2 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 01:33:48 +0400 Subject: [PATCH 158/286] test(http): consolidate selectors=4, cover acceptor + httptools / pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench and tests previously had a mix of selectors=2 / 3 / 4 across the multi-selector test suite. Standardize on selectors=4 as the canonical "many" — the only correctness questions are "does multi-selector work" and "does shutdown reach all threads", both answered the same way at s=2, s=3, or s=4. (Stress suite in acceptor_stress.py keeps a single s=2 case where the smaller worker count is load-bearing for the port- release check.) Add two acceptor-topology gaps: - test_serves_requests_httptools: the cross-thread handoff (Selector.post_track) is parser-agnostic at the type level, but httptools constructs the parser on the acceptor thread and drives it on the worker. Smoke-test that the parser instance survives the move. - test_serves_requests_pooled: pool dispatch routes through ctx.conn.selector.stop_tracking — in acceptor mode that selector is the *worker*, not the acceptor. Catches any regression where conn.selector is misaligned. Extends _serve_pooled helper with selectors / acceptor kwargs. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/service.py | 83 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/tests/http/service.py b/tests/http/service.py index 93bb7b8..5d2b3c1 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -46,13 +46,15 @@ async def _serve_pooled( *, max_concurrency: int, backlog: int = 0, + selectors: int = 1, + acceptor: bool = False, ) -> AsyncGenerator[ServiceLifetimeView]: """Compose ``thread_pool_handler`` + ``http_server`` and yield the http_server lifetime view. Tests own shutdown via the yielded lifetime; the pool drains on exit. """ async with thread_pool_handler(handler, max_concurrency=max_concurrency, backlog=backlog) as wrapped: - async with serve(http_server(cfg, wrapped)) as lt: + async with serve(http_server(cfg, wrapped, selectors=selectors, acceptor=acceptor)) as lt: yield lt @@ -487,10 +489,10 @@ async def test_invalid_selectors(self, free_port): http_server(cfg, _handler_200(), selectors=0) async def test_serves_requests_inline(self, free_port): - """selectors=2, no thread pool. Each request runs on whichever - selector accepted it; both must serve correctly.""" + """selectors=4, no thread pool. Each request runs on whichever + selector accepted it; all must serve correctly.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(http_server(cfg, _handler_200(b"hi"), selectors=2)) as lt: + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4)) as lt: await lt.started await _wait_server_ready(free_port) @@ -504,13 +506,13 @@ async def test_serves_requests_inline(self, free_port): assert lt.exit_code == 0 async def test_serves_requests_pooled_concurrent(self, free_port): - """selectors=2 + thread pool. Multiple selectors push onto the - single shared channel; the pool drains across both producers.""" + """selectors=4 + thread pool. Multiple selectors push onto the + single shared channel; the pool drains across all producers.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) # backlog=8: with 10 fast requests against max_concurrency=4, headroom keeps the # rendezvous race from intermittently 503'ing late arrivals during the test. async with thread_pool_handler(_handler_200(b"hi"), max_concurrency=4, backlog=8) as wrapped: - async with serve(http_server(cfg, wrapped, selectors=2)) as lt: + async with serve(http_server(cfg, wrapped, selectors=4)) as lt: await lt.started await _wait_server_ready(free_port) @@ -536,7 +538,7 @@ async def test_shutdown_stops_all_selectors(self, free_port): """All N selector threads must exit on shutdown — no leaked threads and a clean exit code.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(http_server(cfg, _handler_200(b"x"), selectors=3)) as lt: + async with serve(http_server(cfg, _handler_200(b"x"), selectors=4)) as lt: await lt.started await _wait_server_ready(free_port) resp = await _get(f"http://127.0.0.1:{free_port}/") @@ -616,7 +618,7 @@ async def fire(i: int) -> None: async def test_shutdown_clean(self, free_port): """Acceptor + workers shut down cleanly on lt.shutdown().""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with serve(http_server(cfg, _handler_200(b"x"), selectors=3, acceptor=True)) as lt: + async with serve(http_server(cfg, _handler_200(b"x"), selectors=4, acceptor=True)) as lt: await lt.started await _wait_server_ready(free_port) resp = await _get(f"http://127.0.0.1:{free_port}/") @@ -625,6 +627,69 @@ async def test_shutdown_clean(self, free_port): await lt.stopped assert lt.exit_code == 0 + async def test_serves_requests_httptools(self, free_port): + """Acceptor + httptools backend. + + The cross-thread handoff (``_OpTrack`` → ``Selector.post_track``) is + parser-agnostic at the type level, but httptools' push-callback model + means the parser is *constructed* on the acceptor thread (inside + ``RoundRobinAcceptor.__call__``) and *driven* on the worker thread. + Smoke-tests that the parser instance survives the move. + """ + cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") + async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4, acceptor=True)) as lt: + await lt.started + await _wait_server_ready(free_port) + + for _ in range(8): + resp = await _get(f"http://127.0.0.1:{free_port}/") + assert resp.status_code == 200 + assert resp.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + + async def test_serves_requests_pooled(self, free_port): + """Acceptor + thread_pool_handler — most production-like cell. + + Pool dispatch routes through ``ctx.conn.selector.stop_tracking(ctx.conn)``; + in acceptor mode that selector is the **worker**, not the acceptor. + Catches any regression where ``conn.selector`` is misaligned with the + actual owning selector. + """ + cfg = ServerConfig(host="127.0.0.1", port=free_port) + # backlog=8 keeps the rendezvous race from intermittently 503'ing late + # arrivals while the pool has spare capacity. + async with _serve_pooled( + cfg, + _handler_200(b"hi"), + max_concurrency=4, + backlog=8, + selectors=4, + acceptor=True, + ) as lt: + await lt.started + await _wait_server_ready(free_port) + + results: list[httpx.Response | None] = [None] * 10 + + async def fire(i: int) -> None: + results[i] = await _get(f"http://127.0.0.1:{free_port}/") + + async with anyio.create_task_group() as tg: + for i in range(10): + tg.start_soon(fire, i) + + for r in results: + assert r is not None + assert r.status_code == 200 + assert r.text == "hi" + + lt.shutdown() + await lt.stopped + assert lt.exit_code == 0 + class TestSelectorThreadFastPath: """When the Router is passed directly to ``http_server`` (no ``thread_pool_handler`` From e20a00d067996721522ff8624e90dd82c72f835d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 01:34:13 +0400 Subject: [PATCH 159/286] bench(http): add acceptor stacks, drop redundant s=2 wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new acceptor=True topology (1 acceptor thread + N worker selectors) shipped without bench coverage. Adds three rows to the matrix and trims three redundant s=2 wrappers — net zero stacks, better axis spread. Drop: - localpost_native_s2 - localpost_httptools_s2 - localpost_httptools_inline_s2 Add: - localpost_native_acceptor_s4 - localpost_httptools_acceptor_s4 - localpost_httptools_inline_acceptor_s4 Per-Stack changes wire the acceptor flag end-to-end: - HttpApp.service gains an acceptor=False kwarg that forwards to http_server. Required for bench wrappers, equally useful for any user wanting the topology under the framework path. - benchmarks/http/apps/_cli.py adds --acceptor; the three localpost app modules pass it through. - Stack dataclass gains an acceptor: bool field; runner.Cell mirrors it so results.json is self-describing for the HTML reporter, which gains an acceptor filter dropdown + column. The remaining s=2 case in tests/http/acceptor_stress.py is intentional (smallest non-trivial worker count for the port-release check) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/_render_html.py | 13 ++++-- benchmarks/http/apps/_cli.py | 9 ++-- benchmarks/http/apps/localpost_httptools.py | 2 +- .../apps/localpost_httptools_acceptor_s4.py | 17 +++++++ .../http/apps/localpost_httptools_inline.py | 2 +- .../localpost_httptools_inline_acceptor_s4.py | 17 +++++++ .../apps/localpost_httptools_inline_s2.py | 11 ----- .../http/apps/localpost_httptools_s2.py | 15 ------- benchmarks/http/apps/localpost_native.py | 2 +- .../http/apps/localpost_native_acceptor_s4.py | 17 +++++++ benchmarks/http/apps/localpost_native_s2.py | 15 ------- benchmarks/http/runner.py | 2 + benchmarks/http/stacks.py | 45 ++++++++++++++++--- localpost/http/app.py | 8 +++- 14 files changed, 116 insertions(+), 59 deletions(-) create mode 100644 benchmarks/http/apps/localpost_httptools_acceptor_s4.py create mode 100644 benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py delete mode 100644 benchmarks/http/apps/localpost_httptools_inline_s2.py delete mode 100644 benchmarks/http/apps/localpost_httptools_s2.py create mode 100644 benchmarks/http/apps/localpost_native_acceptor_s4.py delete mode 100644 benchmarks/http/apps/localpost_native_s2.py diff --git a/benchmarks/http/_render_html.py b/benchmarks/http/_render_html.py index 2a170df..83ce966 100644 --- a/benchmarks/http/_render_html.py +++ b/benchmarks/http/_render_html.py @@ -99,6 +99,11 @@ def render_html(report: dict[str, Any]) -> str: + @@ -142,7 +147,7 @@ def render_html(report: dict[str, Any]) -> str: return cells .filter(c => c.scenario === scenario) .map(c => [ - c.stack, c.app, c.backend, c.selectors, c.pool ? 'on' : 'off', + c.stack, c.app, c.backend, c.selectors, c.pool ? 'on' : 'off', c.acceptor ? 'on' : 'off', Math.round(c.rps), +c.p50_ms.toFixed(2), +c.p90_ms.toFixed(2), +c.p99_ms.toFixed(2), c.status_2xx, c.status_other, ]); @@ -161,6 +166,7 @@ def render_html(report: dict[str, Any]) -> str: {{ name: 'backend' }}, {{ name: 'sel' }}, {{ name: 'pool' }}, + {{ name: 'acc' }}, {{ name: 'RPS', sort: {{ compare: (a, b) => a - b }} }}, {{ name: 'p50 (ms)' }}, {{ name: 'p90 (ms)' }}, @@ -178,7 +184,7 @@ def render_html(report: dict[str, Any]) -> str: }} // --- top-level filter dropdowns rebuild grids in place - const filterIds = ['app', 'backend', 'selectors', 'pool', 'scenario']; + const filterIds = ['app', 'backend', 'selectors', 'pool', 'acceptor', 'scenario']; function currentFilters() {{ const f = {{}}; for (const k of filterIds) {{ @@ -202,11 +208,12 @@ def render_html(report: dict[str, Any]) -> str: if (f.backend && c.backend !== f.backend) return false; if (f.selectors && String(c.selectors) !== f.selectors) return false; if (f.pool && (c.pool ? 'true' : 'false') !== f.pool) return false; + if (f.acceptor && (c.acceptor ? 'true' : 'false') !== f.acceptor) return false; return true; }}); grids[scenario].updateConfig({{ data: filtered.map(c => [ - c.stack, c.app, c.backend, c.selectors, c.pool ? 'on' : 'off', + c.stack, c.app, c.backend, c.selectors, c.pool ? 'on' : 'off', c.acceptor ? 'on' : 'off', Math.round(c.rps), +c.p50_ms.toFixed(2), +c.p90_ms.toFixed(2), +c.p99_ms.toFixed(2), c.status_2xx, c.status_other, ]), diff --git a/benchmarks/http/apps/_cli.py b/benchmarks/http/apps/_cli.py index 00b83b1..e7e4cb7 100644 --- a/benchmarks/http/apps/_cli.py +++ b/benchmarks/http/apps/_cli.py @@ -2,7 +2,8 @@ Each app reads a ``--port`` (default 8000) and binds 127.0.0.1. LocalPost apps additionally accept ``--selectors`` (default 1) for multi-selector -mode. +mode and ``--acceptor`` to switch to the dedicated-acceptor topology +(1 acceptor thread + N worker selectors). """ from __future__ import annotations @@ -21,12 +22,14 @@ def parse_port(default: int = 8000) -> int: class AppArgs: port: int selectors: int + acceptor: bool def parse_args(default_port: int = 8000) -> AppArgs: - """Port + selectors. Used by LocalPost bench apps.""" + """Port + selectors + acceptor. Used by LocalPost bench apps.""" p = argparse.ArgumentParser() p.add_argument("--port", type=int, default=default_port) p.add_argument("--selectors", type=int, default=1) + p.add_argument("--acceptor", action="store_true") a = p.parse_args() - return AppArgs(port=a.port, selectors=a.selectors) + return AppArgs(port=a.port, selectors=a.selectors, acceptor=a.acceptor) diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index 7ecb698..19d41f7 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -68,7 +68,7 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): _ = (ping, hello, echo, profile_update) cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") - return run_app(app.service(cfg, selectors=args.selectors)) + return run_app(app.service(cfg, selectors=args.selectors, acceptor=args.acceptor)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_httptools_acceptor_s4.py b/benchmarks/http/apps/localpost_httptools_acceptor_s4.py new file mode 100644 index 0000000..ed2006f --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_acceptor_s4.py @@ -0,0 +1,17 @@ +"""LocalPost httptools backend, acceptor topology with ``selectors=4``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_httptools` that injects +``--selectors 4 --acceptor`` so the runner can pick it up as a separate +stack. One acceptor thread + four worker selectors via the cross-thread +op queue. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_httptools import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4", "--acceptor"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index 8f49cec..1a52c96 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -53,7 +53,7 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): _ = (ping, hello, echo, profile_update) cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="httptools") - return run_app(app.service(cfg, selectors=args.selectors)) + return run_app(app.service(cfg, selectors=args.selectors, acceptor=args.acceptor)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py b/benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py new file mode 100644 index 0000000..40b2cc4 --- /dev/null +++ b/benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py @@ -0,0 +1,17 @@ +"""LocalPost httptools backend, inline (no pool), acceptor topology with ``selectors=4``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_httptools_inline` that +injects ``--selectors 4 --acceptor`` so the runner can pick it up as a +separate stack. Characterises the acceptor topology in isolation — +handlers run on the worker selector thread without a pool hop. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_httptools_inline import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4", "--acceptor"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_inline_s2.py b/benchmarks/http/apps/localpost_httptools_inline_s2.py deleted file mode 100644 index 742f9b5..0000000 --- a/benchmarks/http/apps/localpost_httptools_inline_s2.py +++ /dev/null @@ -1,11 +0,0 @@ -"""LocalPost httptools backend, inline (no pool), ``selectors=2``.""" - -from __future__ import annotations - -import sys - -from benchmarks.http.apps.localpost_httptools_inline import main - -if __name__ == "__main__": - sys.argv += ["--selectors", "2"] - sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_httptools_s2.py b/benchmarks/http/apps/localpost_httptools_s2.py deleted file mode 100644 index bae3aef..0000000 --- a/benchmarks/http/apps/localpost_httptools_s2.py +++ /dev/null @@ -1,15 +0,0 @@ -"""LocalPost httptools backend with ``selectors=2``. - -Wrapper around :mod:`benchmarks.http.apps.localpost_httptools` that injects -``--selectors 2`` so the runner can pick it up as a separate stack. -""" - -from __future__ import annotations - -import sys - -from benchmarks.http.apps.localpost_httptools import main - -if __name__ == "__main__": - sys.argv += ["--selectors", "2"] - sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_native.py index 25fd52c..3e59a92 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_native.py @@ -49,7 +49,7 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): _ = (ping, hello, echo, profile_update) cfg = ServerConfig(host="127.0.0.1", port=args.port, backend="h11") - return run_app(app.service(cfg, selectors=args.selectors)) + return run_app(app.service(cfg, selectors=args.selectors, acceptor=args.acceptor)) if __name__ == "__main__": diff --git a/benchmarks/http/apps/localpost_native_acceptor_s4.py b/benchmarks/http/apps/localpost_native_acceptor_s4.py new file mode 100644 index 0000000..13f1123 --- /dev/null +++ b/benchmarks/http/apps/localpost_native_acceptor_s4.py @@ -0,0 +1,17 @@ +"""LocalPost h11 backend, acceptor topology with ``selectors=4``. + +Wrapper around :mod:`benchmarks.http.apps.localpost_native` that injects +``--selectors 4 --acceptor`` so the runner can pick it up as a separate +stack. One acceptor thread + four worker selectors via the cross-thread +op queue. +""" + +from __future__ import annotations + +import sys + +from benchmarks.http.apps.localpost_native import main + +if __name__ == "__main__": + sys.argv += ["--selectors", "4", "--acceptor"] + sys.exit(main()) diff --git a/benchmarks/http/apps/localpost_native_s2.py b/benchmarks/http/apps/localpost_native_s2.py deleted file mode 100644 index ab860c6..0000000 --- a/benchmarks/http/apps/localpost_native_s2.py +++ /dev/null @@ -1,15 +0,0 @@ -"""LocalPost h11 backend with ``selectors=2``. - -Wrapper around :mod:`benchmarks.http.apps.localpost_native` that injects -``--selectors 2`` so the runner can pick it up as a separate stack. -""" - -from __future__ import annotations - -import sys - -from benchmarks.http.apps.localpost_native import main - -if __name__ == "__main__": - sys.argv += ["--selectors", "2"] - sys.exit(main()) diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index ea463fb..582f47d 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -79,6 +79,7 @@ class Cell: backend: str = "" selectors: int = 1 pool: bool = True + acceptor: bool = False tags: list[str] = field(default_factory=list) @@ -205,6 +206,7 @@ def _run_cell(stack: Stack, scenario: Scenario, port: int, duration_s: int, pyth backend=stack.backend, selectors=stack.selectors, pool=stack.pool, + acceptor=stack.acceptor, tags=sorted(stack.tags), **parsed, ) diff --git a/benchmarks/http/stacks.py b/benchmarks/http/stacks.py index 20837c4..472c51b 100644 --- a/benchmarks/http/stacks.py +++ b/benchmarks/http/stacks.py @@ -2,7 +2,7 @@ Each stack is one row of the matrix. Rather than a flat tuple of names, each stack carries explicit dimension fields (``app``, ``backend``, ``selectors``, -``pool``, ``tags``). The runner uses these to: +``pool``, ``acceptor``, ``tags``). The runner uses these to: * select a subset for a run via ``--filter`` / ``--group`` / ``--stacks``; * annotate each cell in ``results.json`` so the HTML reporter can pivot. @@ -16,7 +16,7 @@ * ``key=a,b`` — comma list inside a key is OR. * ``key=lp-*`` — glob via ``*`` / ``?`` (fnmatch). * ``key!=value`` — negation (combines with comma + glob). -* Keys: ``app``, ``backend``, ``selectors``, ``pool``, ``tags``, ``name``. +* Keys: ``app``, ``backend``, ``selectors``, ``pool``, ``acceptor``, ``tags``, ``name``. """ from __future__ import annotations @@ -39,25 +39,52 @@ class Stack: """``lp-h11`` | ``lp-httptools`` | ``cheroot`` | ``gunicorn`` | ``granian`` | ``uvicorn``.""" selectors: int - """1 | 2 | 4. Always 1 for non-LocalPost backends.""" + """1 (single thread) | 4 (multi). Always 1 for non-LocalPost backends.""" pool: bool """``False`` = handlers run inline on the selector thread.""" + acceptor: bool = False + """``True`` = LocalPost acceptor topology (1 acceptor thread + N worker + selectors via cross-thread op queue). Default ``False`` mirrors the + ``selectors=N`` ``SO_REUSEPORT`` shape and applies to non-LocalPost + backends as well.""" + tags: frozenset[str] = field(default_factory=frozenset) """Free-form labels, e.g. ``reference``.""" STACKS: Final[tuple[Stack, ...]] = ( Stack("localpost_native", app="native", backend="lp-h11", selectors=1, pool=True), - Stack("localpost_native_s2", app="native", backend="lp-h11", selectors=2, pool=True), Stack("localpost_native_s4", app="native", backend="lp-h11", selectors=4, pool=True), + Stack( + "localpost_native_acceptor_s4", + app="native", + backend="lp-h11", + selectors=4, + pool=True, + acceptor=True, + ), Stack("localpost_httptools", app="native", backend="lp-httptools", selectors=1, pool=True), - Stack("localpost_httptools_s2", app="native", backend="lp-httptools", selectors=2, pool=True), Stack("localpost_httptools_s4", app="native", backend="lp-httptools", selectors=4, pool=True), + Stack( + "localpost_httptools_acceptor_s4", + app="native", + backend="lp-httptools", + selectors=4, + pool=True, + acceptor=True, + ), Stack("localpost_httptools_inline", app="native", backend="lp-httptools", selectors=1, pool=False), - Stack("localpost_httptools_inline_s2", app="native", backend="lp-httptools", selectors=2, pool=False), Stack("localpost_httptools_inline_s4", app="native", backend="lp-httptools", selectors=4, pool=False), + Stack( + "localpost_httptools_inline_acceptor_s4", + app="native", + backend="lp-httptools", + selectors=4, + pool=False, + acceptor=True, + ), Stack("localpost_wsgi", app="wsgi", backend="lp-h11", selectors=1, pool=True), Stack("localpost_flask", app="flask", backend="lp-h11", selectors=1, pool=True), Stack("flask_cheroot", app="flask", backend="cheroot", selectors=1, pool=True), @@ -95,7 +122,9 @@ class Stack: } -_VALID_KEYS: Final[frozenset[str]] = frozenset({"app", "backend", "selectors", "pool", "tags", "name"}) +_VALID_KEYS: Final[frozenset[str]] = frozenset( + {"app", "backend", "selectors", "pool", "acceptor", "tags", "name"} +) @dataclass(frozen=True, slots=True) @@ -141,6 +170,8 @@ def _stack_field(stack: Stack, key: str) -> tuple[str, ...]: return (str(stack.selectors),) if key == "pool": return ("true" if stack.pool else "false",) + if key == "acceptor": + return ("true" if stack.acceptor else "false",) return (str(getattr(stack, key)),) diff --git a/localpost/http/app.py b/localpost/http/app.py index e9383a9..98c55e0 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -356,12 +356,16 @@ def service( config: ServerConfig, *, selectors: int = 1, + acceptor: bool = False, ): """Return a :func:`localpost.hosting.service` that runs the app. Composes worker pool + the HTTP server (backend selected via :attr:`ServerConfig.backend`). Use with :func:`localpost.hosting.run_app` or :func:`localpost.hosting.serve`. + + ``selectors`` and ``acceptor`` forward to :func:`http_server` — see + its docstring for the full topology rules. """ max_concurrency = self.max_concurrency backlog = self.backlog @@ -370,12 +374,12 @@ def service( async def _app_service(): if max_concurrency == 0: inner = self._build_router_handler(None) - async with http_server(config, inner, selectors=selectors): + async with http_server(config, inner, selectors=selectors, acceptor=acceptor): yield return async with _pool_context(max_concurrency, backlog) as pool: inner = self._build_router_handler(pool) - async with http_server(config, inner, selectors=selectors): + async with http_server(config, inner, selectors=selectors, acceptor=acceptor): yield return _app_service() From 24cf8051900e7fde9e17d438a5d3441755b4683a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 01:56:03 +0400 Subject: [PATCH 160/286] =?UTF-8?q?docs(perf):=20write=20up=20Phase=2012?= =?UTF-8?q?=20=E2=80=94=20acceptor=20topology=20bench=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Phase 12 to PERF_FINDINGS.md documenting the 6s/cell macOS arm64 bench across CPython 3.13 and 3.14t. The story splits along the GIL axis: - 3.13 (GIL): acceptor is flat-to-slightly-worse than the s=1 baseline (h11 −2 %, httptools −13 %). Cross-thread op-queue dispatch costs more than it saves when parser callbacks + dispatch + response-write all serialise on the GIL. - 3.14t (free-threaded): acceptor + pool wins +33 % on h11 and +10 % on httptools over s=1, and crucially **+30 % on h11 over the SO_REUSEPORT alternative** (native_acceptor_s4 26,684 vs native_s4 20,604) — the exact case the topology was added for per Phase 7b's finding. - acceptor + inline (no pool) is a footgun on the JSON-API common case (−40 % under both runtimes) but the right tool for I/O-bound handlers — profile_update shows ~4× throughput, the theoretical max for 4 workers, vs SO_REUSEPORT pinning everything to one. Also reverses Phase 7's "Accept-dispatch alternative — explicitly dropped" bullet in the *What's left* section: the topology shipped in commit 8748c66 (Selector / ConnHandler split) and the macOS gap is closed. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/PERF_FINDINGS.md | 150 +++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 7 deletions(-) diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 032e56e..75957d0 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -1053,6 +1053,145 @@ built a ``Request`` directly are updated. User code using there; revisit if a future profile shows ``name.lower()`` alloc cost climbing. +## Phase 12 (shipped): acceptor topology — 1 acceptor + N worker selectors (2026-05-02) + +Reverses the call made in Phase 7's *"What's left"* (and the earlier +"Accept-dispatch alternative — explicitly dropped"). Phase 7b made the case +for it concrete: free-threaded scaling needs N busy selector threads, and on +macOS `SO_REUSEPORT` puts every conn on one. The acceptor topology adds a +single dedicated acceptor thread that round-robins each accepted conn onto N +worker selectors via the existing op queue + wakeup pipe. + +### What shipped + +The architectural enabler is the *Selector / ConnHandler* split (commit +`8748c66`) — `BaseServer` now composes a dumb fd→callback `Selector` with a +`ConnHandler` (after-accept policy): + +``` +Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ +ConnHandler ── after-accept policy; default: track on the accepting selector; + │ acceptor mode: post_track to a worker selector + ▼ +RequestHandler ── pre-body dispatch + │ + ▼ +BodyHandler ── post-body continuation +``` + +`http_server` / `wsgi_server` / `HttpApp.service` each gained an +`acceptor: bool = False` knob. With `acceptor=True, selectors=N`: +one thread runs the acceptor `BaseServer` (listen socket only, no conn +tracking), N threads each run a worker `Selector` (no listen socket); a +`RoundRobinAcceptor` `ConnHandler` builds each new conn for the next worker +and calls `worker.post_track(conn)` (cross-thread `_OpTrack` on the existing +op queue from Phase 3-B). The handoff path was already cross-thread-safe — +`ConnHandler` just gives it a stable name. + +### Bench (6 s/cell, Darwin arm64, two interpreters) + +The story splits cleanly along the GIL axis. Numbers: `localpost` group +only, plaintext scenario. + +**Standard CPython 3.13 (GIL):** + +| Stack | RPS | vs `s=1` baseline | +| ----------------------------------------- | -----: | ----------------: | +| `localpost_native` (s=1, pool) | 12,504 | — | +| `localpost_native_s4` (SO_REUSEPORT) | 12,812 | +2% | +| `localpost_native_acceptor_s4` | 12,215 | −2% | +| `localpost_httptools` (s=1, pool) | 24,356 | — | +| `localpost_httptools_s4` | 24,598 | +1% | +| `localpost_httptools_acceptor_s4` | 21,207 | **−13%** | + +Under the GIL the topology is flat-to-slightly-worse. The cross-thread +op-queue dispatch costs more than it saves: parser callbacks + dispatch + +response-write all serialise on the GIL, so spreading the readable-event +work across N threads buys nothing, and pays one extra wakeup-pipe write per +conn for the privilege. + +**Free-threaded CPython 3.14t:** + +| Stack | RPS | vs `s=1` baseline | +| ----------------------------------------- | -----: | ----------------: | +| `localpost_native` (s=1, pool) | 20,033 | — | +| `localpost_native_s4` (SO_REUSEPORT) | 20,604 | +3% | +| **`localpost_native_acceptor_s4`** | **26,684** | **+33%** | +| `localpost_httptools` (s=1, pool) | 16,262 | — | +| `localpost_httptools_s4` | 15,973 | −2% | +| **`localpost_httptools_acceptor_s4`** | **17,958** | **+10%** | + +The headline result on h11: `native_acceptor_s4` does **26,684 vs `native_s4`'s +20,604 = +30% over the SO_REUSEPORT alternative**. This is precisely the +case the topology was added for — Phase 7b confirmed `SO_REUSEPORT` puts +100 % of accepts on one selector on macOS; the explicit round-robin replaces +that with a real 4-way spread. httptools shows a smaller gain because its +hot path is shorter, so the per-conn dispatch overhead occupies a larger +share of the budget. + +### Footgun: `acceptor + inline` (no pool) + +The wrong combination on the JSON-API common case: + +| Stack (3.14t) | RPS | vs inline s=1 | +| ------------------------------------------------- | -----: | ------------: | +| `localpost_httptools_inline` (s=1) | 73,536 | — | +| `localpost_httptools_inline_s4` (SO_REUSEPORT) | 74,033 | +1% | +| `localpost_httptools_inline_acceptor_s4` | 43,998 | **−40%** | + +With no pool, the handler runs on the worker selector thread, and the accept +→ cross-thread `_OpTrack` hop is pure overhead — there's no parallelism to +amortise it against. **`acceptor=True` should be paired with +`thread_pool_handler`** for the JSON-API shape; documented on the +`http_server` docstring. + +### When `acceptor + inline` is *right*: I/O-bound handlers + +The inline + acceptor combination *does* win when the handler genuinely +parallelises (e.g. blocks on I/O). The `profile_update` scenario has a +deliberate `time.sleep` totalling ~4 ms per request: + +| Stack (3.14t) | RPS | Notes | +| ------------------------------------------------- | ---: | -------------------------------- | +| `localpost_httptools_inline_acceptor_s4` | 647 | round-robin across 4 selectors | +| `localpost_httptools_inline_s4` (SO_REUSEPORT) | 164 | piled on one — same as s=1 | +| `localpost_httptools_inline` (s=1) | 164 | bound by single-thread sleep | + +**~4× throughput** — the theoretical max for 4 workers. The +`SO_REUSEPORT` variant offers no improvement because the kernel pinned every +conn to the same selector (Phase 7b finding); explicit round-robin is what +unlocks the parallelism. + +### What this validates + +- **Free-threading is now the supported fast path** (already true since + Phase 7b). The acceptor topology compounds it: +30 % over `SO_REUSEPORT` + on h11, +10 % on httptools, on the macOS dev box where `SO_REUSEPORT` + doesn't kernel-balance. +- **The Phase 7 `selectors > 1` knob is still correct on Linux.** This + Phase didn't bench Linux; the documented expectation (`SO_REUSEPORT` + distributes there since 3.9) stands. Acceptor mode is the macOS / free- + threaded option, not a Linux replacement. +- **The Selector / ConnHandler split is the right factoring.** The acceptor + topology fell out of `_service.py` in ~80 lines, with no changes to + `_base.py`'s parser-driven hot path. Future "where does this conn live" + decisions (per-host pinning, conn-shedding, deterministic-routing) are + the same shape — a different `ConnHandler`. + +### Trade-offs + +- **One more thread.** `acceptor=True, selectors=N` runs **N+1** OS threads, + not N. The acceptor thread does only `accept()` + a cross-thread enqueue; + it's nearly idle relative to the workers, but the cost is real on + thread-budget-sensitive deployments (e.g. ulimit-constrained containers). +- **Pin point per conn.** A conn is round-robined exactly once on accept + and lives on its assigned worker selector for its full keep-alive + lifetime. No re-balancing if a worker gets a hot client. Two consequences: + long-lived keep-alive conns can drift out of balance; the parser-handoff + invariant (single owner per parser instance) is preserved by design. + ## What's left for future perf work Within the [Optimisation boundaries](#optimisation-boundaries) at the top of @@ -1063,13 +1202,10 @@ this doc: 3.9). Untested locally because dev is macOS-only; settle it on the first Linux bench cell — no code change needed. Real deployments are ~99% Linux, so this is the actual perf focus, not the macOS dev-box - result above. -- **Accept-dispatch alternative — explicitly dropped.** A - one-acceptor + N-selector design (dispatching new conns via the - existing Phase 3-B op queue) would close the macOS multi-selector - gap, but adds an in-process accept hop on Linux where the kernel - already distributes for free. Not worth the maintenance cost given - the deployment target. + result above. The macOS `SO_REUSEPORT` gap is now closed by the + acceptor topology (Phase 12); a Linux bench should also tell us + whether `acceptor=True` is worth using *over* `SO_REUSEPORT` on + free-threaded Linux, or whether kernel distribution dominates. - **Worker-side syscall coalescing.** One ``sendall`` per response (status + headers + body in a single ``bytearray``); ``socket.sendmsg`` for chunked responses (status + first chunk + terminator in one syscall). From ede306ed247cb2d561ff4bee96cab0a7927f1f33 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 13:25:23 +0400 Subject: [PATCH 161/286] =?UTF-8?q?bench(http):=20rename=20localpost=5Fnat?= =?UTF-8?q?ive=20=E2=86=92=20localpost=5Fh11=20(parser=20explicit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old name was confusing — "native" doubled as "default LocalPost framework" and "default backend (h11)", but every LocalPost stack here uses the same HttpApp framework, so the name carried no information that wasn't already implicit. Sitting next to localpost_httptools in the matrix made the asymmetry worse: one name spelled the parser, one didn't. Rename: - localpost_native → localpost_h11 - localpost_native_s4 → localpost_h11_s4 - localpost_native_acceptor_s4 → localpost_h11_acceptor_s4 Now every parser-named stack has a parser in its name. WSGI / Flask stacks keep their framework-named labels (the default backend stays h11 for both, documented in their app modules). Updates: - benchmarks/http/apps/*: file rename via git mv + import paths - benchmarks/http/stacks.py: Stack(...) names - benchmarks/http/runner.py: docstring example - benchmarks/README.md: usage example + stack table (also fills in the table — it was stale, listed only one of the LocalPost stacks) - tests/benchmarks/http_stacks.py: expected name assertion - benchmarks/http/PERF_FINDINGS.md: Phase 12 tables and prose use the new name; a quoted note in Phase 12 explains the rename for archive-readers; Phases 1–11 keep `localpost_native` for historical accuracy (those tables come from runs before the rename). Archived RESULTS.md files are intentionally untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/README.md | 4 ++-- benchmarks/http/PERF_FINDINGS.md | 24 ++++++++++++------- .../{localpost_native.py => localpost_h11.py} | 2 +- ...tor_s4.py => localpost_h11_acceptor_s4.py} | 4 ++-- ...lpost_native_s4.py => localpost_h11_s4.py} | 4 ++-- benchmarks/http/apps/localpost_httptools.py | 4 ++-- benchmarks/http/runner.py | 2 +- benchmarks/http/stacks.py | 6 ++--- tests/benchmarks/http_stacks.py | 2 +- 9 files changed, 30 insertions(+), 22 deletions(-) rename benchmarks/http/apps/{localpost_native.py => localpost_h11.py} (96%) rename benchmarks/http/apps/{localpost_native_acceptor_s4.py => localpost_h11_acceptor_s4.py} (74%) rename benchmarks/http/apps/{localpost_native_s4.py => localpost_h11_s4.py} (66%) diff --git a/benchmarks/README.md b/benchmarks/README.md index 8548dce..2e2f53e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -19,7 +19,7 @@ brew install oha # one-time prereq just bench-http # faster sanity sweep -just bench-http --duration 5 --stacks localpost_native,flask_gunicorn +just bench-http --duration 5 --stacks localpost_h11,flask_gunicorn # micro (pytest-benchmark) just bench-micro @@ -31,7 +31,7 @@ Three "app types", each implementing the same four scenarios: | App type | Stacks | |------------|-------------------------------------------------------------------------------------------| -| Native | `localpost_native` | +| Native | `localpost_h11`, `localpost_httptools`, plus their `_s4` (`SO_REUSEPORT`) and `_acceptor_s4` variants; `localpost_httptools_inline` (no pool) and its multi-selector variants | | WSGI/Flask | `localpost_wsgi`, `localpost_flask`, `flask_cheroot`, `flask_gunicorn`, `flask_granian` | | ASGI | `starlette_uvicorn`, `starlette_granian` | diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/http/PERF_FINDINGS.md index 75957d0..f95468b 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/http/PERF_FINDINGS.md @@ -1090,6 +1090,14 @@ and calls `worker.post_track(conn)` (cross-thread `_OpTrack` on the existing op queue from Phase 3-B). The handoff path was already cross-thread-safe — `ConnHandler` just gives it a stable name. +> **Stack rename in this phase.** `localpost_native` → `localpost_h11` +> (and its `_s4` / `_acceptor_s4` variants). The old name conflated +> "default LocalPost framework" with "default backend" — confusing once +> `localpost_httptools` sat next to it in the matrix. Earlier-phase tables +> in this document still reference `localpost_native` for historical +> accuracy; from this phase onward the parser is explicit on every stack +> name (`_h11` / `_httptools`). + ### Bench (6 s/cell, Darwin arm64, two interpreters) The story splits cleanly along the GIL axis. Numbers: `localpost` group @@ -1099,9 +1107,9 @@ only, plaintext scenario. | Stack | RPS | vs `s=1` baseline | | ----------------------------------------- | -----: | ----------------: | -| `localpost_native` (s=1, pool) | 12,504 | — | -| `localpost_native_s4` (SO_REUSEPORT) | 12,812 | +2% | -| `localpost_native_acceptor_s4` | 12,215 | −2% | +| `localpost_h11` (s=1, pool) | 12,504 | — | +| `localpost_h11_s4` (SO_REUSEPORT) | 12,812 | +2% | +| `localpost_h11_acceptor_s4` | 12,215 | −2% | | `localpost_httptools` (s=1, pool) | 24,356 | — | | `localpost_httptools_s4` | 24,598 | +1% | | `localpost_httptools_acceptor_s4` | 21,207 | **−13%** | @@ -1116,15 +1124,15 @@ conn for the privilege. | Stack | RPS | vs `s=1` baseline | | ----------------------------------------- | -----: | ----------------: | -| `localpost_native` (s=1, pool) | 20,033 | — | -| `localpost_native_s4` (SO_REUSEPORT) | 20,604 | +3% | -| **`localpost_native_acceptor_s4`** | **26,684** | **+33%** | +| `localpost_h11` (s=1, pool) | 20,033 | — | +| `localpost_h11_s4` (SO_REUSEPORT) | 20,604 | +3% | +| **`localpost_h11_acceptor_s4`** | **26,684** | **+33%** | | `localpost_httptools` (s=1, pool) | 16,262 | — | | `localpost_httptools_s4` | 15,973 | −2% | | **`localpost_httptools_acceptor_s4`** | **17,958** | **+10%** | -The headline result on h11: `native_acceptor_s4` does **26,684 vs `native_s4`'s -20,604 = +30% over the SO_REUSEPORT alternative**. This is precisely the +The headline result on h11: `localpost_h11_acceptor_s4` does **26,684 vs +`localpost_h11_s4`'s 20,604 = +30% over the SO_REUSEPORT alternative**. This is precisely the case the topology was added for — Phase 7b confirmed `SO_REUSEPORT` puts 100 % of accepts on one selector on macOS; the explicit round-robin replaces that with a real 4-way spread. httptools shows a smaller gain because its diff --git a/benchmarks/http/apps/localpost_native.py b/benchmarks/http/apps/localpost_h11.py similarity index 96% rename from benchmarks/http/apps/localpost_native.py rename to benchmarks/http/apps/localpost_h11.py index 3e59a92..6082bd5 100644 --- a/benchmarks/http/apps/localpost_native.py +++ b/benchmarks/http/apps/localpost_h11.py @@ -1,4 +1,4 @@ -"""LocalPost native handler — HttpApp + h11 backend.""" +"""LocalPost — HttpApp framework + h11 (pure-Python) backend.""" from __future__ import annotations diff --git a/benchmarks/http/apps/localpost_native_acceptor_s4.py b/benchmarks/http/apps/localpost_h11_acceptor_s4.py similarity index 74% rename from benchmarks/http/apps/localpost_native_acceptor_s4.py rename to benchmarks/http/apps/localpost_h11_acceptor_s4.py index 13f1123..609e63e 100644 --- a/benchmarks/http/apps/localpost_native_acceptor_s4.py +++ b/benchmarks/http/apps/localpost_h11_acceptor_s4.py @@ -1,6 +1,6 @@ """LocalPost h11 backend, acceptor topology with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_native` that injects +Wrapper around :mod:`benchmarks.http.apps.localpost_h11` that injects ``--selectors 4 --acceptor`` so the runner can pick it up as a separate stack. One acceptor thread + four worker selectors via the cross-thread op queue. @@ -10,7 +10,7 @@ import sys -from benchmarks.http.apps.localpost_native import main +from benchmarks.http.apps.localpost_h11 import main if __name__ == "__main__": sys.argv += ["--selectors", "4", "--acceptor"] diff --git a/benchmarks/http/apps/localpost_native_s4.py b/benchmarks/http/apps/localpost_h11_s4.py similarity index 66% rename from benchmarks/http/apps/localpost_native_s4.py rename to benchmarks/http/apps/localpost_h11_s4.py index 9af30ad..88621fe 100644 --- a/benchmarks/http/apps/localpost_native_s4.py +++ b/benchmarks/http/apps/localpost_h11_s4.py @@ -1,6 +1,6 @@ """LocalPost h11 backend with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_native` that injects +Wrapper around :mod:`benchmarks.http.apps.localpost_h11` that injects ``--selectors 4`` so the runner can pick it up as a separate stack. """ @@ -8,7 +8,7 @@ import sys -from benchmarks.http.apps.localpost_native import main +from benchmarks.http.apps.localpost_h11 import main if __name__ == "__main__": sys.argv += ["--selectors", "4"] diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index 19d41f7..f6b18f5 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -1,6 +1,6 @@ -"""LocalPost native handler — HttpApp + httptools backend. +"""LocalPost — HttpApp framework + httptools (C/llhttp) backend. -Same behaviour as ``localpost_native``; only the server backend differs. +Same behaviour as ``localpost_h11``; only the server backend differs. """ from __future__ import annotations diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index 582f47d..0d9b65b 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -26,7 +26,7 @@ just bench-http --group quick # ~4 stacks, fast PR check just bench-http --filter app=flask # all Flask servers just bench-http --filter 'backend=lp-*' --filter selectors=1 - just bench-http --stacks localpost_native,flask_gunicorn # exact list (escape hatch) + just bench-http --stacks localpost_h11,flask_gunicorn # exact list (escape hatch) just bench-http --pythons 3.13=.venv-bench/3.13/bin/python """ diff --git a/benchmarks/http/stacks.py b/benchmarks/http/stacks.py index 472c51b..8cbdbb5 100644 --- a/benchmarks/http/stacks.py +++ b/benchmarks/http/stacks.py @@ -55,10 +55,10 @@ class Stack: STACKS: Final[tuple[Stack, ...]] = ( - Stack("localpost_native", app="native", backend="lp-h11", selectors=1, pool=True), - Stack("localpost_native_s4", app="native", backend="lp-h11", selectors=4, pool=True), + Stack("localpost_h11", app="native", backend="lp-h11", selectors=1, pool=True), + Stack("localpost_h11_s4", app="native", backend="lp-h11", selectors=4, pool=True), Stack( - "localpost_native_acceptor_s4", + "localpost_h11_acceptor_s4", app="native", backend="lp-h11", selectors=4, diff --git a/tests/benchmarks/http_stacks.py b/tests/benchmarks/http_stacks.py index a39252b..6649a67 100644 --- a/tests/benchmarks/http_stacks.py +++ b/tests/benchmarks/http_stacks.py @@ -30,7 +30,7 @@ def test_select_by_backend_glob_and_selectors(): selected = select_stacks(filters=["backend=lp-*", "selectors=1"]) names = {s.name for s in selected} assert names == { - "localpost_native", + "localpost_h11", "localpost_httptools", "localpost_httptools_inline", "localpost_wsgi", From 8a7a559d5cf3b2d1ee9e4b8fddb277ef04b88dee Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 13:54:30 +0400 Subject: [PATCH 162/286] chore: mark localpost as free-threaded ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `free-threading` / `no-gil` keywords for PyPI search. - Drop the `<0.8` cap on the `[http-fast]` httptools pin so the no-GIL-clean 0.8 release is picked up automatically when it ships; comment in the extra explains the FT relationship. - Advertise FT readiness in `pypi_readme.md` (with the `[http-fast]` caveat — h11 backend is fully no-GIL today, httptools 0.7.x still re-enables the GIL on import). - New `tests-free-threaded` CI job on 3.14t with `PYTHON_GIL=0` so any C dep that re-enables the GIL hard-fails the build. Narrow extras subset (no `[http-fast]`, no grpcio/granian/setproctitle) — exactly the surface the marking actually claims. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 24 ++++++++++++++++++++++++ pypi_readme.md | 5 +++++ pyproject.toml | 5 ++++- uv.lock | 2 +- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a30d23e..cab25e5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,3 +27,27 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + tests-free-threaded: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install Python 3.14t + run: uv python install 3.14t + - name: Install dependencies (FT-clean subset) + # --no-default-groups + narrow opt-in: avoid C extensions that + # re-enable the GIL on 3.14t (httptools 0.7.x, grpcio, granian, + # setproctitle). Covers the pure-Python core that the FT marking + # actually claims. + run: > + uv sync --python 3.14t --no-default-groups + --group tests --group tests-unit + --extra http --extra scheduler --extra cron + - name: pytest + timeout-minutes: 5 + env: + PYTHON_GIL: "0" # fail loudly if any imported C ext re-enables the GIL + run: uv run --python 3.14t pytest -v -m "not integration" diff --git a/pypi_readme.md b/pypi_readme.md index 8dbfe91..a67b4e9 100644 --- a/pypi_readme.md +++ b/pypi_readme.md @@ -13,6 +13,11 @@ Python 3.12+ required. operators like `every("1m") // delay((0, 10))`. - **HTTP** — small h11-based sync server; wrap any WSGI app. - **DI** — `.NET`-style scoped IoC container; optional Flask integration. +- **Free-threaded ready** — pure Python, no C extensions; runs on free-threaded + CPython 3.14t (no-GIL) with the default `[http]` (h11) backend. Verified by + the bench: ~3x RPS at `selectors=1`. The optional `[http-fast]` (httptools) + backend will be no-GIL-clean once httptools 0.8 lands; the currently-released + 0.7.x re-enables the GIL on import. ## Quick start diff --git a/pyproject.toml b/pyproject.toml index 0c4c4a4..8281a02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ authors = [ keywords = [ "hosting", "scheduler", "cron", "http", "wsgi", "anyio", + "free-threading", "no-gil", ] classifiers = [ "Development Status :: 4 - Beta", @@ -59,7 +60,9 @@ http = [ ] http-fast = [ # C-based HTTP/1.1 parser (llhttp); enables ``start_httptools_server``. - "httptools >=0.6,<0.8", + # NOTE: under free-threaded CPython, 0.7.x auto-re-enables the GIL on + # import; 0.8+ declares Py_mod_gil = Py_MOD_GIL_NOT_USED. + "httptools", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index debe9d0..0457d0f 100644 --- a/uv.lock +++ b/uv.lock @@ -789,7 +789,7 @@ requires-dist = [ { name = "anyio", specifier = "~=4.12" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, - { name = "httptools", marker = "extra == 'http-fast'", specifier = ">=0.6,<0.8" }, + { name = "httptools", marker = "extra == 'http-fast'" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] From e9c062548e95235aa2c1b7f6fb1798d1deaf4fe0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 16:57:36 +0400 Subject: [PATCH 163/286] =?UTF-8?q?refactor(http):=20small=20fixes=20batch?= =?UTF-8?q?=20=E2=80=94=20CLI,=20Response=20rename,=20pool/style=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - localpost/http/__main__.py: add Click CLI (`python -m localpost.http module:handler [--host] [--port] [--workers] [--selectors] [--acceptor]`) - NativeResponse → Response throughout (breaking; public type was already named Response in _types.py, alias was the holdover) - thread_pool_handler / streaming_pool_handler: merge public+private split into single @asynccontextmanager; validation now fires at context entry - suppress(Exception): replace bare except/pass in _base.py stale-conn sweep - @dataclass arg order: standardize to frozen=True, slots=True, eq=False across all of localpost/ - Section dividers: _pool.py and router.py converted to three-line module- level style matching _base.py - examples/http/middleware_basic_auth.py, middleware_rate_limit.py: new middleware examples (Basic Auth, per-IP sliding-window rate limiter) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 10 +- benchmarks/http/apps/localpost_h11.py | 10 +- benchmarks/http/apps/localpost_httptools.py | 10 +- .../http/apps/localpost_httptools_diag.py | 4 +- .../http/apps/localpost_httptools_inline.py | 10 +- benchmarks/micro/bench_router.py | 4 +- examples/http/middleware_basic_auth.py | 116 ++++++++++++++++ examples/http/middleware_rate_limit.py | 126 ++++++++++++++++++ examples/http/multithread_server.py | 4 +- examples/http/sentry_router_server.py | 4 +- examples/http/simple_server.py | 4 +- justfile | 4 +- localpost/di/flask.py | 2 +- localpost/http/__init__.py | 5 +- localpost/http/__main__.py | 62 +++++++++ localpost/http/_base.py | 24 ++-- localpost/http/_cancel.py | 2 +- localpost/http/_pool.py | 43 +++--- localpost/http/_service.py | 8 +- localpost/http/app.py | 26 ++-- localpost/http/flask.py | 4 +- localpost/http/router.py | 30 +++-- localpost/http/server_h11.py | 4 +- localpost/http/server_httptools.py | 4 +- localpost/http/wsgi.py | 4 +- pyproject.toml | 8 +- tests/http/_integration_app.py | 4 +- tests/http/acceptor_stress.py | 4 +- tests/http/app.py | 10 +- tests/http/backend_parity.py | 24 ++-- tests/http/parser_parity_props.py | 4 +- tests/http/router.py | 8 +- tests/http/router_props.py | 12 +- tests/http/router_sentry_handler.py | 6 +- tests/http/server.py | 42 +++--- tests/http/service.py | 42 +++--- uv.lock | 30 +---- 37 files changed, 490 insertions(+), 228 deletions(-) create mode 100644 examples/http/middleware_basic_auth.py create mode 100644 examples/http/middleware_rate_limit.py diff --git a/CLAUDE.md b/CLAUDE.md index 7b0af09..ea7af8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,9 +111,9 @@ module's `__init__.py`. ## Module deep-dives -For more detail on each module, see: +For more detail on each module, read its README on demand: -@localpost/hosting/README.md -@localpost/scheduler/README.md -@localpost/di/README.md -@localpost/http/README.md +- `localpost/hosting/README.md` +- `localpost/scheduler/README.md` +- `localpost/di/README.md` +- `localpost/http/README.md` diff --git a/benchmarks/http/apps/localpost_h11.py b/benchmarks/http/apps/localpost_h11.py index 6082bd5..5bf6e99 100644 --- a/benchmarks/http/apps/localpost_h11.py +++ b/benchmarks/http/apps/localpost_h11.py @@ -8,7 +8,7 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, NativeResponse, ServerConfig +from localpost.http import HttpApp, HTTPReqCtx, Response, ServerConfig def main() -> int: @@ -17,7 +17,7 @@ def main() -> int: @app.get("/ping") def ping(): - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(PING_BODY)).encode("ascii"))], ), PING_BODY @@ -25,14 +25,14 @@ def ping(): @app.get("/hello/{name}") def hello(name: str): body = hello_body(name) - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), body @app.post("/echo") def echo(ctx: HTTPReqCtx): - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"application/json"), (b"content-length", str(len(ctx.body)).encode("ascii"))], ), ctx.body @@ -42,7 +42,7 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): body = profile_update_body(user_id, ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))], ), body diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index f6b18f5..902b773 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -11,7 +11,7 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, NativeResponse, ServerConfig +from localpost.http import HttpApp, HTTPReqCtx, Response, ServerConfig def main() -> int: @@ -22,7 +22,7 @@ def main() -> int: def ping(): # Wire-bytes for the tightest plaintext path (skips the str → bytes # encode and the Content-Type rewrite). - return NativeResponse( + return Response( status_code=200, headers=[ (b"content-type", b"text/plain"), @@ -33,7 +33,7 @@ def ping(): @app.get("/hello/{name}") def hello(name: str): body = hello_body(name) - return NativeResponse( + return Response( status_code=200, headers=[ (b"content-type", b"text/plain"), @@ -43,7 +43,7 @@ def hello(name: str): @app.post("/echo") def echo(ctx: HTTPReqCtx): - return NativeResponse( + return Response( status_code=200, headers=[ (b"content-type", b"application/json"), @@ -56,7 +56,7 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): body = profile_update_body(user_id, ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return NativeResponse( + return Response( status_code=200, headers=[ (b"content-type", b"application/json"), diff --git a/benchmarks/http/apps/localpost_httptools_diag.py b/benchmarks/http/apps/localpost_httptools_diag.py index 7d21859..37265cb 100644 --- a/benchmarks/http/apps/localpost_httptools_diag.py +++ b/benchmarks/http/apps/localpost_httptools_diag.py @@ -31,7 +31,7 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, + Response, Routes, ServerConfig, http_server, @@ -50,7 +50,7 @@ def _record() -> None: def _emit(ctx: HTTPReqCtx, body: bytes, content_type: bytes = b"text/plain") -> None: ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", content_type), (b"content-length", str(len(body)).encode("ascii"))], ), diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index 1a52c96..dbb46f7 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -12,7 +12,7 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, NativeResponse, ServerConfig +from localpost.http import HttpApp, HTTPReqCtx, Response, ServerConfig def main() -> int: @@ -21,7 +21,7 @@ def main() -> int: @app.get("/ping") def ping(): - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(PING_BODY)).encode("ascii"))], ), PING_BODY @@ -29,14 +29,14 @@ def ping(): @app.get("/hello/{name}") def hello(name: str): body = hello_body(name) - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), body @app.post("/echo") def echo(ctx: HTTPReqCtx): - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"application/json"), (b"content-length", str(len(ctx.body)).encode("ascii"))], ), ctx.body @@ -46,7 +46,7 @@ def profile_update(ctx: HTTPReqCtx, user_id: str): body = profile_update_body(user_id, ctx.body) for delay_s in PROFILE_WORK_DELAYS_S: time.sleep(delay_s) - return NativeResponse( + return Response( status_code=200, headers=[(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))], ), body diff --git a/benchmarks/micro/bench_router.py b/benchmarks/micro/bench_router.py index 27b75c8..a0621e4 100644 --- a/benchmarks/micro/bench_router.py +++ b/benchmarks/micro/bench_router.py @@ -13,11 +13,11 @@ from __future__ import annotations -from localpost.http import HTTPReqCtx, NativeResponse, Routes +from localpost.http import HTTPReqCtx, Response, Routes def _ok(ctx: HTTPReqCtx): - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def _build_routes_20() -> Routes: diff --git a/examples/http/middleware_basic_auth.py b/examples/http/middleware_basic_auth.py new file mode 100644 index 0000000..77093ae --- /dev/null +++ b/examples/http/middleware_basic_auth.py @@ -0,0 +1,116 @@ +"""Basic-auth middleware example. + +Demonstrates a :data:`localpost.http.Middleware` that validates the +``Authorization: Basic …`` header before dispatching to the inner handler. +Unauthenticated requests get a 401 on the selector thread — no worker hop. + +Run:: + + uv run examples/http/middleware_basic_auth.py + + curl http://localhost:8000/hello # 401 + curl -u alice:secret http://localhost:8000/hello # 200 +""" + +from __future__ import annotations + +import base64 +import logging +import sys + +from localpost.hosting import run_app, service +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + RequestHandler, + Response, + Routes, + ServerConfig, + compose, + http_server, + route_match, + thread_pool_handler, +) + +# ----------- credentials store (replace with a real check) ---------------- + +_USERS: dict[str, str] = {"alice": "secret", "bob": "pass"} + + +def _check_basic_auth(authorization: bytes | None) -> bool: + if not authorization or not authorization.startswith(b"Basic "): + return False + try: + decoded = base64.b64decode(authorization[6:]).decode("latin-1") + username, _, password = decoded.partition(":") + return _USERS.get(username) == password + except Exception: # noqa: BLE001 + return False + + +# ----------- middleware --------------------------------------------------- + +_UNAUTHORIZED = Response( + status_code=401, + headers=[(b"www-authenticate", b'Basic realm="localpost"'), (b"content-length", b"0")], +) + + +def basic_auth(inner: RequestHandler) -> RequestHandler: + """Reject requests without valid Basic credentials before dispatching.""" + + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + auth_header = next( + (v for k, v in ctx.request.headers if k == b"authorization"), + None, + ) + if not _check_basic_auth(auth_header): + ctx.complete(_UNAUTHORIZED, b"") + return None + return inner(ctx) + + return wrapped + + +# ----------- routes ------------------------------------------------------- + + +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + name = route_match(ctx).path_args.get("name", "world") + body = f"Hello, {name}!\n".encode() + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], + ), + body, + ) + return None + + +def build_router() -> RequestHandler: + routes = Routes() + routes.get("/hello")(_hello) + routes.get("/hello/{name}")(_hello) + handler = routes.build().as_handler() + return compose(basic_auth)(handler) + + +# ----------- app ---------------------------------------------------------- + + +@service +async def app(): + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(build_router(), max_concurrency=8) as h: + async with http_server(config, h): + yield + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py new file mode 100644 index 0000000..9ef3b42 --- /dev/null +++ b/examples/http/middleware_rate_limit.py @@ -0,0 +1,126 @@ +"""Per-IP sliding-window rate-limiter middleware example. + +Demonstrates a :data:`localpost.http.Middleware` that counts requests per +client IP within a rolling time window and returns 429 when the budget is +exceeded. The check runs on the selector thread pre-body — no worker hop +for rejected requests. + +Run:: + + uv run examples/http/middleware_rate_limit.py + + # Spam quickly to trigger the limiter: + for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\\n" http://localhost:8000/; done +""" + +from __future__ import annotations + +import logging +import sys +import threading +import time +from collections import defaultdict, deque + +from localpost.hosting import run_app, service +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + Middleware, + RequestHandler, + Response, + Routes, + ServerConfig, + compose, + http_server, + thread_pool_handler, +) + +# ----------- rate-limit state (selector-thread-safe via lock) ------------- + +_TOO_MANY = Response( + status_code=429, + headers=[(b"content-length", b"0"), (b"retry-after", b"1")], +) + + +class _SlidingWindowCounter: + """Thread-safe per-key sliding-window request counter.""" + + def __init__(self, max_requests: int, window_seconds: float) -> None: + self._max = max_requests + self._window = window_seconds + self._timestamps: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def is_allowed(self, key: str) -> bool: + now = time.monotonic() + cutoff = now - self._window + with self._lock: + bucket = self._timestamps[key] + while bucket and bucket[0] < cutoff: + bucket.popleft() + if len(bucket) >= self._max: + return False + bucket.append(now) + return True + + +# ----------- middleware --------------------------------------------------- + + +def rate_limit(max_requests: int = 10, window_seconds: float = 1.0) -> Middleware: + """Limit each remote IP to ``max_requests`` within ``window_seconds``.""" + counter = _SlidingWindowCounter(max_requests, window_seconds) + + def middleware(inner: RequestHandler) -> RequestHandler: + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + client_ip = ctx.conn.sock.getpeername()[0] + if not counter.is_allowed(client_ip): + ctx.complete(_TOO_MANY, b"") + return None + return inner(ctx) + + return wrapped + + return middleware + + +# ----------- routes ------------------------------------------------------- + + +def _root(ctx: HTTPReqCtx) -> BodyHandler | None: + body = b"ok\n" + ctx.complete( + Response( + status_code=200, + headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], + ), + body, + ) + return None + + +def build_handler() -> RequestHandler: + routes = Routes() + routes.get("/")(_root) + return compose(rate_limit(max_requests=10, window_seconds=1.0))(routes.build().as_handler()) + + +# ----------- app ---------------------------------------------------------- + + +@service +async def app(): + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(build_handler(), max_concurrency=8) as h: + async with http_server(config, h): + yield + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index be3bddc..0a4e6ba 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -26,7 +26,7 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, + Response, Router, Routes, ServerConfig, @@ -38,7 +38,7 @@ def _emit(ctx: HTTPReqCtx, body: bytes) -> None: ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index db530fe..3e8a681 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -25,7 +25,7 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, + Response, Routes, ServerConfig, http_server, @@ -37,7 +37,7 @@ def _emit(ctx: HTTPReqCtx, body: bytes) -> None: ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), diff --git a/examples/http/simple_server.py b/examples/http/simple_server.py index 42ea942..979c879 100644 --- a/examples/http/simple_server.py +++ b/examples/http/simple_server.py @@ -1,12 +1,12 @@ import logging -from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig, start_http_server +from localpost.http import HTTPReqCtx, Response, ServerConfig, start_http_server def _main(): def simple_app(ctx: HTTPReqCtx): ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"Content-Type", b"text/plain"), (b"Content-Length", b"14")], ), diff --git a/justfile b/justfile index f5030e5..b24dc1a 100755 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ #!/usr/bin/env -S just --justfile -default: - just --list +doctor: + brew install uv oha ty deps: uv sync --all-groups --all-extras diff --git a/localpost/di/flask.py b/localpost/di/flask.py index 7d11a0f..0832782 100644 --- a/localpost/di/flask.py +++ b/localpost/di/flask.py @@ -15,7 +15,7 @@ @final -@dataclass(frozen=True, eq=False, slots=True) +@dataclass(frozen=True, slots=True, eq=False) class RequestContext(ResolutionContext): ctx: ExitStack = field(default_factory=ExitStack) diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index fdbb776..ccdbf5a 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -9,8 +9,7 @@ from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, wsgi_server -from localpost.http._types import BodyTooLarge, InformationalResponse, Request -from localpost.http._types import Response as NativeResponse +from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.app import HttpApp from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( @@ -44,7 +43,7 @@ "RoundRobinAcceptor", # neutral wire types (used directly with HTTPReqCtx) "Request", - "NativeResponse", + "Response", "InformationalResponse", "BodyTooLarge", # router diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py index e69de29..a850d3c 100644 --- a/localpost/http/__main__.py +++ b/localpost/http/__main__.py @@ -0,0 +1,62 @@ +"""CLI entry point: ``python -m localpost.http module:handler``.""" + +from __future__ import annotations + +import importlib +import logging +import sys + +import click + +from localpost import hosting +from localpost.http import ServerConfig, http_server, thread_pool_handler +from localpost.http.server import RequestHandler + + +def _load_handler(app_str: str) -> RequestHandler: + if ":" not in app_str: + raise click.BadParameter(f"Expected 'module:attr', got {app_str!r}", param_hint="APP") + module_path, attr = app_str.rsplit(":", 1) + try: + module = importlib.import_module(module_path) + except ImportError as e: + raise click.ClickException(f"Cannot import {module_path!r}: {e}") from e + try: + return getattr(module, attr) # type: ignore[return-value] + except AttributeError as e: + raise click.ClickException(f"{module_path!r} has no attribute {attr!r}") from e + + +@click.command() +@click.argument("app") +@click.option("--host", default="127.0.0.1", show_default=True, help="Bind host.") +@click.option("--port", default=8000, show_default=True, help="Bind port.") +@click.option("--workers", default=0, show_default=True, help="Thread-pool size (0 = no pool).") +@click.option("--selectors", default=1, show_default=True, help="Selector threads.") +@click.option("--acceptor", is_flag=True, default=False, help="Use acceptor topology.") +def main(app: str, host: str, port: int, workers: int, selectors: int, acceptor: bool) -> None: + """Run a LocalPost HTTP/1.1 server. + + APP is a 'module:handler' reference — e.g. ``myapp:router_handler``. + The attribute must be a :data:`localpost.http.RequestHandler` callable. + Pass ``--workers N`` to wrap it with :func:`localpost.http.thread_pool_handler`. + """ + logging.basicConfig(level=logging.INFO) + handler = _load_handler(app) + config = ServerConfig(host=host, port=port) + + @hosting.service + async def _serve(): + if workers > 0: + async with thread_pool_handler(handler, max_concurrency=workers) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + else: + async with http_server(config, handler, selectors=selectors, acceptor=acceptor): + yield + + sys.exit(hosting.run_app(_serve())) + + +if __name__ == "__main__": + main() diff --git a/localpost/http/_base.py b/localpost/http/_base.py index cb7b9da..9ac71a2 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -68,7 +68,7 @@ @final -@dataclass(eq=False, slots=True, frozen=True) +@dataclass(frozen=True, slots=True, eq=False) class _OpTrack: """Worker-enqueued op: register ``conn`` in the selector with ``data=conn``.""" @@ -76,7 +76,7 @@ class _OpTrack: @final -@dataclass(eq=False, slots=True, frozen=True) +@dataclass(frozen=True, slots=True, eq=False) class _OpClose: """Worker-enqueued op: clean up ``selector._fd_to_key[fd]`` after ``sock.close()``. @@ -455,7 +455,7 @@ def emit_stale_408(self) -> None: @final -@dataclass(eq=False, slots=True, frozen=True) +@dataclass(frozen=True, slots=True, eq=False) class _DrainWakeup: """Selector callback for the wakeup pipe fd. Drains the byte(s) and pulls any pending ops onto the selector thread. @@ -467,7 +467,7 @@ def __call__(self, sel: Selector, /) -> None: @final -@dataclass(eq=False, slots=True, frozen=True) +@dataclass(frozen=True, slots=True, eq=False) class _AcceptListener: """Selector callback for a listen socket. Accepts one connection and delegates to the configured :data:`ConnHandler`. @@ -505,7 +505,7 @@ def __call__(self, sel: Selector, /) -> None: @final -@dataclass(eq=False, slots=True, frozen=True) +@dataclass(frozen=True, slots=True, eq=False) class TrackHere: """Default :data:`ConnHandler`. Builds a conn for the accepting selector and tracks it locally. This is the behaviour the server has always had — @@ -521,7 +521,7 @@ def __call__(self, sel: Selector, sock: socket.socket, addr: tuple[str, int]) -> @final -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class RoundRobinAcceptor: """:data:`ConnHandler` for the acceptor topology. Spreads new conns across a tuple of worker :class:`Selector` instances using a simple @@ -832,14 +832,10 @@ def _cleanup_stale(self) -> None: # Stalled mid-request gets a 408; idle keep-alive gets silently # dropped. The decision and the bytes-on-wire are the conn's # job — backends differ. - try: + with suppress(Exception): conn.emit_stale_408() - except Exception: # noqa: BLE001, S110 — the conn is being torn down anyway - pass - try: + with suppress(OSError): conn.sock.close() - except OSError: - pass # ----- Run loop ----- @@ -1023,9 +1019,7 @@ def start_http_server_base( selector.close() -def start_http_server( - config: ServerConfig, handler: RequestHandler, / -) -> AbstractContextManager[BaseServer]: +def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> AbstractContextManager[BaseServer]: """Open a listening socket and yield a server driving the configured backend. Backend is read from ``config.backend``. ``"h11"`` (default) is the diff --git a/localpost/http/_cancel.py b/localpost/http/_cancel.py index a9715a2..45fc016 100644 --- a/localpost/http/_cancel.py +++ b/localpost/http/_cancel.py @@ -34,7 +34,7 @@ class RequestCancelled(Exception): @final -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class RequestCancel: """Per-request cancellation token. diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 0eb0111..ff80a30 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -22,12 +22,11 @@ import logging import threading from collections.abc import AsyncGenerator, Callable -from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress +from contextlib import asynccontextmanager, suppress from anyio import ( BrokenResourceError, ClosedResourceError, - EndOfStream, create_task_group, to_thread, ) @@ -44,7 +43,9 @@ from localpost.http.config import LOGGER_NAME from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error -# --- Internal pool primitive -------------------------------------------- +# -------------------------------------------------------------------------- +# Internal pool primitive +# -------------------------------------------------------------------------- _WorkFn = Callable[[HTTPReqCtx], None] @@ -164,11 +165,7 @@ async def _pool_context(max_concurrency: int, backlog: int) -> AsyncGenerator[_P def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: with my_rx: - while True: - try: - ctx, cancel, fn = my_rx.get() - except (EndOfStream, ClosedResourceError): - return + for ctx, cancel, fn in my_rx: try: with _enter_request(cancel): try: @@ -211,7 +208,7 @@ async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: async with create_task_group() as tg: for _ in range(max_concurrency): tg.start_soon(run_worker, rx.clone()) - rx.close() + rx.close() # Keep only workers read ends try: yield _Pool(tx, shutdown_event, admission) finally: @@ -229,16 +226,19 @@ def _emit_body_too_large(ctx: HTTPReqCtx) -> None: ctx.conn.close() -# --- Public wrappers ---------------------------------------------------- +# -------------------------------------------------------------------------- +# Public wrappers +# -------------------------------------------------------------------------- -def thread_pool_handler( +@asynccontextmanager +async def thread_pool_handler( inner: RequestHandler, /, *, max_concurrency: int, backlog: int = 0, -) -> AbstractAsyncContextManager[RequestHandler]: +) -> AsyncGenerator[RequestHandler]: """Async context manager: yields a ``RequestHandler`` that offloads body-handler continuations to a worker thread. @@ -277,13 +277,6 @@ def thread_pool_handler( raise ValueError("max_concurrency must be >= 1") if backlog < 0: raise ValueError("backlog must be >= 0") - return _thread_pool_handler(inner, max_concurrency, backlog) - - -@asynccontextmanager -async def _thread_pool_handler( - inner: RequestHandler, max_concurrency: int, backlog: int -) -> AsyncGenerator[RequestHandler]: async with _pool_context(max_concurrency, backlog) as pool: def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: @@ -295,13 +288,14 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: yield wrapped -def streaming_pool_handler( +@asynccontextmanager +async def streaming_pool_handler( inner: _WorkFn, /, *, max_concurrency: int, backlog: int = 0, -) -> AbstractAsyncContextManager[RequestHandler]: +) -> AsyncGenerator[RequestHandler]: """Async context manager: yields a ``RequestHandler`` that runs ``inner`` on a worker thread with a *borrowed* conn — body **not** pre-buffered. @@ -327,7 +321,7 @@ def upload(ctx: HTTPReqCtx) -> None: with open("/tmp/u", "wb") as f: while chunk := ctx.receive(8192): f.write(chunk) - ctx.complete(NativeResponse(204, [(b"content-length", b"0")]), b"") + ctx.complete(Response(204, [(b"content-length", b"0")]), b"") async with streaming_pool_handler(upload, max_concurrency=4) as h: @@ -338,10 +332,5 @@ def upload(ctx: HTTPReqCtx) -> None: raise ValueError("max_concurrency must be >= 1") if backlog < 0: raise ValueError("backlog must be >= 0") - return _streaming_pool_handler(inner, max_concurrency, backlog) - - -@asynccontextmanager -async def _streaming_pool_handler(inner: _WorkFn, max_concurrency: int, backlog: int) -> AsyncGenerator[RequestHandler]: async with _pool_context(max_concurrency, backlog) as pool: yield pool.dispatch_streaming(inner) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 2372116..93ca5d8 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -192,18 +192,14 @@ async def run(lt: ServiceLifetime) -> None: # capture them. Each worker has its own Selector with its own # wakeup pipe / op queue. ``shutting_down`` is per-selector — we # toggle them on shutdown. - worker_selectors = tuple( - Selector(config, port=port, logger=logger) for _ in range(workers) - ) + worker_selectors = tuple(Selector(config, port=port, logger=logger) for _ in range(workers)) acceptor_selector = Selector(config, port=port, logger=logger) round_robin = RoundRobinAcceptor( workers=worker_selectors, handler=handler, conn_factory=conn_factory, ) - acceptor_server = BaseServer( - config, round_robin, logger, listen_sock, acceptor_selector - ) + acceptor_server = BaseServer(config, round_robin, logger, listen_sock, acceptor_selector) worker_started: list[Event] = [Event() for _ in range(workers)] acceptor_started = Event() diff --git a/localpost/http/app.py b/localpost/http/app.py index 98c55e0..788226d 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -10,7 +10,7 @@ :data:`localpost.http.HTTPReqCtx` get the request context. - Automatic response conversion: ``str`` → ``text/plain``, ``bytes`` → ``application/octet-stream``, ``dict`` / ``list`` → ``application/json``, - :data:`localpost.http.NativeResponse` → as-is, ``(NativeResponse, bytes)`` + :data:`localpost.http.Response` → as-is, ``(Response, bytes)`` tuple → with body, ``None`` → 204. - Worker-pool dispatch for matched routes. - Two body modes per route: @@ -46,7 +46,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): with open(f"/tmp/{name}.jpg", "wb") as f: while chunk := ctx.receive(8192): f.write(chunk) - return NativeResponse(status_code=204, headers=[(b"content-length", b"0")]) + return Response(status_code=204, headers=[(b"content-length", b"0")]) sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) @@ -64,7 +64,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from localpost import hosting from localpost.http._pool import _Pool, _pool_context from localpost.http._service import http_server -from localpost.http._types import Response as NativeResponse +from localpost.http._types import Response from localpost.http.config import ServerConfig from localpost.http.router import Routes, URITemplate, route_match from localpost.http.server import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose @@ -119,26 +119,26 @@ def _make_path_arg_resolver(name: str) -> _ParamResolver: return lambda ctx: route_match(ctx).path_args[name] -def _wrap_response(value: Any) -> tuple[NativeResponse, bytes]: - """Convert a handler's return value into ``(NativeResponse, body)``. +def _wrap_response(value: Any) -> tuple[Response, bytes]: + """Convert a handler's return value into ``(Response, body)``. Supported shapes: - ``str`` — ``200 text/plain; charset=utf-8`` - ``bytes`` / ``bytearray`` / ``memoryview`` — ``200 application/octet-stream`` - ``dict`` / ``list`` — ``200 application/json`` (via ``json.dumps``) - - :class:`NativeResponse` — passed through, empty body - - ``(NativeResponse, bytes)`` tuple — passed through, with body + - :class:`Response` — passed through, empty body + - ``(Response, bytes)`` tuple — passed through, with body - ``None`` — ``204 No Content`` """ - if isinstance(value, NativeResponse): + if isinstance(value, Response): return value, b"" if value is None: return ( - NativeResponse(status_code=204, headers=[(b"content-length", b"0")]), + Response(status_code=204, headers=[(b"content-length", b"0")]), b"", ) - if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], NativeResponse): + if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], Response): response, body = value return response, body if isinstance(body, bytes) else bytes(body) if isinstance(value, str): @@ -152,12 +152,12 @@ def _wrap_response(value: Any) -> tuple[NativeResponse, bytes]: return _build_response(200, b"application/json", body), body raise TypeError( f"unsupported return type {type(value).__name__!r} from handler — " - f"return str, bytes, dict, list, NativeResponse, (NativeResponse, bytes), or None" + f"return str, bytes, dict, list, Response, (Response, bytes), or None" ) -def _build_response(status: int, content_type: bytes, body: bytes) -> NativeResponse: - return NativeResponse( +def _build_response(status: int, content_type: bytes, body: bytes) -> Response: + return Response( status_code=status, headers=[ (b"content-type", content_type), diff --git a/localpost/http/flask.py b/localpost/http/flask.py index 14a2824..ba44e34 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -21,7 +21,7 @@ from flask import Flask from localpost.http._service import http_server -from localpost.http._types import Response as _NativeResponse +from localpost.http._types import Response as _Response from localpost.http.config import ServerConfig from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.wsgi import _build_environ @@ -66,7 +66,7 @@ def _write_response(http_ctx: HTTPReqCtx, response) -> None: reason = (response.status.split(" ", 1)[1] if " " in response.status else "").encode("iso-8859-1") wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items()] http_ctx.start_response( - _NativeResponse( + _Response( status_code=response.status_code, headers=wire_headers, reason=reason, diff --git a/localpost/http/router.py b/localpost/http/router.py index 32dff72..b376389 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -23,7 +23,7 @@ from http.client import responses as _http_phrases from typing import Self, final -from localpost.http._types import Response as _NativeResponse +from localpost.http._types import Response as _Response from localpost.http.server import BodyHandler, HTTPReqCtx from localpost.http.server import RequestHandler as NativeRequestHandler @@ -95,7 +95,7 @@ def route_match(ctx: HTTPReqCtx) -> RouteMatch: @final -@dataclass(frozen=True, eq=False, slots=True) +@dataclass(frozen=True, slots=True, eq=False) class Route: """One compiled route inside a :class:`Router`.""" @@ -106,7 +106,7 @@ class Route: allow_header: str """Pre-rendered ``Allow`` header value (e.g. ``"GET, POST"``).""" - method_not_allowed: tuple[_NativeResponse, bytes] + method_not_allowed: tuple[_Response, bytes] """Pre-built ``(Response, body)`` for the 405 path on this route. Avoids rebuilding the response (status, headers list, ``content-length`` ASCII encode) on every method-mismatch.""" @@ -116,7 +116,7 @@ class Route: @final -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class Routes: """Mutable route builder. @@ -134,7 +134,7 @@ class Routes: @routes.get("/hello/{name}") def hello(ctx: HTTPReqCtx) -> BodyHandler | None: match = route_match(ctx) - ctx.complete(NativeResponse(...), b"hi " + match.path_args["name"].encode()) + ctx.complete(Response(...), b"hi " + match.path_args["name"].encode()) return None @@ -180,7 +180,7 @@ def build(self) -> Router: @final -@dataclass(frozen=True, eq=False, slots=True) +@dataclass(frozen=True, slots=True, eq=False) class Router: """Immutable, compiled URI-template dispatcher. @@ -250,7 +250,7 @@ def _match(self, path: str, method_str: str) -> _MatchResult: allowed_methods: set[HTTPMethod] = set() matched_routes = 0 - single_route_405: tuple[_NativeResponse, bytes] | None = None + single_route_405: tuple[_Response, bytes] | None = None for route in self.routes: path_args = route.template.match(path) @@ -318,7 +318,9 @@ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: return dispatch -# --- Match result types ------------------------------------------------- +# -------------------------------------------------------------------------- +# Match result types +# -------------------------------------------------------------------------- @final @@ -337,7 +339,7 @@ class _MatchNotFound: @final @dataclass(frozen=True, slots=True) class _MatchMethodNotAllowed: - response: _NativeResponse + response: _Response body: bytes @@ -345,7 +347,9 @@ class _MatchMethodNotAllowed: _MATCH_NOT_FOUND = _MatchNotFound() -# --- Internal helpers --------------------------------------------------- +# -------------------------------------------------------------------------- +# Internal helpers +# -------------------------------------------------------------------------- def _literal_prefix_len(t: URITemplate) -> int: @@ -386,8 +390,8 @@ def _build_plain_response( body: bytes, *, extra_headers: tuple[tuple[bytes, bytes], ...] = (), -) -> _NativeResponse: - return _NativeResponse( +) -> _Response: + return _Response( status_code=status_code, headers=[ (b"content-type", b"text/plain"), @@ -403,7 +407,7 @@ def _build_plain_response( _METHOD_NOT_ALLOWED_BODY = b"Method Not Allowed" -def _build_method_not_allowed(allow_header: str) -> tuple[_NativeResponse, bytes]: +def _build_method_not_allowed(allow_header: str) -> tuple[_Response, bytes]: """Pre-build the 405 response for a route. Called once at ``Routes.build()`` time so the dispatch hot path skips the list / encode / Response build.""" response = _build_plain_response( diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index f7dc4b2..504fceb 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -70,7 +70,7 @@ def _has_response_framing(headers) -> bool: @final -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class HTTPConn(BaseHTTPConn): selector: Selector sock: socket.socket @@ -302,7 +302,7 @@ def emit_stale_408(self) -> None: pass -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class HTTPReqCtxH11: """Per-request context for the h11 backend. diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index b6ffae6..634e963 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -110,7 +110,7 @@ def _response_allows_body(request_method: bytes, status_code: int) -> bool: @final -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class HTTPConn(BaseHTTPConn): selector: Selector sock: socket.socket @@ -455,7 +455,7 @@ class _ProtocolError(Exception): """Translated httptools parser error. Mapped to 400 by the conn loop.""" -@dataclass(eq=False, slots=True) +@dataclass(slots=True, eq=False) class HTTPReqCtxHttptools: """Per-request context for the httptools backend. diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 5edf4f4..1e270ff 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -7,7 +7,7 @@ from urllib.parse import unquote_to_bytes from wsgiref.types import WSGIApplication -from localpost.http._types import Response as _NativeResponse +from localpost.http._types import Response as _Response from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler __all__ = ["wrap_wsgi"] @@ -83,7 +83,7 @@ def start_response( status_code = int(status.split(" ", 1)[0]) reason = status.split(" ", 1)[1] if " " in status else "" wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in headers] - response_state["response"] = _NativeResponse( + response_state["response"] = _Response( status_code=status_code, headers=wire_headers, reason=reason.encode("iso-8859-1") if reason else b"", diff --git a/pyproject.toml b/pyproject.toml index 8281a02..0f37db8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,12 +57,12 @@ scheduler = [ ] http = [ "h11 ~=0.16", + "click ~=8.0", ] http-fast = [ - # C-based HTTP/1.1 parser (llhttp); enables ``start_httptools_server``. - # NOTE: under free-threaded CPython, 0.7.x auto-re-enables the GIL on - # import; 0.8+ declares Py_mod_gil = Py_MOD_GIL_NOT_USED. - "httptools", + # C-based HTTP/1.1 parser (llhttp) + # NOTE! Under free-threaded CPython, 0.7.1 and below auto-re-enables the GIL on import. + "httptools @ git+https://github.com/MagicStack/httptools.git", ] [dependency-groups] diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index a51e833..fa59738 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -23,14 +23,14 @@ def _build_handler(): from localpost.http import ( # noqa: PLC0415 BodyHandler, HTTPReqCtx, - NativeResponse, + Response, Routes, route_match, ) def _emit(ctx: HTTPReqCtx, body: bytes) -> None: ctx.complete( - NativeResponse( + Response( status_code=200, headers=[ (b"content-type", b"text/plain"), diff --git a/tests/http/acceptor_stress.py b/tests/http/acceptor_stress.py index e978835..89c8a05 100644 --- a/tests/http/acceptor_stress.py +++ b/tests/http/acceptor_stress.py @@ -27,7 +27,7 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, + Response, ServerConfig, http_server, ) @@ -41,7 +41,7 @@ def handler(ctx: HTTPReqCtx) -> BodyHandler | None: with lock: seen[threading.get_ident()] += 1 ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) return None diff --git a/tests/http/app.py b/tests/http/app.py index 9653381..8603918 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -26,8 +26,8 @@ HttpApp, HTTPReqCtx, Middleware, - NativeResponse, RequestHandler, + Response, ServerConfig, http_server, start_http_server, @@ -146,13 +146,13 @@ def bin_(): await lt.stopped async def test_native_response_passes_through(self, free_port): - """Handlers can drop to wire-format with NativeResponse. Body must match + """Handlers can drop to wire-format with Response. Body must match the declared Content-Length (or set 0).""" app = HttpApp() @app.get("/raw") def raw(): - return NativeResponse( + return Response( status_code=418, headers=[(b"content-type", b"text/plain"), (b"content-length", b"0")], ) @@ -255,7 +255,7 @@ def _short_circuit_with_status(status: int, body: bytes) -> Middleware: def mw(inner: RequestHandler) -> RequestHandler: def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: ctx.complete( - NativeResponse( + Response( status_code=status, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), @@ -629,7 +629,7 @@ def dispatch(req_ctx: HTTPReqCtx) -> None: break chunks.append(chunk) captured["body"] = b"".join(chunks) - req_ctx.complete(NativeResponse(200, [(b"content-length", b"2")]), b"ok") + req_ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") cast(Any, ctx)._defer_streaming_dispatch(dispatch) diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index ca779d4..d85be93 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -6,14 +6,14 @@ import httpx -from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig +from localpost.http import HTTPReqCtx, Response, ServerConfig from tests.http._helpers import drain_socket, read_http_response, read_until def _ok(body: bytes = b"hello"): def handler(ctx: HTTPReqCtx) -> None: ctx.complete( - NativeResponse( + Response( status_code=200, headers=[ (b"content-type", b"text/plain"), @@ -40,7 +40,7 @@ def body_handler(ctx: HTTPReqCtx) -> None: received.append(ctx.body) out = b"echo:" + ctx.body ctx.complete( - NativeResponse( + Response( status_code=200, headers=[ (b"content-type", b"text/plain"), @@ -68,7 +68,7 @@ def handler(ctx: HTTPReqCtx) -> None: counter += 1 body = str(counter).encode("ascii") ctx.complete( - NativeResponse( + Response( 200, [ (b"content-type", b"text/plain"), @@ -95,7 +95,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_malformed_request_returns_400(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") with serve_backend_in_thread(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: @@ -108,7 +108,7 @@ def test_expect_100_continue(serve_backend_in_thread): def body_handler(ctx: HTTPReqCtx) -> None: out = b"got:" + ctx.body ctx.complete( - NativeResponse( + Response( 200, [ (b"content-type", b"text/plain"), @@ -140,7 +140,7 @@ def test_oversize_content_length_returns_413(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: nonlocal called called = True - ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") config = ServerConfig(host="127.0.0.1", port=0, max_body_size=100) with serve_backend_in_thread(handler, config) as port: @@ -159,7 +159,7 @@ def handler(ctx: HTTPReqCtx) -> None: calls += 1 if ctx.request.target == b"/boom": raise RuntimeError("handler crashed") - ctx.complete(NativeResponse(200, [(b"content-length", b"2")]), b"ok") + ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") with serve_backend_in_thread(handler) as port: r1 = httpx.get(f"http://127.0.0.1:{port}/boom", timeout=2) @@ -175,7 +175,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_streaming_response_chunks(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: ctx.start_response( - NativeResponse( + Response( status_code=200, headers=[], ) @@ -193,7 +193,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_unframed_204_response_has_no_transfer_encoding(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(NativeResponse(status_code=204, headers=[])) + ctx.start_response(Response(status_code=204, headers=[])) ctx.finish_response() with serve_backend_in_thread(handler) as port: @@ -206,7 +206,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_unframed_304_response_has_no_transfer_encoding(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(NativeResponse(status_code=304, headers=[])) + ctx.start_response(Response(status_code=304, headers=[])) ctx.finish_response() with serve_backend_in_thread(handler) as port: @@ -219,7 +219,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_unframed_head_response_has_no_body_or_transfer_encoding(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(NativeResponse(status_code=200, headers=[])) + ctx.start_response(Response(status_code=200, headers=[])) ctx.finish_response() with serve_backend_in_thread(handler) as port: diff --git a/tests/http/parser_parity_props.py b/tests/http/parser_parity_props.py index d12a574..42d5578 100644 --- a/tests/http/parser_parity_props.py +++ b/tests/http/parser_parity_props.py @@ -24,9 +24,9 @@ from localpost.http import ( HTTPReqCtx, - NativeResponse, Request, RequestHandler, + Response, ServerConfig, start_http_server, ) @@ -170,7 +170,7 @@ def parity_servers() -> Iterator[tuple[int, int, list[Request], list[Request]]]: def make_handler(captured: list[Request]) -> RequestHandler: def handler(ctx: HTTPReqCtx): captured.append(ctx.request) - ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") return handler diff --git a/tests/http/router.py b/tests/http/router.py index ba78782..a8bccc2 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -17,7 +17,7 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, + Response, RouteMatch, Routes, URITemplate, @@ -30,7 +30,7 @@ def _ok_handler(body: bytes = b"ok"): def handler(ctx: HTTPReqCtx) -> BodyHandler | None: ctx.complete( - NativeResponse( + Response( status_code=200, headers=[ (b"content-type", b"text/plain"), @@ -116,7 +116,7 @@ def handler(ctx: HTTPReqCtx) -> BodyHandler | None: captured["template"] = m.matched_template.template captured["path_args"] = dict(m.path_args) ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-length", b"2")], ), @@ -271,7 +271,7 @@ def test_url_encoded_path_arg_passed_through(self, serve_in_thread): def handler(ctx: HTTPReqCtx) -> BodyHandler | None: m = route_match(ctx) captured["name"] = m.path_args["name"] - ctx.complete(NativeResponse(200, [(b"content-length", b"2")]), b"ok") + ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") return None routes = Routes() diff --git a/tests/http/router_props.py b/tests/http/router_props.py index f6b3f30..f43de5e 100644 --- a/tests/http/router_props.py +++ b/tests/http/router_props.py @@ -25,8 +25,8 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, Request, + Response, RouteMatch, Router, Routes, @@ -94,10 +94,10 @@ class _FakeCtx: body: bytes = b"" response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) - completed_response: NativeResponse | None = None + completed_response: Response | None = None completed_body: bytes = b"" - def complete(self, response: NativeResponse, body: bytes | None = None) -> None: + def complete(self, response: Response, body: bytes | None = None) -> None: self.response_status = response.status_code self.completed_response = response self.completed_body = body or b"" @@ -107,11 +107,11 @@ def complete(self, response: NativeResponse, body: bytes | None = None) -> None: class _Outcome: status: int route_match: RouteMatch | None - response: NativeResponse | None + response: Response | None def _route_handler(ctx: HTTPReqCtx) -> BodyHandler | None: - ctx.complete(NativeResponse(200, [(b"content-length", b"0")]), b"") + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") return None @@ -156,7 +156,7 @@ def _matches(template: str, path: str) -> bool: return URITemplate.parse(template).match(path) is not None -def _allow_header(response: NativeResponse) -> str | None: +def _allow_header(response: Response) -> str | None: for name, value in response.headers: if name.lower() == b"allow": return value.decode("ascii") diff --git a/tests/http/router_sentry_handler.py b/tests/http/router_sentry_handler.py index 39c1f1e..0f0bf46 100644 --- a/tests/http/router_sentry_handler.py +++ b/tests/http/router_sentry_handler.py @@ -6,7 +6,7 @@ import pytest import sentry_sdk -from localpost.http import BodyHandler, HTTPReqCtx, NativeResponse, Routes, route_match +from localpost.http import BodyHandler, HTTPReqCtx, Response, Routes, route_match from localpost.http.router_sentry import sentry_router_handler from ._sentry_helpers import CapturingTransport, init_sentry, transactions @@ -27,7 +27,7 @@ def _build_router(): def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: body = f"book={route_match(ctx).path_args['id']}".encode() ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), @@ -37,7 +37,7 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: @routes.post("/books") def create_book(ctx: HTTPReqCtx) -> BodyHandler | None: - ctx.complete(NativeResponse(status_code=201, headers=[(b"content-length", b"0")]), b"") + ctx.complete(Response(status_code=201, headers=[(b"content-length", b"0")]), b"") return None assert get_book is not None diff --git a/tests/http/server.py b/tests/http/server.py index 9547b71..5e1d75c 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -9,7 +9,7 @@ import httpx import pytest -from localpost.http import HTTPReqCtx, NativeResponse, ServerConfig, start_http_server +from localpost.http import HTTPReqCtx, Response, ServerConfig, start_http_server from tests.http._helpers import drain_socket # --- Listening-socket lifecycle (no requests) --------------------------------- @@ -50,7 +50,7 @@ class TestBasicRequestResponse: def test_simple_200(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete( - NativeResponse(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), b"OK", ) @@ -63,7 +63,7 @@ def handler(ctx: HTTPReqCtx): def test_404_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.complete( - NativeResponse(status_code=404, headers=[(b"Content-Type", b"text/plain")]), + Response(status_code=404, headers=[(b"Content-Type", b"text/plain")]), b"Not Found", ) @@ -75,7 +75,7 @@ def handler(ctx: HTTPReqCtx): def test_empty_body(self, serve_in_thread): def handler(ctx: HTTPReqCtx): - ctx.complete(NativeResponse(status_code=204, headers=[])) + ctx.complete(Response(status_code=204, headers=[])) with serve_in_thread(handler) as port: resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) @@ -91,7 +91,7 @@ def test_handler_sees_method_and_target(self, serve_in_thread): def handler(ctx: HTTPReqCtx): captured["method"] = ctx.request.method captured["target"] = ctx.request.target - ctx.complete(NativeResponse(status_code=200, headers=[]), b"") + ctx.complete(Response(status_code=200, headers=[]), b"") with serve_in_thread(handler) as port: httpx.post(f"http://127.0.0.1:{port}/api/items?q=1", timeout=5) @@ -104,7 +104,7 @@ def test_handler_sees_headers(self, serve_in_thread): def handler(ctx: HTTPReqCtx): captured_headers.update(ctx.request.headers) - ctx.complete(NativeResponse(status_code=200, headers=[]), b"") + ctx.complete(Response(status_code=200, headers=[]), b"") with serve_in_thread(handler) as port: httpx.get(f"http://127.0.0.1:{port}/", headers={"X-Custom": "hello"}, timeout=5) @@ -122,7 +122,7 @@ def handler(ctx: HTTPReqCtx): if not chunk: break received_body.extend(chunk) - ctx.complete(NativeResponse(status_code=200, headers=[]), b"ok") + ctx.complete(Response(status_code=200, headers=[]), b"ok") with serve_in_thread(handler) as port: httpx.post(f"http://127.0.0.1:{port}/", content=b"hello world body", timeout=5) @@ -134,7 +134,7 @@ class TestChunkedResponse: def test_streaming_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): ctx.start_response( - NativeResponse( + Response( status_code=200, headers=[(b"Transfer-Encoding", b"chunked")], ) @@ -158,7 +158,7 @@ def handler(ctx: HTTPReqCtx): borrow_states.append(ctx.borrowed) # False — still tracked with ctx.borrow(): borrow_states.append(ctx.borrowed) # True — untracked - ctx.complete(NativeResponse(status_code=200, headers=[]), b"borrowed") + ctx.complete(Response(status_code=200, headers=[]), b"borrowed") borrow_states.append(ctx.borrowed) # False — re-tracked after finish_response with serve_in_thread(handler) as port: @@ -175,7 +175,7 @@ def handler(ctx: HTTPReqCtx): nonlocal call_count call_count += 1 ctx.complete( - NativeResponse(status_code=200, headers=[(b"Content-Length", b"2")]), + Response(status_code=200, headers=[(b"Content-Length", b"2")]), b"ok", ) @@ -199,7 +199,7 @@ def handler(ctx: HTTPReqCtx): nonlocal call_count call_count += 1 ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -226,7 +226,7 @@ def handler(ctx: HTTPReqCtx): def _ok_handler(ctx: HTTPReqCtx) -> None: body = b"ok" ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), body, ) @@ -270,7 +270,7 @@ def boom(ctx: HTTPReqCtx) -> None: with crash_lock: crash_count += 1 ctx.start_response( - NativeResponse( + Response( status_code=200, headers=[(b"transfer-encoding", b"chunked")], ) @@ -338,7 +338,7 @@ def handler(ctx: HTTPReqCtx) -> None: break captured.extend(chunk) ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -377,7 +377,7 @@ def handler(ctx: HTTPReqCtx) -> None: (b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode()), ] - response = NativeResponse(status_code=200, headers=headers) + response = Response(status_code=200, headers=headers) if ctx.request.method == b"HEAD": ctx.complete(response, None) else: @@ -400,7 +400,7 @@ def handler(ctx: HTTPReqCtx) -> None: served.append(ctx.request.target) body = b"resp-for-" + ctx.request.target ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), + Response(status_code=200, headers=[(b"content-length", str(len(body)).encode())]), body, ) @@ -429,7 +429,7 @@ def test_send_accepts_memoryview(self, serve_in_thread): """``ctx.send`` accepts any Buffer (memoryview, bytearray, …).""" def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(NativeResponse(status_code=200, headers=[(b"content-length", b"5")])) + ctx.start_response(Response(status_code=200, headers=[(b"content-length", b"5")])) payload = bytearray(b"hello") ctx.send(memoryview(payload)[:5]) ctx.finish_response() @@ -450,7 +450,7 @@ def test_idle_keep_alive_silently_closed_after_timeout(self): def handler(ctx: HTTPReqCtx) -> None: ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -478,7 +478,7 @@ def test_mid_request_stale_returns_408(self): """Client starts sending headers and stops; server emits 408 once stale.""" def handler(ctx: HTTPReqCtx) -> None: - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=0, keep_alive_timeout=0.1, rw_timeout=0.1) with start_http_server(cfg, handler) as server: @@ -508,7 +508,7 @@ def test_oversized_content_length_returns_413(self): def handler(ctx: HTTPReqCtx) -> None: captured["called"] = True - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=0, max_body_size=10) with start_http_server(cfg, handler) as server: @@ -590,7 +590,7 @@ def test_idle_keep_alive_connection_closed_on_exit(self, serve_in_thread): def handler(ctx: HTTPReqCtx) -> None: ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) diff --git a/tests/http/service.py b/tests/http/service.py index 5d2b3c1..61d8582 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -22,9 +22,9 @@ from localpost.http import ( BodyHandler, HTTPReqCtx, - NativeResponse, Request, RequestCancelled, + Response, Routes, ServerConfig, check_cancelled, @@ -61,7 +61,7 @@ async def _serve_pooled( def _handler_200(body: bytes = b"ok"): def handler(ctx: HTTPReqCtx): ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode())], ), @@ -133,7 +133,7 @@ def body_handler(ctx: HTTPReqCtx): # Block until the test releases us; this forces parallelism. release.wait(timeout=5.0) ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) @@ -191,7 +191,7 @@ def body_handler(ctx: HTTPReqCtx): time.sleep(0.1) with lock: in_flight -= 1 - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler @@ -229,7 +229,7 @@ def body_handler(ctx: HTTPReqCtx): except RequestCancelled: handler_cancelled.set() raise - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler @@ -265,7 +265,7 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: book_id = route_match(ctx).path_args["id"] body = f"book={book_id}".encode() ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", str(len(body)).encode("ascii"))], ), @@ -293,13 +293,13 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: async def test_invalid_max_concurrency(self): with pytest.raises(ValueError, match="max_concurrency"): - # The CM-creation call is enough to trigger validation; we don't - # need to enter it. - thread_pool_handler(_handler_200(), max_concurrency=0) + async with thread_pool_handler(_handler_200(), max_concurrency=0): + pass async def test_invalid_backlog(self): with pytest.raises(ValueError, match="backlog"): - thread_pool_handler(_handler_200(), max_concurrency=1, backlog=-1) + async with thread_pool_handler(_handler_200(), max_concurrency=1, backlog=-1): + pass class TestBacklogAdmission: @@ -320,7 +320,7 @@ async def test_rendezvous_default_rejects_when_workers_busy(self, free_port): def body_handler(ctx: HTTPReqCtx): entered.set() release.wait(timeout=5.0) - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler @@ -363,7 +363,7 @@ async def test_rendezvous_idle_worker_dispatches_normally(self, free_port): idle on get() at dispatch time.""" def handler(ctx: HTTPReqCtx): - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: @@ -386,7 +386,7 @@ async def test_backlog_admits_extra_requests(self, free_port): def body_handler(ctx: HTTPReqCtx): entered.release() release.wait(timeout=5.0) - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler @@ -433,7 +433,7 @@ async def test_backlog_overflow_rejects_with_503(self, free_port): def body_handler(ctx: HTTPReqCtx): entered.set() release.wait(timeout=5.0) - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler @@ -579,7 +579,7 @@ def handler(ctx: HTTPReqCtx) -> BodyHandler | None: with lock: threads_seen.add(threading.get_ident()) ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), + Response(status_code=200, headers=[(b"content-length", b"2")]), b"hi", ) return None @@ -710,7 +710,7 @@ def hit(ctx: HTTPReqCtx) -> BodyHandler | None: with lock: threads_seen.add(threading.get_ident()) ctx.complete( - NativeResponse( + Response( status_code=200, headers=[(b"content-type", b"text/plain"), (b"content-length", b"2")], ), @@ -813,7 +813,7 @@ def body_handler(ctx: HTTPReqCtx): gate.wait(timeout=5.0) with lock: in_flight -= 1 - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler @@ -853,7 +853,7 @@ async def test_pool_overload_rejects_without_blocking_selector(self, free_port): def body_handler(ctx: HTTPReqCtx): entered.set() release.wait(timeout=5.0) - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") routes = Routes() @@ -945,7 +945,7 @@ async def test_slot_released_after_normal_request(self, free_port): """ def handler(ctx: HTTPReqCtx): - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: @@ -967,7 +967,7 @@ async def test_many_requests_served_from_worker_threads(self, free_port): def body_handler(ctx: HTTPReqCtx): tid = str(threading.get_ident()).encode() ctx.complete( - NativeResponse(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), + Response(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), tid, ) @@ -1021,7 +1021,7 @@ def body_handler(ctx: HTTPReqCtx): handler_cancelled.set() raise # Should not be reached - ctx.complete(NativeResponse(status_code=200, headers=[(b"content-length", b"2")]), b"ok") + ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") def handler(_ctx: HTTPReqCtx): return body_handler diff --git a/uv.lock b/uv.lock index 0457d0f..3e15530 100644 --- a/uv.lock +++ b/uv.lock @@ -545,32 +545,8 @@ wheels = [ [[package]] name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] +version = "0.8.0.dev0" +source = { git = "https://github.com/MagicStack/httptools.git#28d1db15eaeaab5bc7d376d2c2035d966b6e1378" } [[package]] name = "httpx" @@ -789,7 +765,7 @@ requires-dist = [ { name = "anyio", specifier = "~=4.12" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, - { name = "httptools", marker = "extra == 'http-fast'" }, + { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] From a7d7c394b603f7c013cb999f760024c17864d345 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 17:41:18 +0400 Subject: [PATCH 164/286] feat(openapi): new type-driven HTTP framework with OpenAPI 3.2 generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slim FastAPI-style layer on top of localpost.http. The win over FastAPI: response shapes come from union return types (`Book | NotFound[str]`) and filters/auth contribute to the OpenAPI doc themselves via an `update_doc` hook — no second place to declare auth, no separate `response_model=`. Built directly on the public HTTP primitives (Routes, thread_pool_handler, http_server) — does not depend on localpost.http.app.HttpApp, which remains a sibling minimal example. msgspec for schema/serde; pydantic auto-detected but not a runtime dep (user installs it themselves). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/openapi/__init__.py | 0 examples/openapi/app.py | 69 ++++ localpost/openapi/README.md | 171 ++++++++++ localpost/openapi/__init__.py | 100 ++++++ localpost/openapi/_docs.py | 62 ++++ localpost/openapi/app.py | 234 ++++++++++++++ localpost/openapi/filter.py | 53 +++ localpost/openapi/operation.py | 368 +++++++++++++++++++++ localpost/openapi/pydantic.py | 49 +++ localpost/openapi/resolvers.py | 365 +++++++++++++++++++++ localpost/openapi/results.py | 225 +++++++++++++ localpost/openapi/schemas.py | 126 ++++++++ localpost/openapi/spec.py | 574 +++++++++++++++++++++++++++++++++ pyproject.toml | 10 + tests/openapi/__init__.py | 0 tests/openapi/app.py | 419 ++++++++++++++++++++++++ tests/openapi/conftest.py | 96 ++++++ tests/openapi/integration.py | 181 +++++++++++ tests/openapi/schemas.py | 130 ++++++++ tests/openapi/spec.py | 108 +++++++ uv.lock | 52 ++- 21 files changed, 3391 insertions(+), 1 deletion(-) create mode 100644 examples/openapi/__init__.py create mode 100644 examples/openapi/app.py create mode 100644 localpost/openapi/README.md create mode 100644 localpost/openapi/__init__.py create mode 100644 localpost/openapi/_docs.py create mode 100644 localpost/openapi/app.py create mode 100644 localpost/openapi/filter.py create mode 100644 localpost/openapi/operation.py create mode 100644 localpost/openapi/pydantic.py create mode 100644 localpost/openapi/resolvers.py create mode 100644 localpost/openapi/results.py create mode 100644 localpost/openapi/schemas.py create mode 100644 localpost/openapi/spec.py create mode 100644 tests/openapi/__init__.py create mode 100644 tests/openapi/app.py create mode 100644 tests/openapi/conftest.py create mode 100644 tests/openapi/integration.py create mode 100644 tests/openapi/schemas.py create mode 100644 tests/openapi/spec.py diff --git a/examples/openapi/__init__.py b/examples/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/openapi/app.py b/examples/openapi/app.py new file mode 100644 index 0000000..0199dea --- /dev/null +++ b/examples/openapi/app.py @@ -0,0 +1,69 @@ +"""Working example for ``localpost.openapi.HttpApp``. + +Run:: + + uv run python examples/openapi/app.py + +Then:: + + curl http://localhost:8000/hello/world + curl http://localhost:8000/books/42 + curl -X POST http://localhost:8000/books \\ + -H 'Content-Type: application/json' \\ + -d '{"id": "1", "title": "Dune", "author": "Frank Herbert"}' + +Docs UIs: + http://localhost:8000/docs (Swagger UI) + http://localhost:8000/docs/redoc (ReDoc) + http://localhost:8000/docs/scalar (Scalar) +""" + +import sys +from dataclasses import dataclass + +from localpost import hosting +from localpost.http import ServerConfig +from localpost.openapi import BadRequest, Created, HttpApp, NotFound, spec + + +@dataclass +class Book: + id: str + title: str + author: str + + +_LIBRARY: dict[str, Book] = { + "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy", author="Douglas Adams"), +} + + +app = HttpApp(info=spec.Info(title="Library API", version="1.0.0")) + + +@app.get("/hello/{name}") +def hello(name: str) -> str | BadRequest[str]: + """Greet someone.""" + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + """Look up a book by ID.""" + book = _LIBRARY.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + +@app.post("/books") +def create_book(book: Book) -> Created[Book]: + """Add a new book to the library.""" + _LIBRARY[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +if __name__ == "__main__": + sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md new file mode 100644 index 0000000..00122e8 --- /dev/null +++ b/localpost/openapi/README.md @@ -0,0 +1,171 @@ +# localpost.openapi + +Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of +[`localpost.http`](../http/README.md). FastAPI-inspired; the differences: + +- **Response shapes are union return types**, not a separate `response_model=` + declaration: + ```python + def get_book(book_id: str) -> Book | NotFound[str]: ... + ``` + Both branches end up in the OpenAPI doc with proper schemas; the runtime + short-circuits to the right status code. +- **OpenAPI-aware middleware (filters).** Filters and auth contribute to the + spec themselves (security schemes, extra parameters, extra response codes) + via an `update_doc` hook. There's no second place to declare auth. +- **msgspec-first.** [msgspec](https://jcristharif.com/msgspec/) does request + body decoding, response encoding, and JSON Schema generation. Pydantic + models are recognised automatically — but pydantic is **not** a runtime + dependency; install it yourself if you want to use it. + +## Install + +```bash +pip install 'localpost[http,openapi]' +``` + +For Pydantic models in handlers: + +```bash +pip install pydantic +``` + +## Quick start + +```python +import sys +from dataclasses import dataclass + +from localpost import hosting +from localpost.http import ServerConfig +from localpost.openapi import HttpApp, NotFound, BadRequest, Created + + +@dataclass +class Book: + id: str + title: str + author: str + + +app = HttpApp() + + +@app.get("/hello/{name}") +def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + if book_id != "42": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="HHGTTG", author="Adams") + + +@app.post("/books") +def create_book(book: Book) -> Created[Book]: + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +if __name__ == "__main__": + sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) +``` + +```bash +curl http://localhost:8000/hello/world +curl http://localhost:8000/openapi.json # OpenAPI 3.2 doc +open http://localhost:8000/docs # Swagger UI +open http://localhost:8000/docs/redoc # ReDoc +open http://localhost:8000/docs/scalar # Scalar +``` + +## Concepts + +### Operations + +Plain Python functions registered with `@app.get(path)`, `.post`, `.put`, +`.delete`, `.patch`. The path follows +[`URITemplate`](../http/README.md) syntax (`/books/{id}`). + +### Argument resolvers + +One per parameter. Picked from the annotation; explicit factories override: + +| Source | Factory | Auto-picked when… | +|---|---|---| +| Path variable | `FromPath()` | param name matches `{name}` in template | +| Query string | `FromQuery()` | scalar parameter not in path, no body type | +| Header | `FromHeader("X-…")` | only via explicit `Annotated[...]` | +| Request body | `FromBody()` | param annotated as `msgspec.Struct` / dataclass / pydantic model | +| Request ctx | (none) | param annotated as `HTTPReqCtx` | + +Each resolver may short-circuit by returning an `OpResult` (e.g. validation +failure → `BadRequest`). + +### `OpResult` hierarchy + +| Class | Status | Notes | +|---|---|---| +| `Ok[T]` | 200 | Implicit when you return a plain value. | +| `Created[T]` | 201 | | +| `Accepted[T]` | 202 | | +| `NoContent` | 204 | No body. | +| `BadRequest[T]` | 400 | | +| `Unauthorized[T]` | 401 | | +| `Forbidden[T]` | 403 | | +| `NotFound[T]` | 404 | | +| `Conflict[T]` | 409 | | +| `UnprocessableEntity[T]` | 422 | | +| `TooManyRequests[T]` | 429 | | +| `InternalServerError[T]` | 500 | | + +Use the *class* in return annotations (`Book | NotFound[str]`) so the OpenAPI +doc picks up the body type per status code. Returning a bare +`localpost.http.Response` is the escape hatch (no schema contribution). + +### `OpFilter` (designed; concrete impls land later) + +```python +class OpFilter(Protocol): + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: ... + def update_doc( + self, doc: spec.OpenAPI, op: spec.Operation | None = None, / + ) -> spec.OpenAPI: ... +``` + +App-level filters get `op=None`; per-operation filters get the relevant +`Operation`. `update_doc` returns a new (immutable) doc. Concrete +`HttpBasicAuth` / `HttpBearerAuth` / `OpenIDConnectAuth` are a follow-up. + +## Hosting + +`HttpApp.service(config)` returns a `localpost.hosting.service` you feed to +`hosting.run_app(...)` or `hosting.serve(...)`. It composes: + +1. A worker pool (`thread_pool_handler`) so user fns run on threads, not the + selector. +2. The HTTP server (`http_server`). + +Pass `selectors=N` and/or `acceptor=True` to use multi-selector topology. + +## Design notes + +See the in-tree design doc for rationale and a layout map: keep this README +focused on user-facing concepts. + +## Sub-modules + +| Module | Provides | +|---|---| +| `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs | +| `operation.py` | `Operation`: signature parsing, return-type inference, runtime closure | +| `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories | +| `results.py` | `OpResult` hierarchy | +| `filter.py` | `OpFilter` protocol | +| `spec.py` | OpenAPI 3.2 dataclasses | +| `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | +| `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | +| `_docs.py` | HTML for Swagger UI / ReDoc / Scalar (CDN-loaded) | diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py new file mode 100644 index 0000000..c9681f8 --- /dev/null +++ b/localpost/openapi/__init__.py @@ -0,0 +1,100 @@ +"""Type-driven HTTP framework with OpenAPI 3.2 generation built in. + +Sits on top of the ``localpost.http`` server. The user surface mirrors +FastAPI's decorator API; the difference is that the OpenAPI doc and the +runtime request handling are derived from the *same* type annotations, +including return-type unions for response shapes:: + + from typing import Annotated + from dataclasses import dataclass + + from localpost import hosting + from localpost.http import ServerConfig + from localpost.openapi import HttpApp, NotFound + + + @dataclass + class Book: + id: str + title: str + + + app = HttpApp() + + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: + if book_id != "42": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="The Hitchhiker's Guide") + + + sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) + +Pydantic models are recognised automatically — install pydantic +yourself; it isn't a runtime dependency of localpost. +""" + +from localpost.openapi import spec +from localpost.openapi.app import HttpApp +from localpost.openapi.filter import OpFilter +from localpost.openapi.operation import Operation +from localpost.openapi.resolvers import ( + ArgResolver, + ArgResolverFactory, + FromBody, + FromHeader, + FromPath, + FromQuery, +) +from localpost.openapi.results import ( + Accepted, + BadRequest, + Conflict, + Created, + Forbidden, + InternalServerError, + NoContent, + NotFound, + Ok, + OpResult, + TooManyRequests, + Unauthorized, + UnprocessableEntity, +) +from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry, is_pydantic_model + +__all__ = [ + # core + "HttpApp", + "Operation", + # spec sub-module (advanced; not all symbols flattened) + "spec", + # filters + "OpFilter", + # arg resolvers + "ArgResolver", + "ArgResolverFactory", + "FromPath", + "FromQuery", + "FromHeader", + "FromBody", + # OpResult hierarchy + "OpResult", + "Ok", + "Created", + "Accepted", + "NoContent", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "Conflict", + "UnprocessableEntity", + "TooManyRequests", + "InternalServerError", + # schema utilities + "SchemaRegistry", + "REF_TEMPLATE", + "is_pydantic_model", +] diff --git a/localpost/openapi/_docs.py b/localpost/openapi/_docs.py new file mode 100644 index 0000000..1ad16bd --- /dev/null +++ b/localpost/openapi/_docs.py @@ -0,0 +1,62 @@ +"""Built-in doc UI HTML templates (loaded from CDN).""" + +from __future__ import annotations + + +def swagger_html(openapi_url: str) -> bytes: + return f""" + + + + + Swagger UI + + + +
+ + + +""".encode() + + +def redoc_html(openapi_url: str) -> bytes: + return f""" + + + + + ReDoc + + + + + + +""".encode() + + +def scalar_html(openapi_url: str) -> bytes: + return f""" + + + + + Scalar API Reference + + +
+ + + +""".encode() diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py new file mode 100644 index 0000000..0e47653 --- /dev/null +++ b/localpost/openapi/app.py @@ -0,0 +1,234 @@ +"""``HttpApp`` — type-driven OpenAPI 3.2 framework on top of localpost.http. + +The user surface mirrors FastAPI's decorator API. Each registered +operation becomes a :class:`localpost.openapi.operation.Operation` whose +arg resolvers, response shapes, and OpenAPI doc contributions are derived +from the function signature. + +:: + + from localpost import hosting + from localpost.http import ServerConfig + from localpost.openapi import HttpApp, NotFound + + app = HttpApp() + + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + + sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Sequence +from http import HTTPMethod +from typing import Any, Literal + +from localpost import hosting +from localpost.http._pool import thread_pool_handler +from localpost.http._service import http_server +from localpost.http._types import Response +from localpost.http.config import ServerConfig +from localpost.http.router import Routes +from localpost.http.server import HTTPReqCtx, RequestHandler +from localpost.openapi import spec as openapi_spec +from localpost.openapi._docs import redoc_html, scalar_html, swagger_html +from localpost.openapi.operation import Operation +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["HttpApp"] + + +_FluentDecorator = Callable[[Callable[..., Any]], Callable[..., Any]] +DocsUI = Literal["swagger", "redoc", "scalar", "all"] + + +class HttpApp: + """Type-driven HTTP application that emits an OpenAPI 3.2 spec. + + Args: + info: Top-level OpenAPI :class:`Info` block. + max_concurrency: Worker-pool size used by :func:`thread_pool_handler`. + Default 32. + backlog: Extra channel buffer between the selector and the worker + pool. Default ``0`` (rendezvous). + openapi_path: URL the generated spec is served on. ``None`` to + disable. + docs_path: Base URL for the built-in doc UIs. Each UI is served + under it: ``{docs_path}`` (Swagger), ``{docs_path}/redoc``, + ``{docs_path}/scalar``. ``None`` to disable all UIs. + docs_ui: Which doc UIs to mount. Default ``"all"``. + """ + + def __init__( + self, + *, + info: openapi_spec.Info | None = None, + max_concurrency: int = 32, + backlog: int = 0, + openapi_path: str | None = "/openapi.json", + docs_path: str | None = "/docs", + docs_ui: DocsUI = "all", + ) -> None: + if max_concurrency < 1: + raise ValueError("max_concurrency must be >= 1") + if backlog < 0: + raise ValueError("backlog must be >= 0") + self._info = info or openapi_spec.Info() + self._max_concurrency = max_concurrency + self._backlog = backlog + self._openapi_path = openapi_path + self._docs_path = docs_path + self._docs_ui = docs_ui + self._operations: list[Operation] = [] + self._lock = threading.Lock() + # Cached spec; invalidated whenever an operation is added. + self._cached_spec: openapi_spec.OpenAPI | None = None + self._cached_spec_bytes: bytes | None = None + + # ----- Decorators ----- + + def get(self, path: str) -> _FluentDecorator: + return self._decorator(HTTPMethod.GET, path) + + def post(self, path: str) -> _FluentDecorator: + return self._decorator(HTTPMethod.POST, path) + + def put(self, path: str) -> _FluentDecorator: + return self._decorator(HTTPMethod.PUT, path) + + def delete(self, path: str) -> _FluentDecorator: + return self._decorator(HTTPMethod.DELETE, path) + + def patch(self, path: str) -> _FluentDecorator: + return self._decorator(HTTPMethod.PATCH, path) + + def _decorator(self, method: HTTPMethod, path: str) -> _FluentDecorator: + def deco(fn: Callable[..., Any]) -> Callable[..., Any]: + op = Operation.create(method, path, fn) + with self._lock: + self._operations.append(op) + self._cached_spec = None + self._cached_spec_bytes = None + return fn + + return deco + + # ----- Spec ----- + + @property + def operations(self) -> Sequence[Operation]: + return tuple(self._operations) + + @property + def openapi_doc(self) -> openapi_spec.OpenAPI: + """Return the (cached) OpenAPI 3.2 document for the registered ops.""" + with self._lock: + cached = self._cached_spec + if cached is not None: + return cached + registry = SchemaRegistry() + doc = openapi_spec.OpenAPI(info=self._info) + for op in self._operations: + spec_op = op.build_spec(registry) + doc = doc.add_operation(op.path, op.method.value, spec_op) + doc = doc.with_components(openapi_spec.Components(schemas=registry.components())) + self._cached_spec = doc + return doc + + def _openapi_bytes(self) -> bytes: + with self._lock: + cached = self._cached_spec_bytes + if cached is not None: + return cached + # Compute outside the lock so doc rendering doesn't hold contention. + body = self.openapi_doc.to_json() + with self._lock: + self._cached_spec_bytes = body + return body + + # ----- Hosting ----- + + def service( + self, + config: ServerConfig, + *, + selectors: int = 1, + acceptor: bool = False, + ) -> hosting.ServiceF: + """Return a :func:`localpost.hosting.service` running this app. + + Composes a worker pool (via :func:`thread_pool_handler`) and the + HTTP server (via :func:`http_server`). The user fn for each + registered operation runs on a worker after the request body is + buffered by the selector. + + ``selectors`` and ``acceptor`` forward to :func:`http_server`. + """ + max_concurrency = self._max_concurrency + backlog = self._backlog + router = self._build_router_handler() + + @hosting.service + async def _app_service(): + async with thread_pool_handler(router, max_concurrency=max_concurrency, backlog=backlog) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + + return _app_service() + + # ----- Internals ----- + + def _build_router_handler(self) -> RequestHandler: + routes = Routes() + for op in self._operations: + routes.add(op.method, op.path, op.as_handler()) + self._mount_built_in(routes) + return routes.build().as_handler() + + def _mount_built_in(self, routes: Routes) -> None: + openapi_path = self._openapi_path + if openapi_path: + self_ref = self + + def openapi_handler(ctx: HTTPReqCtx): + body = self_ref._openapi_bytes() + response = Response( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + ctx.complete(response, body) + + routes.add(HTTPMethod.GET, openapi_path, openapi_handler) + docs_path = self._docs_path + if docs_path and self._openapi_path: + ui = self._docs_ui + if ui in ("swagger", "all"): + self._add_html_route(routes, docs_path, swagger_html(self._openapi_path)) + if ui in ("redoc", "all"): + self._add_html_route(routes, f"{docs_path}/redoc", redoc_html(self._openapi_path)) + if ui in ("scalar", "all"): + self._add_html_route(routes, f"{docs_path}/scalar", scalar_html(self._openapi_path)) + + @staticmethod + def _add_html_route(routes: Routes, path: str, body: bytes) -> None: + response = Response( + status_code=200, + headers=[ + (b"content-type", b"text/html; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + + def handler(ctx: HTTPReqCtx): + ctx.complete(response, body) + + routes.add(HTTPMethod.GET, path, handler) diff --git a/localpost/openapi/filter.py b/localpost/openapi/filter.py new file mode 100644 index 0000000..1f07f7b --- /dev/null +++ b/localpost/openapi/filter.py @@ -0,0 +1,53 @@ +"""``OpFilter`` — middleware that also contributes to the OpenAPI doc. + +A filter is a callable that runs before the operation handler. It can +short-circuit the request by returning an :class:`OpResult` (e.g. a +``Unauthorized`` from auth) or return ``None`` to let the operation +proceed. + +The OpenAPI improvement vs. FastAPI: the same object that runs at request +time also describes its contribution to the OpenAPI spec — security +schemes, extra parameters, extra response codes. There's no second place +to declare auth. + +v1 ships only the protocol; concrete filters (``HttpBasicAuth``, +``HttpBearerAuth``, ``OpenIDConnectAuth``) come later. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from localpost.http.server import HTTPReqCtx + from localpost.openapi import spec + from localpost.openapi.results import OpResult + + +@runtime_checkable +class OpFilter(Protocol): + """Pre-handler middleware that knows how to describe itself in OpenAPI. + + Filters can be applied app-wide (in :class:`HttpApp(filters=...)`) or + per-operation (decorator on the same function as ``@app.get(...)``). + Either way, the filter's :meth:`update_doc` is called when the spec is + built so the doc stays consistent with the runtime behaviour. + """ + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + """Run pre-handler. Return :class:`OpResult` to short-circuit, or + ``None`` to let the operation proceed.""" + ... + + def update_doc(self, doc: spec.OpenAPI, op: spec.Operation | None = None, /) -> spec.OpenAPI: + """Contribute to the OpenAPI doc. + + Called once at spec-build time. ``op`` is ``None`` for app-level + filters (where the contribution is typically a + ``components.securitySchemes`` entry) and the relevant operation + for op-level filters (where the contribution is typically an + extra response code and a ``security`` requirement). + + Returns a new :class:`spec.OpenAPI` (the doc is immutable). + """ + ... diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py new file mode 100644 index 0000000..fd85ac5 --- /dev/null +++ b/localpost/openapi/operation.py @@ -0,0 +1,368 @@ +"""Per-operation wiring: signature parsing, return-type inference, the +``(ctx) -> Response`` closure that runs at request time. + +An :class:`Operation` is built once at decoration time and used in two +places: its :meth:`as_handler` populates the :class:`localpost.http.Routes` +table for dispatch, and its :meth:`build_spec` contributes a +:class:`localpost.openapi.spec.Operation` to the OpenAPI doc. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from http import HTTPMethod +from types import UnionType +from typing import Annotated, Any, Self, Union, get_args, get_origin, get_type_hints + +import msgspec + +from localpost.http._types import Response as _Response +from localpost.http.router import URITemplate +from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.openapi import spec as openapi_spec +from localpost.openapi.resolvers import ( + ArgResolver, + ArgResolverFactory, + FromBody, + FromPath, + FromQuery, + is_body_type, +) +from localpost.openapi.results import NoContent, Ok, OpResult +from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model + +__all__ = ["Operation"] + + +# Sentinel — we expose the request ctx for params explicitly typed as HTTPReqCtx. +def _resolve_ctx(ctx: HTTPReqCtx) -> HTTPReqCtx: + return ctx + + +@dataclass(frozen=True, slots=True) +class _ResponseShape: + """One branch of the return type — a (status, body type) pair.""" + + status_code: int + description: str + body_type: Any | None + + +@dataclass(frozen=True, slots=True) +class Operation: + method: HTTPMethod + path: str + template: URITemplate + target: Callable[..., Any] + arg_resolvers: tuple[tuple[str, ArgResolver], ...] + arg_resolver_factories: tuple[tuple[str, inspect.Parameter, ArgResolverFactory | None], ...] + """Per-parameter ``(name, inspect.Parameter, factory)``. ``factory`` is + ``None`` for the ``HTTPReqCtx`` pass-through, which contributes nothing to + the OpenAPI doc.""" + + return_shapes: tuple[_ResponseShape, ...] + summary: str + operation_id: str + description: str + + @classmethod + def create(cls, method: HTTPMethod, path: str, fn: Callable[..., Any], /) -> Self: + template = URITemplate.parse(path) + path_var_names = set(template.variable_names) + + # Resolve PEP 563 string annotations (``from __future__ import + # annotations`` is in effect for most callers) into the real types + # before feeding them to the resolver factories. We build a localns + # from the function's closure cells so types defined in an enclosing + # scope (common in tests, factories) resolve too. + localns = _closure_locals(fn) + try: + sig = inspect.signature(fn, eval_str=True, locals=localns) + except Exception: # noqa: BLE001 + sig = inspect.signature(fn) + try: + hints = get_type_hints(fn, localns=localns, include_extras=True) + except Exception: # noqa: BLE001 + hints = {} + sig = _signature_with_hints(sig, hints) + + arg_resolvers: list[tuple[str, ArgResolver]] = [] + arg_factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] + + for name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + raise ValueError(f"handler {_qualname(fn)!r}: *args / **kwargs not supported") + factory = _pick_factory(name, param, path_var_names) + if factory is None: + # HTTPReqCtx pass-through. + arg_resolvers.append((name, _resolve_ctx)) + arg_factories.append((name, param, None)) + else: + arg_resolvers.append((name, factory(param))) + arg_factories.append((name, param, factory)) + + return_shapes = tuple(_extract_response_shapes(sig.return_annotation)) + + doc = inspect.getdoc(fn) or "" + summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" + description = doc[len(summary) :].lstrip("\n") if doc else "" + operation_id = f"{method.value.lower()}_{_path_to_id(path)}" + + return cls( + method=method, + path=path, + template=template, + target=fn, + arg_resolvers=tuple(arg_resolvers), + arg_resolver_factories=tuple(arg_factories), + return_shapes=return_shapes, + summary=summary, + operation_id=operation_id, + description=description, + ) + + # ----- runtime ----- + + def as_handler(self) -> RequestHandler: + """Build the ``RequestHandler`` registered with the router. + + The pre-body phase returns a ``BodyHandler`` so that + :func:`localpost.http.thread_pool_handler` offloads execution to a + worker after the request body is buffered. The body handler runs the + full operation pipeline: arg resolvers → user fn → response build → + ``ctx.complete``. + """ + run = self._run + + def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: + return run + + return pre_body + + def _run(self, ctx: HTTPReqCtx) -> None: + kwargs: dict[str, object] = {} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + response, body = _build_http_response(value) + ctx.complete(response, body) + return + kwargs[name] = value + result = self.target(**kwargs) + if isinstance(result, _Response): + ctx.complete(result, b"") + return + if isinstance(result, OpResult): + op_result: OpResult = result + else: + op_result = Ok(result) + response, body = _build_http_response(op_result) + ctx.complete(response, body) + + # ----- spec build ----- + + def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: + op = openapi_spec.Operation( + summary=self.summary, + operation_id=self.operation_id, + description=self.description, + ) + for _name, param, factory in self.arg_resolver_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + responses = _build_responses(self.return_shapes, registry) + return replace(op, responses=responses) + + +# --- Helpers ------------------------------------------------------------- + + +def _qualname(fn: Callable[..., Any]) -> str: + return getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + + +def _closure_locals(fn: Callable[..., Any]) -> dict[str, Any]: + """Return a name → value dict from ``fn``'s closure cells. + + Used as ``localns`` for type-annotation resolution so types defined in + an enclosing function scope are visible (common in tests, model + factories). + """ + closure = getattr(fn, "__closure__", None) or () + code = getattr(fn, "__code__", None) + if not closure or code is None: + return {} + free_vars = code.co_freevars + locals_dict: dict[str, Any] = {} + for name, cell in zip(free_vars, closure, strict=False): + try: + locals_dict[name] = cell.cell_contents + except ValueError: + continue + return locals_dict + + +def _signature_with_hints(sig: inspect.Signature, hints: dict[str, Any]) -> inspect.Signature: + """Return ``sig`` with each parameter's ``annotation`` replaced by the + resolved type from ``hints``. Same for the return annotation. + + With ``from __future__ import annotations`` in effect, ``param.annotation`` + is a ``str``; we need the real type for runtime introspection. + """ + new_params = [ + param.replace(annotation=hints[name]) if name in hints else param for name, param in sig.parameters.items() + ] + return_annotation = hints.get("return", sig.return_annotation) + return sig.replace(parameters=new_params, return_annotation=return_annotation) + + +def _path_to_id(path: str) -> str: + cleaned = path.replace("/", "_").replace("{", "").replace("}", "").strip("_") + return cleaned or "root" + + +def _pick_factory( + name: str, + param: inspect.Parameter, + path_var_names: set[str], +) -> ArgResolverFactory | None: + """Return the resolver factory for one parameter, or ``None`` for the + ``HTTPReqCtx`` pass-through.""" + annotation = param.annotation + + # Explicit ``Annotated[T, FromX(...)]`` wins. + if get_origin(annotation) is Annotated: + for arg in get_args(annotation)[1:]: + if _is_resolver_factory(arg): + return arg + + target = annotation + if get_origin(target) is Annotated: + target = get_args(target)[0] + + if name in path_var_names: + return FromPath(name=name) + if target is HTTPReqCtx: + return None + if is_body_type(target): + return FromBody() + return FromQuery(name=name) + + +def _is_resolver_factory(arg: Any) -> ArgResolverFactory | None: + """Duck-typing for our :class:`ArgResolverFactory` ``Protocol``. + + We can't ``isinstance``-check it because the protocol is structural and + not ``@runtime_checkable``; resolver factories are tagged by carrying an + ``update_doc`` method on top of being callable. + """ + if callable(arg) and hasattr(arg, "update_doc"): + return arg + return None + + +def _extract_response_shapes(return_annotation: Any) -> list[_ResponseShape]: + if return_annotation is inspect.Signature.empty: + return [_ResponseShape(200, "Successful response", None)] + + origin = get_origin(return_annotation) + members = list(get_args(return_annotation)) if origin is Union or origin is UnionType else [return_annotation] + + shapes: list[_ResponseShape] = [] + seen_codes: set[int] = set() + has_success = False + for member in members: + if member is type(None): + continue + member_origin = get_origin(member) + cls = member_origin if member_origin is not None else member + if isinstance(cls, type) and issubclass(cls, OpResult): + code = cls._status_code + description = cls._description + body_type: Any | None + if cls is NoContent: + body_type = None + else: + generic_args = get_args(member) if member_origin is not None else () + body_type = generic_args[0] if generic_args else None + if code < 400: + has_success = True + elif isinstance(cls, type) and issubclass(cls, _Response): + # Bare ``Response`` escape hatch — emit nothing for this branch. + has_success = True + continue + else: + code = 200 + description = "Successful response" + body_type = member + has_success = True + if code in seen_codes: + continue + seen_codes.add(code) + shapes.append(_ResponseShape(code, description, body_type)) + + if not has_success and not shapes: + shapes.append(_ResponseShape(200, "Successful response", None)) + return shapes + + +def _build_responses(shapes: tuple[_ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: + responses: dict[str, openapi_spec.Response] = {} + for shape in shapes: + if shape.body_type is None: + responses[str(shape.status_code)] = openapi_spec.Response(description=shape.description) + continue + schema = registry.schema_for(shape.body_type) + responses[str(shape.status_code)] = openapi_spec.Response( + description=shape.description, + content={"application/json": openapi_spec.MediaType(schema=schema)}, + ) + return responses + + +# --- Response building --------------------------------------------------- + +_JSON_CONTENT_TYPE = b"application/json" +_TEXT_CONTENT_TYPE = b"text/plain; charset=utf-8" +_OCTET_CONTENT_TYPE = b"application/octet-stream" + + +def _encode_body(value: object) -> tuple[bytes, bytes]: + """Return ``(body_bytes, content_type)`` for ``value``. + + Pass-through for ``bytes`` / ``str``; pydantic models go through + ``model_dump_json``; everything else through :func:`msgspec.json.encode`. + """ + if value is None: + return b"", b"" + if isinstance(value, bytes): + return value, _OCTET_CONTENT_TYPE + if isinstance(value, bytearray): + return bytes(value), _OCTET_CONTENT_TYPE + if isinstance(value, str): + return value.encode("utf-8"), _TEXT_CONTENT_TYPE + if is_pydantic_model(type(value)): + return getattr(value, "model_dump_json")().encode("utf-8"), _JSON_CONTENT_TYPE # noqa: B009 + return msgspec.json.encode(value), _JSON_CONTENT_TYPE + + +def _build_http_response(result: OpResult) -> tuple[_Response, bytes]: + body_bytes, default_ct = _encode_body(result.body) + headers: list[tuple[bytes, bytes]] = [] + headers_seen: set[bytes] = set() + for name, value in _iter_headers(result.headers): + headers.append((name, value)) + headers_seen.add(name.lower()) + if body_bytes and b"content-type" not in headers_seen and default_ct: + headers.append((b"content-type", default_ct)) + if b"content-length" not in headers_seen: + headers.append((b"content-length", str(len(body_bytes)).encode("ascii"))) + return _Response(status_code=result.status_code, headers=headers), body_bytes + + +def _iter_headers(headers: Mapping[str, str]): + for name, value in headers.items(): + yield name.encode("ascii"), value.encode("iso-8859-1") diff --git a/localpost/openapi/pydantic.py b/localpost/openapi/pydantic.py new file mode 100644 index 0000000..2c061c4 --- /dev/null +++ b/localpost/openapi/pydantic.py @@ -0,0 +1,49 @@ +"""Pydantic interop for :mod:`localpost.openapi`. + +Pydantic is **not** a runtime dependency of localpost. To use this module, +install pydantic yourself:: + + pip install pydantic + +Once available, :class:`localpost.openapi.HttpApp` recognises pydantic +models *automatically*: a parameter annotated as ``MyModel`` is parsed +from the request body via :meth:`BaseModel.model_validate_json`, and a +return value of type ``MyModel`` is serialised via +:meth:`BaseModel.model_dump_json`. The OpenAPI schema for the model is +sourced from :meth:`BaseModel.model_json_schema`. + +This module re-exports the parsing / serialising helpers for users who +want to call them explicitly (or build custom resolvers / response +converters on top). +""" + +from __future__ import annotations + +from typing import Any + +from localpost.openapi.schemas import _PydanticBaseModel, is_pydantic_model + +__all__ = ["is_pydantic_model", "decode_body", "encode_body"] + + +def decode_body(model: type[Any], body: bytes) -> Any: + """Validate ``body`` (raw JSON bytes) into an instance of ``model``. + + ``model`` must be a :class:`pydantic.BaseModel` subclass. + + Raises :exc:`pydantic.ValidationError` on invalid input. + """ + if _PydanticBaseModel is None: + raise RuntimeError("pydantic is not installed") + if not is_pydantic_model(model): + raise TypeError(f"Expected a pydantic BaseModel subclass, got {model!r}") + return getattr(model, "model_validate_json")(body) # noqa: B009 + + +def encode_body(value: Any) -> bytes: + """Encode a pydantic model instance to JSON bytes via ``model_dump_json``.""" + if _PydanticBaseModel is None: + raise RuntimeError("pydantic is not installed") + if not is_pydantic_model(type(value)): + raise TypeError(f"Expected a pydantic BaseModel instance, got {type(value)!r}") + return getattr(value, "model_dump_json")().encode("utf-8") # noqa: B009 diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py new file mode 100644 index 0000000..94f9351 --- /dev/null +++ b/localpost/openapi/resolvers.py @@ -0,0 +1,365 @@ +"""Argument resolvers — pull function parameters from the request. + +Each resolver factory inspects an :class:`inspect.Parameter` once and +returns a runtime closure ``(HTTPReqCtx) -> object | OpResult``. Returning +an :class:`OpResult` short-circuits the operation (e.g. validation +failure) before the user function runs. + +Resolvers also describe themselves to OpenAPI via :meth:`update_doc` +— this keeps the spec aligned with the runtime behaviour without a +separate declaration step. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, Annotated, Any, Protocol, get_args, get_origin +from urllib.parse import parse_qs + +import msgspec + +from localpost.http.router import RouteMatch +from localpost.openapi import spec as openapi_spec +from localpost.openapi.results import BadRequest, OpResult +from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model + +if TYPE_CHECKING: + from localpost.http.server import HTTPReqCtx + +__all__ = [ + "ArgResolver", + "ArgResolverFactory", + "FromPath", + "FromQuery", + "FromHeader", + "FromBody", + "is_body_type", +] + + +# Sentinel attached to ctx.attrs once we've parsed the query string for +# this request, so multiple FromQuery resolvers don't re-parse. +_QUERY_CACHE_KEY = "__lp_openapi_query__" + + +class ArgResolver(Protocol): + def __call__(self, ctx: HTTPReqCtx, /) -> object | OpResult: ... + + +class ArgResolverFactory(Protocol): + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: ... + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: ... + + +# --- Helpers ------------------------------------------------------------- + + +def _unwrap_annotated(t: Any) -> Any: + """Strip ``Annotated[T, ...]`` to ``T``; return ``Any`` if no annotation.""" + if t is inspect.Parameter.empty: + return Any + if get_origin(t) is Annotated: + return get_args(t)[0] + return t + + +def _has_default(param: inspect.Parameter) -> bool: + return param.default is not inspect.Parameter.empty + + +def _cast_str(value: str, target: Any) -> Any: + """Coerce a single string value into ``target``. + + Falls back to :func:`msgspec.convert` with ``strict=False`` for the + long tail (UUID, datetime, Decimal, Enum, …). + """ + if target is str or target is Any or target is inspect.Parameter.empty: + return value + if target is int: + return int(value) + if target is float: + return float(value) + if target is bool: + return value.lower() in ("true", "1", "yes", "on") + if target is bytes: + return value.encode() + return msgspec.convert(value, type=target, strict=False) + + +def _list_element_type(t: Any) -> Any | None: + """If ``t`` is ``list[X]`` / ``Sequence[X]`` / ``set[X]``, return ``X``.""" + origin = get_origin(t) + if origin in (list, set, tuple, frozenset, Sequence): + args = get_args(t) + return args[0] if args else Any + return None + + +def is_body_type(t: Any) -> bool: + """True if ``t`` looks like something we should parse from the request body. + + Recognises :class:`msgspec.Struct` subclasses, dataclasses, ``TypedDict``, + ``NamedTuple``, and pydantic models. Primitives stay as query params. + """ + if not isinstance(t, type): + return False + if issubclass(t, msgspec.Struct): + return True + if hasattr(t, "__dataclass_fields__"): + return True + if hasattr(t, "__annotations__") and hasattr(t, "_fields"): # NamedTuple + return True + return is_pydantic_model(t) + + +def _route_match(ctx: HTTPReqCtx) -> RouteMatch: + return ctx.attrs[RouteMatch] + + +def _query_args(ctx: HTTPReqCtx) -> dict[str, list[str]]: + cached = ctx.attrs.get(_QUERY_CACHE_KEY) + if cached is not None: + return cached + qs = ctx.request.query_string.decode("iso-8859-1") if ctx.request.query_string else "" + parsed: dict[str, list[str]] = parse_qs(qs, keep_blank_values=True) if qs else {} + ctx.attrs[_QUERY_CACHE_KEY] = parsed + return parsed + + +# --- FromPath ------------------------------------------------------------ + + +@dataclass(frozen=True, slots=True) +class FromPath: + """Resolve a parameter from a URI template variable.""" + + name: str | None = None + description: str = "" + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + var_name = self.name or param.name + target = _unwrap_annotated(param.annotation) + + def resolve(ctx: HTTPReqCtx) -> object | OpResult: + raw = _route_match(ctx).path_args.get(var_name) + if raw is None: + return BadRequest(f"Missing path parameter: {var_name}") + try: + return _cast_str(raw, target) + except (ValueError, msgspec.ValidationError) as exc: + return BadRequest(f"Invalid path parameter {var_name!r}: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target = _unwrap_annotated(param.annotation) + schema = registry.schema_for(target) if target is not Any else {"type": "string"} + parameter = openapi_spec.Parameter( + name=self.name or param.name, + location="path", + required=True, + description=self.description, + schema=schema, + ) + return replace(op, parameters=(*op.parameters, parameter)) + + +# --- FromQuery ----------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class FromQuery: + """Resolve a parameter from the URL query string. + + For ``list[T]`` / ``Sequence[T]`` annotations all values for the key + are returned. For scalar annotations the *first* value is taken and + coerced. + """ + + name: str | None = None + description: str = "" + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + param_name = self.name or param.name + target = _unwrap_annotated(param.annotation) + elem_type = _list_element_type(target) + is_list = elem_type is not None + has_default = _has_default(param) + default = param.default if has_default else None + + def resolve(ctx: HTTPReqCtx) -> object | OpResult: + values = _query_args(ctx).get(param_name) + if not values: + if has_default: + return default + return BadRequest(f"Missing required query parameter: {param_name}") + try: + if is_list: + return [_cast_str(v, elem_type) for v in values] + return _cast_str(values[0], target) + except (ValueError, msgspec.ValidationError) as exc: + return BadRequest(f"Invalid query parameter {param_name!r}: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target = _unwrap_annotated(param.annotation) + schema = registry.schema_for(target) if target is not Any else {"type": "string"} + parameter = openapi_spec.Parameter( + name=self.name or param.name, + location="query", + required=not _has_default(param), + description=self.description, + schema=schema, + ) + return replace(op, parameters=(*op.parameters, parameter)) + + +# --- FromHeader ---------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class FromHeader: + """Resolve a parameter from a request header.""" + + name: str | None = None + description: str = "" + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + header_name = (self.name or param.name.replace("_", "-")).lower().encode("ascii") + target = _unwrap_annotated(param.annotation) + has_default = _has_default(param) + default = param.default if has_default else None + + def resolve(ctx: HTTPReqCtx) -> object | OpResult: + value: str | None = None + for name, val in ctx.request.headers: + if name == header_name: + value = val.decode("iso-8859-1") + break + if value is None: + if has_default: + return default + return BadRequest(f"Missing required header: {header_name.decode()}") + try: + return _cast_str(value, target) + except (ValueError, msgspec.ValidationError) as exc: + return BadRequest(f"Invalid header {header_name.decode()!r}: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target = _unwrap_annotated(param.annotation) + schema = registry.schema_for(target) if target is not Any else {"type": "string"} + parameter = openapi_spec.Parameter( + name=self.name or param.name.replace("_", "-"), + location="header", + required=not _has_default(param), + description=self.description, + schema=schema, + ) + return replace(op, parameters=(*op.parameters, parameter)) + + +# --- FromBody ------------------------------------------------------------ + + +@dataclass(frozen=True, slots=True) +class FromBody: + """Resolve a parameter from the request body. + + Defaults to JSON parsing via :func:`msgspec.json.decode`. Raw ``bytes`` + / ``str`` annotations get the body verbatim. For pydantic models, uses + :meth:`BaseModel.model_validate_json`. + """ + + content_type: str = "application/json" + description: str = "" + converter: Callable[[bytes, Any], object] | None = None + + def __call__(self, param: inspect.Parameter, /) -> ArgResolver: + target = _unwrap_annotated(param.annotation) + converter = self.converter or _default_body_converter(target) + + def resolve(ctx: HTTPReqCtx) -> object | OpResult: + try: + return converter(ctx.body, target) + except msgspec.ValidationError as exc: + return BadRequest(f"Invalid request body: {exc}") + except (ValueError, TypeError) as exc: + return BadRequest(f"Invalid request body: {exc}") + + return resolve + + def update_doc( + self, + param: inspect.Parameter, + op: openapi_spec.Operation, + registry: SchemaRegistry, + /, + ) -> openapi_spec.Operation: + target = _unwrap_annotated(param.annotation) + schema = registry.schema_for(target) if target is not Any else {} + request_body = openapi_spec.RequestBody( + description=self.description, + required=not _has_default(param), + content={self.content_type: openapi_spec.MediaType(schema=schema)}, + ) + return replace(op, request_body=request_body) + + +def _default_body_converter(target: Any) -> Callable[[bytes, Any], object]: + if target is bytes: + return _bytes_passthrough + if target is str: + return _str_decode + if is_pydantic_model(target): + return _pydantic_decode + return _msgspec_decode + + +def _bytes_passthrough(body: bytes, _target: Any) -> object: + return body + + +def _str_decode(body: bytes, _target: Any) -> object: + return body.decode("utf-8") + + +def _msgspec_decode(body: bytes, target: Any) -> object: + if not body: + raise ValueError("empty request body") + return msgspec.json.decode(body, type=target) + + +def _pydantic_decode(body: bytes, target: Any) -> object: + if not body: + raise ValueError("empty request body") + return getattr(target, "model_validate_json")(body) # noqa: B009 diff --git a/localpost/openapi/results.py b/localpost/openapi/results.py new file mode 100644 index 0000000..3316bc1 --- /dev/null +++ b/localpost/openapi/results.py @@ -0,0 +1,225 @@ +"""Operation results. + +Functions registered with :class:`localpost.openapi.HttpApp` return a +plain value (treated as ``200 OK``) or one of the :class:`OpResult` +subclasses below. The class chosen carries both the HTTP status code at +runtime *and* the type-level information the OpenAPI doc builder reads +from the function's return annotation. + +Use ``BadRequest[T]``, ``NotFound[T]`` etc. in the return annotation so +the schema generator knows the body type per status code:: + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: ... +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import ClassVar + +__all__ = [ + "OpResult", + "Ok", + "Created", + "Accepted", + "NoContent", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "Conflict", + "UnprocessableEntity", + "TooManyRequests", + "InternalServerError", +] + + +_EMPTY_HEADERS: Mapping[str, str] = MappingProxyType({}) + + +@dataclass(frozen=True, eq=False, slots=True) +class OpResult: + """Concrete operation outcome — body + status + headers. + + Subclasses (:class:`Ok`, :class:`BadRequest`, …) override + :attr:`_status_code` and :attr:`_description`. The class itself is the + type-level marker the OpenAPI doc reads — keep return annotations + using the *class*, not the instance:: + + def f() -> Book | NotFound[str]: # ✅ correct + def f() -> Book | NotFound("nope"): # ❌ instance, won't type-check + """ + + body: object + headers: Mapping[str, str] = field(default=_EMPTY_HEADERS) + + _status_code: ClassVar[int] = 200 + _description: ClassVar[str] = "Successful response" + + @property + def status_code(self) -> int: + return self._status_code + + @property + def description(self) -> str: + return self._description + + +def _normalize_headers(headers: Mapping[str, str] | None) -> Mapping[str, str]: + return MappingProxyType(dict(headers)) if headers else _EMPTY_HEADERS + + +# --- 2xx ----------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Ok[T](OpResult): + body: T + + _status_code: ClassVar[int] = 200 + _description: ClassVar[str] = "Successful response" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Created[T](OpResult): + body: T + + _status_code: ClassVar[int] = 201 + _description: ClassVar[str] = "Created" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Accepted[T](OpResult): + body: T + + _status_code: ClassVar[int] = 202 + _description: ClassVar[str] = "Accepted" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class NoContent(OpResult): + """No body. Ideal for ``DELETE`` / ``PUT`` returns.""" + + _status_code: ClassVar[int] = 204 + _description: ClassVar[str] = "No Content" + + def __init__(self, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", None) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +# --- 4xx ----------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class BadRequest[T](OpResult): + body: T + + _status_code: ClassVar[int] = 400 + _description: ClassVar[str] = "Bad Request" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Unauthorized[T](OpResult): + body: T + + _status_code: ClassVar[int] = 401 + _description: ClassVar[str] = "Unauthorized" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Forbidden[T](OpResult): + body: T + + _status_code: ClassVar[int] = 403 + _description: ClassVar[str] = "Forbidden" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class NotFound[T](OpResult): + body: T + + _status_code: ClassVar[int] = 404 + _description: ClassVar[str] = "Not Found" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class Conflict[T](OpResult): + body: T + + _status_code: ClassVar[int] = 409 + _description: ClassVar[str] = "Conflict" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class UnprocessableEntity[T](OpResult): + body: T + + _status_code: ClassVar[int] = 422 + _description: ClassVar[str] = "Unprocessable Entity" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class TooManyRequests[T](OpResult): + body: T + + _status_code: ClassVar[int] = 429 + _description: ClassVar[str] = "Too Many Requests" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + +# --- 5xx ----------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False, slots=True, init=False) +class InternalServerError[T](OpResult): + body: T + + _status_code: ClassVar[int] = 500 + _description: ClassVar[str] = "Internal Server Error" + + def __init__(self, body: T, /, *, headers: Mapping[str, str] | None = None) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) diff --git a/localpost/openapi/schemas.py b/localpost/openapi/schemas.py new file mode 100644 index 0000000..ff249dd --- /dev/null +++ b/localpost/openapi/schemas.py @@ -0,0 +1,126 @@ +"""JSON Schema generation for OpenAPI 3.2 components. + +Backed by :func:`msgspec.json.schema_components`, which understands +:class:`msgspec.Struct`, dataclasses, ``TypedDict``, ``NamedTuple``, +primitives, collections, ``Annotated[T, msgspec.Meta(...)]``, ``Literal``, +``Enum``, ``Union``, ``UUID``, ``datetime`` and so on. The output is JSON +Schema 2020-12, which is the dialect OpenAPI 3.1 / 3.2 use natively. + +Pydantic models are recognised lazily and routed through +:meth:`pydantic.BaseModel.model_json_schema` — pydantic itself is *not* an +import dependency of this module. +""" + +from __future__ import annotations + +import threading +from typing import Any + +import msgspec + +try: + from pydantic import BaseModel # type: ignore[import-not-found] + + _PydanticBaseModel: type | None = BaseModel +except ImportError: + _PydanticBaseModel = None + +__all__ = ["SchemaRegistry", "REF_TEMPLATE", "is_pydantic_model"] + + +REF_TEMPLATE = "#/components/schemas/{name}" + + +def is_pydantic_model(t: Any) -> bool: + """True if ``t`` is a subclass of :class:`pydantic.BaseModel`. + + Returns ``False`` if pydantic isn't installed. + """ + return _PydanticBaseModel is not None and isinstance(t, type) and issubclass(t, _PydanticBaseModel) + + +class SchemaRegistry: + """Accumulator for types referenced across operations. + + Call :meth:`schema_for` for each type the spec needs to describe; the + registry returns a JSON Schema fragment (``$ref`` for named types, + inline for primitives / unions). At spec emission time, call + :meth:`components` to resolve the accumulated named types into + ``components.schemas`` entries. + + The registry is thread-safe: registration is guarded so concurrent doc + builds don't race on the cached components dict. + """ + + __slots__ = ("_components", "_lock", "_msgspec_types", "_pydantic_types") + + def __init__(self) -> None: + self._lock = threading.Lock() + # Types we'll feed to msgspec.json.schema_components. We store a list + # (not a set) to preserve order — msgspec keys components by their + # name, but order matters for stable doc output. + self._msgspec_types: list[Any] = [] + self._pydantic_types: list[type] = [] + self._components: dict[str, dict[str, Any]] | None = None + + def schema_for(self, t: Any) -> dict[str, Any]: + """Return a JSON Schema fragment describing ``t``. + + For named types (:class:`msgspec.Struct`, dataclass, pydantic model, + ``TypedDict``, ``NamedTuple``, ``Enum``) this returns a ``$ref`` and + registers the type. For primitives / unions / generics the schema is + inlined. + """ + if t is None or t is type(None): + return {"type": "null"} + with self._lock: + self._components = None # invalidate + if is_pydantic_model(t): + if t not in self._pydantic_types: + self._pydantic_types.append(t) + return {"$ref": REF_TEMPLATE.format(name=t.__name__)} + self._msgspec_types.append(t) + # msgspec returns ``(schemas, components)`` — for a single type the + # ``schemas`` list has one element, which is either the inline schema + # or a ``$ref``. We discard the components dict here and re-derive + # everything in :meth:`components` so we have a single source of truth. + try: + (schema,), _ = msgspec.json.schema_components([t], ref_template=REF_TEMPLATE) + except (TypeError, RuntimeError): + # Fall back to a permissive schema for types msgspec can't introspect. + return {} + return schema + + def components(self) -> dict[str, dict[str, Any]]: + """Return the resolved ``components.schemas`` dict for every type + ever passed to :meth:`schema_for`. + + Result is cached until the next :meth:`schema_for` call. + """ + with self._lock: + if self._components is not None: + return self._components + schemas: dict[str, dict[str, Any]] = {} + if self._msgspec_types: + _, components = msgspec.json.schema_components(self._msgspec_types, ref_template=REF_TEMPLATE) + schemas.update(components) + for t in self._pydantic_types: + schemas[t.__name__] = _pydantic_json_schema(t) + self._components = schemas + return schemas + + +def _pydantic_json_schema(model: type) -> dict[str, Any]: + """Extract a pydantic model's JSON Schema, stripped of pydantic-specific + framing (``$defs``, top-level ``title`` we'd duplicate). + + Pydantic's nested ``$defs`` are inlined under ``components.schemas`` — + callers should merge them in. v1 keeps the simpler path of treating each + pydantic model as a standalone component; deep cross-model refs may need + work later. + """ + # Pydantic uses ``{model}`` as the placeholder, not ``{name}`` like msgspec. + pydantic_ref_template = REF_TEMPLATE.replace("{name}", "{model}") + raw: dict[str, Any] = getattr(model, "model_json_schema")(ref_template=pydantic_ref_template) # noqa: B009 + raw.pop("$defs", None) + return raw diff --git a/localpost/openapi/spec.py b/localpost/openapi/spec.py new file mode 100644 index 0000000..1de8d57 --- /dev/null +++ b/localpost/openapi/spec.py @@ -0,0 +1,574 @@ +"""Immutable dataclasses for the OpenAPI 3.2 specification. + +The dataclasses are version-agnostic enough that a 3.1 / 3.0 serializer +could be added later as a sibling of :meth:`OpenAPI.to_dict` without +touching the data shape. v1 emits 3.2 only. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +import msgspec + +ParameterLocation = Literal["query", "header", "path", "cookie"] +SecuritySchemeType = Literal["apiKey", "http", "oauth2", "openIdConnect", "mutualTLS"] + + +@dataclass(frozen=True, slots=True) +class Reference: + """JSON ``$ref`` to another component.""" + + ref: str + summary: str = "" + description: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"$ref": self.ref} + if self.summary: + d["summary"] = self.summary + if self.description: + d["description"] = self.description + return d + + +@dataclass(frozen=True, slots=True) +class Contact: + name: str = "" + url: str = "" + email: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.name: + d["name"] = self.name + if self.url: + d["url"] = self.url + if self.email: + d["email"] = self.email + return d + + +@dataclass(frozen=True, slots=True) +class License: + name: str + identifier: str = "" + url: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"name": self.name} + if self.identifier: + d["identifier"] = self.identifier + if self.url: + d["url"] = self.url + return d + + +@dataclass(frozen=True, slots=True) +class Info: + title: str = "API" + version: str = "0.1.0" + summary: str = "" + description: str = "" + terms_of_service: str = "" + contact: Contact | None = None + license: License | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"title": self.title, "version": self.version} + if self.summary: + d["summary"] = self.summary + if self.description: + d["description"] = self.description + if self.terms_of_service: + d["termsOfService"] = self.terms_of_service + if self.contact: + d["contact"] = self.contact.to_dict() + if self.license: + d["license"] = self.license.to_dict() + return d + + +@dataclass(frozen=True, slots=True) +class ServerVariable: + default: str + enum: tuple[str, ...] = () + description: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"default": self.default} + if self.enum: + d["enum"] = list(self.enum) + if self.description: + d["description"] = self.description + return d + + +@dataclass(frozen=True, slots=True) +class Server: + url: str + description: str = "" + variables: dict[str, ServerVariable] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"url": self.url} + if self.description: + d["description"] = self.description + if self.variables: + d["variables"] = {k: v.to_dict() for k, v in self.variables.items()} + return d + + +@dataclass(frozen=True, slots=True) +class ExternalDocs: + url: str + description: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"url": self.url} + if self.description: + d["description"] = self.description + return d + + +@dataclass(frozen=True, slots=True) +class Tag: + name: str + description: str = "" + summary: str = "" + external_docs: ExternalDocs | None = None + parent: str = "" + """3.2 addition: name of the parent tag for nested grouping.""" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"name": self.name} + if self.summary: + d["summary"] = self.summary + if self.description: + d["description"] = self.description + if self.external_docs: + d["externalDocs"] = self.external_docs.to_dict() + if self.parent: + d["parent"] = self.parent + return d + + +@dataclass(frozen=True, slots=True) +class TagGroup: + """3.2 addition: tag group declared at the root level.""" + + name: str + tags: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "tags": list(self.tags)} + + +@dataclass(frozen=True, slots=True) +class Encoding: + content_type: str = "" + headers: dict[str, Header | Reference] = field(default_factory=dict) + style: str = "" + explode: bool | None = None + allow_reserved: bool = False + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.content_type: + d["contentType"] = self.content_type + if self.headers: + d["headers"] = {k: v.to_dict() for k, v in self.headers.items()} + if self.style: + d["style"] = self.style + if self.explode is not None: + d["explode"] = self.explode + if self.allow_reserved: + d["allowReserved"] = True + return d + + +@dataclass(frozen=True, slots=True) +class Example: + summary: str = "" + description: str = "" + value: Any = None + external_value: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.summary: + d["summary"] = self.summary + if self.description: + d["description"] = self.description + if self.value is not None: + d["value"] = self.value + if self.external_value: + d["externalValue"] = self.external_value + return d + + +@dataclass(frozen=True, slots=True) +class MediaType: + schema: dict[str, Any] = field(default_factory=dict) + example: Any = None + examples: dict[str, Example | Reference] = field(default_factory=dict) + encoding: dict[str, Encoding] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.schema: + d["schema"] = self.schema + if self.example is not None: + d["example"] = self.example + if self.examples: + d["examples"] = {k: v.to_dict() for k, v in self.examples.items()} + if self.encoding: + d["encoding"] = {k: v.to_dict() for k, v in self.encoding.items()} + return d + + +@dataclass(frozen=True, slots=True) +class Header: + description: str = "" + required: bool = False + deprecated: bool = False + schema: dict[str, Any] = field(default_factory=dict) + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.description: + d["description"] = self.description + if self.required: + d["required"] = True + if self.deprecated: + d["deprecated"] = True + if self.schema: + d["schema"] = self.schema + if self.content: + d["content"] = {k: v.to_dict() for k, v in self.content.items()} + return d + + +@dataclass(frozen=True, slots=True) +class Parameter: + name: str + location: ParameterLocation = "query" + required: bool = False + description: str = "" + deprecated: bool = False + schema: dict[str, Any] = field(default_factory=dict) + style: str = "" + explode: bool | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"name": self.name, "in": self.location} + if self.required: + d["required"] = True + if self.description: + d["description"] = self.description + if self.deprecated: + d["deprecated"] = True + if self.schema: + d["schema"] = self.schema + if self.style: + d["style"] = self.style + if self.explode is not None: + d["explode"] = self.explode + return d + + +@dataclass(frozen=True, slots=True) +class RequestBody: + description: str = "" + required: bool = False + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.description: + d["description"] = self.description + if self.required: + d["required"] = True + if self.content: + d["content"] = {k: v.to_dict() for k, v in self.content.items()} + return d + + +@dataclass(frozen=True, slots=True) +class Response: + description: str = "" + headers: dict[str, Header | Reference] = field(default_factory=dict) + content: dict[str, MediaType] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"description": self.description} + if self.headers: + d["headers"] = {k: v.to_dict() for k, v in self.headers.items()} + if self.content: + d["content"] = {k: v.to_dict() for k, v in self.content.items()} + return d + + +SecurityRequirement = dict[str, tuple[str, ...]] +"""``{schemeName: (scope1, scope2, ...)}`` — multiple keys = AND; multiple +elements in the surrounding tuple = OR.""" + + +@dataclass(frozen=True, slots=True) +class Operation: + summary: str = "" + operation_id: str = "" + description: str = "" + parameters: tuple[Parameter | Reference, ...] = () + request_body: RequestBody | Reference | None = None + responses: dict[str, Response | Reference] = field(default_factory=dict) + deprecated: bool = False + tags: tuple[str, ...] = () + security: tuple[SecurityRequirement, ...] = () + servers: tuple[Server, ...] = () + external_docs: ExternalDocs | None = None + callbacks: dict[str, dict[str, Any]] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.summary: + d["summary"] = self.summary + if self.operation_id: + d["operationId"] = self.operation_id + if self.description: + d["description"] = self.description + if self.tags: + d["tags"] = list(self.tags) + if self.parameters: + d["parameters"] = [p.to_dict() for p in self.parameters] + if self.request_body is not None: + d["requestBody"] = self.request_body.to_dict() + if self.responses: + d["responses"] = {code: r.to_dict() for code, r in self.responses.items()} + if self.callbacks: + d["callbacks"] = dict(self.callbacks) + if self.deprecated: + d["deprecated"] = True + if self.security: + d["security"] = [{k: list(v) for k, v in req.items()} for req in self.security] + if self.servers: + d["servers"] = [s.to_dict() for s in self.servers] + if self.external_docs: + d["externalDocs"] = self.external_docs.to_dict() + return d + + +@dataclass(frozen=True, slots=True) +class PathItem: + summary: str = "" + description: str = "" + operations: dict[str, Operation] = field(default_factory=dict) + """Keyed by lowercase HTTP method: get, post, put, delete, patch, + head, options, trace, query (3.2).""" + parameters: tuple[Parameter | Reference, ...] = () + servers: tuple[Server, ...] = () + ref: str = "" + + def to_dict(self) -> dict[str, Any]: + if self.ref: + return {"$ref": self.ref} + d: dict[str, Any] = {} + if self.summary: + d["summary"] = self.summary + if self.description: + d["description"] = self.description + for method, op in self.operations.items(): + d[method] = op.to_dict() + if self.parameters: + d["parameters"] = [p.to_dict() for p in self.parameters] + if self.servers: + d["servers"] = [s.to_dict() for s in self.servers] + return d + + +# --- Security schemes ----------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class OAuthFlow: + scopes: dict[str, str] = field(default_factory=dict) + authorization_url: str = "" + token_url: str = "" + refresh_url: str = "" + device_authorization_url: str = "" + """3.2 addition: device-authorization URL for the device flow.""" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"scopes": dict(self.scopes)} + if self.authorization_url: + d["authorizationUrl"] = self.authorization_url + if self.token_url: + d["tokenUrl"] = self.token_url + if self.refresh_url: + d["refreshUrl"] = self.refresh_url + if self.device_authorization_url: + d["deviceAuthorizationUrl"] = self.device_authorization_url + return d + + +@dataclass(frozen=True, slots=True) +class OAuthFlows: + implicit: OAuthFlow | None = None + password: OAuthFlow | None = None + client_credentials: OAuthFlow | None = None + authorization_code: OAuthFlow | None = None + device_authorization: OAuthFlow | None = None + """3.2 addition.""" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.implicit: + d["implicit"] = self.implicit.to_dict() + if self.password: + d["password"] = self.password.to_dict() + if self.client_credentials: + d["clientCredentials"] = self.client_credentials.to_dict() + if self.authorization_code: + d["authorizationCode"] = self.authorization_code.to_dict() + if self.device_authorization: + d["deviceAuthorization"] = self.device_authorization.to_dict() + return d + + +@dataclass(frozen=True, slots=True) +class SecurityScheme: + type: SecuritySchemeType + description: str = "" + name: str = "" + """``apiKey``: parameter name.""" + location: Literal["query", "header", "cookie", ""] = "" + """``apiKey``: where the key lives.""" + scheme: str = "" + """``http``: e.g. ``basic`` / ``bearer``.""" + bearer_format: str = "" + flows: OAuthFlows | None = None + open_id_connect_url: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"type": self.type} + if self.description: + d["description"] = self.description + if self.name: + d["name"] = self.name + if self.location: + d["in"] = self.location + if self.scheme: + d["scheme"] = self.scheme + if self.bearer_format: + d["bearerFormat"] = self.bearer_format + if self.flows: + d["flows"] = self.flows.to_dict() + if self.open_id_connect_url: + d["openIdConnectUrl"] = self.open_id_connect_url + return d + + +@dataclass(frozen=True, slots=True) +class Components: + schemas: dict[str, dict[str, Any]] = field(default_factory=dict) + responses: dict[str, Response | Reference] = field(default_factory=dict) + parameters: dict[str, Parameter | Reference] = field(default_factory=dict) + request_bodies: dict[str, RequestBody | Reference] = field(default_factory=dict) + headers: dict[str, Header | Reference] = field(default_factory=dict) + security_schemes: dict[str, SecurityScheme | Reference] = field(default_factory=dict) + examples: dict[str, Example | Reference] = field(default_factory=dict) + path_items: dict[str, PathItem] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.schemas: + d["schemas"] = dict(self.schemas) + if self.responses: + d["responses"] = {k: v.to_dict() for k, v in self.responses.items()} + if self.parameters: + d["parameters"] = {k: v.to_dict() for k, v in self.parameters.items()} + if self.request_bodies: + d["requestBodies"] = {k: v.to_dict() for k, v in self.request_bodies.items()} + if self.headers: + d["headers"] = {k: v.to_dict() for k, v in self.headers.items()} + if self.security_schemes: + d["securitySchemes"] = {k: v.to_dict() for k, v in self.security_schemes.items()} + if self.examples: + d["examples"] = {k: v.to_dict() for k, v in self.examples.items()} + if self.path_items: + d["pathItems"] = {k: v.to_dict() for k, v in self.path_items.items()} + return d + + def is_empty(self) -> bool: + return not ( + self.schemas + or self.responses + or self.parameters + or self.request_bodies + or self.headers + or self.security_schemes + or self.examples + or self.path_items + ) + + +@dataclass(frozen=True, slots=True) +class OpenAPI: + """Root of an OpenAPI 3.2 document.""" + + openapi: str = "3.2.0" + info: Info = field(default_factory=Info) + servers: tuple[Server, ...] = () + paths: dict[str, PathItem] = field(default_factory=dict) + webhooks: dict[str, PathItem | Reference] = field(default_factory=dict) + components: Components = field(default_factory=Components) + security: tuple[SecurityRequirement, ...] = () + tags: tuple[Tag, ...] = () + tag_groups: tuple[TagGroup, ...] = () + """3.2 addition.""" + external_docs: ExternalDocs | None = None + json_schema_dialect: str = "" + + def add_operation(self, path: str, method: str, operation: Operation) -> OpenAPI: + """Return a new :class:`OpenAPI` with ``operation`` registered at + ``method`` (case-insensitive) on ``path``.""" + m = method.lower() + new_paths = dict(self.paths) + existing = new_paths.get(path) + if existing is None: + new_paths[path] = PathItem(operations={m: operation}) + else: + new_ops = dict(existing.operations) + new_ops[m] = operation + new_paths[path] = replace(existing, operations=new_ops) + return replace(self, paths=new_paths) + + def with_components(self, components: Components) -> OpenAPI: + return replace(self, components=components) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"openapi": self.openapi, "info": self.info.to_dict()} + if self.json_schema_dialect: + d["jsonSchemaDialect"] = self.json_schema_dialect + if self.servers: + d["servers"] = [s.to_dict() for s in self.servers] + if self.paths: + d["paths"] = {p: item.to_dict() for p, item in self.paths.items()} + if self.webhooks: + d["webhooks"] = {k: v.to_dict() for k, v in self.webhooks.items()} + if not self.components.is_empty(): + d["components"] = self.components.to_dict() + if self.security: + d["security"] = [{k: list(v) for k, v in req.items()} for req in self.security] + if self.tags: + d["tags"] = [t.to_dict() for t in self.tags] + if self.tag_groups: + d["tagGroups"] = [g.to_dict() for g in self.tag_groups] + if self.external_docs: + d["externalDocs"] = self.external_docs.to_dict() + return d + + def to_json(self) -> bytes: + return msgspec.json.encode(self.to_dict()) diff --git a/pyproject.toml b/pyproject.toml index 0f37db8..aed3ec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,10 @@ http-fast = [ # NOTE! Under free-threaded CPython, 0.7.1 and below auto-re-enables the GIL on import. "httptools @ git+https://github.com/MagicStack/httptools.git", ] +openapi = [ +# "localpost[http]", + "msgspec ~=0.19", +] [dependency-groups] dev = [ @@ -82,6 +86,12 @@ dev-http = [ "flask ~=3.1", "httpx", ] +dev-openapi = [ + # Pydantic is supported via localpost.openapi.pydantic but is NOT a runtime + # dep of the openapi extra — end users install pydantic themselves. We pull + # it in here so our own examples and tests can exercise that path. + "pydantic ~=2.7", +] dev-sentry = [ "sentry-sdk ~=2.51", ] diff --git a/tests/openapi/__init__.py b/tests/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/openapi/app.py b/tests/openapi/app.py new file mode 100644 index 0000000..1965af1 --- /dev/null +++ b/tests/openapi/app.py @@ -0,0 +1,419 @@ +"""Tests for ``localpost.openapi.HttpApp`` registration + dispatch. + +Operation handlers are exercised in isolation: we hand each ``Operation`` +a fake :class:`HTTPReqCtx`-like object, run it, and inspect the captured +``Response`` + body bytes. The full HTTP server path is covered by the +integration tests. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from http import HTTPMethod +from typing import Annotated, Any + +import msgspec +import pytest + +from localpost.http import RouteMatch, URITemplate +from localpost.http._types import Request, Response +from localpost.openapi import ( + BadRequest, + Created, + FromHeader, + FromPath, + HttpApp, + NoContent, + NotFound, +) +from localpost.openapi.operation import Operation + + +# --- Fakes --------------------------------------------------------------- + + +@dataclass +class FakeCtx: + """Minimal stand-in for :class:`HTTPReqCtx` for unit tests.""" + + request: Request + body: bytes = b"" + attrs: dict[Any, Any] = field(default_factory=dict) + completed: tuple[Response, bytes] | None = None + + def complete(self, response: Response, body: bytes = b"") -> None: + self.completed = (response, body) + + +def make_ctx( + method: str = "GET", + path: str = "/", + query: bytes = b"", + headers: list[tuple[bytes, bytes]] | None = None, + body: bytes = b"", + path_args: Mapping[str, str] | None = None, + template: URITemplate | None = None, +) -> FakeCtx: + request = Request( + method=method.encode(), + target=path.encode(), + path=path.encode(), + query_string=query, + headers=headers or [], + http_version=b"1.1", + ) + ctx = FakeCtx(request=request, body=body) + if path_args is not None: + ctx.attrs[RouteMatch] = RouteMatch( + method=HTTPMethod(method), + matched_template=template or URITemplate.parse(path), + path_args=dict(path_args), + ) + return ctx + + +def run_op(op: Operation, ctx: FakeCtx) -> tuple[int, bytes, dict[str, str]]: + op._run(ctx) + assert ctx.completed is not None, "handler did not complete the response" + response, body = ctx.completed + headers = {k.decode(): v.decode("iso-8859-1") for k, v in response.headers} + return response.status_code, body, headers + + +# --- Sample types -------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +# Pydantic Pet lives at module scope so PEP 563 string annotations resolve via +# ``get_type_hints`` (closure cells aren't created for annotation-only refs). +try: + from pydantic import BaseModel + + class Pet(BaseModel): + name: str + age: int + +except ImportError: + Pet = None # type: ignore[assignment,misc] + + +# --- Tests --------------------------------------------------------------- + + +class TestRegistration: + def test_get_decorator_records_operation(self): + app = HttpApp() + + @app.get("/foo") + def foo() -> str: + return "ok" + + assert len(app.operations) == 1 + op = app.operations[0] + assert op.method is HTTPMethod.GET + assert op.path == "/foo" + + def test_multiple_operations_on_same_path(self): + app = HttpApp() + + @app.get("/foo") + def get_foo() -> str: + return "g" + + @app.post("/foo") + def post_foo() -> str: + return "p" + + ops = app.operations + assert {o.method for o in ops} == {HTTPMethod.GET, HTTPMethod.POST} + + def test_path_var_resolved_implicitly(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str: + return item_id + + op = app.operations[0] + ctx = make_ctx(path="/items/abc", path_args={"item_id": "abc"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"abc" + + def test_query_param_implicit_when_not_in_path(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str, page: int = 1) -> str: + return f"{item_id}/{page}" + + op = app.operations[0] + ctx = make_ctx(path="/items/x", query=b"page=3", path_args={"item_id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"x/3" + + +class TestReturnTypeInference: + def test_str_return_emits_text_plain(self): + app = HttpApp() + + @app.get("/hi") + def hi() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/hi") + status, body, headers = run_op(op, ctx) + assert status == 200 + assert body == b"hello" + assert headers["content-type"].startswith("text/plain") + + def test_dataclass_return_emits_json(self): + app = HttpApp() + + @app.get("/b") + def get() -> Book: + return Book(id="1", title="t") + + op = app.operations[0] + status, body, headers = run_op(op, make_ctx(path="/b")) + assert status == 200 + assert msgspec.json.decode(body) == {"id": "1", "title": "t"} + assert headers["content-type"] == "application/json" + + def test_op_result_subclass_uses_its_status(self): + app = HttpApp() + + @app.get("/b/{id}") + def get(id: str) -> Book | NotFound[str]: + return NotFound(f"missing: {id}") + + op = app.operations[0] + ctx = make_ctx(path="/b/x", path_args={"id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 404 + assert body == b"missing: x" + + def test_no_content_emits_empty_body(self): + app = HttpApp() + + @app.delete("/b/{id}") + def delete(id: str) -> NoContent: + return NoContent() + + op = app.operations[0] + ctx = make_ctx(method="DELETE", path="/b/x", path_args={"id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 204 + assert body == b"" + + def test_created_with_custom_headers(self): + app = HttpApp() + + @app.post("/b") + def create() -> Created[Book]: + return Created(Book(id="1", title="t"), headers={"Location": "/b/1"}) + + op = app.operations[0] + status, body, headers = run_op(op, make_ctx(method="POST", path="/b")) + assert status == 201 + assert headers["Location"] == "/b/1" + assert msgspec.json.decode(body) == {"id": "1", "title": "t"} + + +class TestFromBody: + def test_dataclass_body_is_parsed(self): + app = HttpApp() + + @app.post("/b") + def create(book: Book) -> Book: + return book + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/b", body=b'{"id":"1","title":"X"}') + status, body, _ = run_op(op, ctx) + assert status == 200 + assert msgspec.json.decode(body) == {"id": "1", "title": "X"} + + def test_invalid_body_returns_bad_request(self): + app = HttpApp() + + @app.post("/b") + def create(book: Book) -> Book: + return book + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/b", body=b"not json") + status, body, _ = run_op(op, ctx) + assert status == 400 + assert b"Invalid request body" in body + + +class TestFromHeader: + def test_header_resolved_with_explicit_annotation(self): + app = HttpApp() + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")]) -> str: + return user_id + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"x-user-id", b"alice")]) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"alice" + + def test_missing_required_header_returns_bad_request(self): + app = HttpApp() + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")]) -> str: + return user_id + + op = app.operations[0] + ctx = make_ctx(path="/me") + status, body, _ = run_op(op, ctx) + assert status == 400 + assert b"Missing required header" in body + + def test_header_default_used_when_absent(self): + app = HttpApp() + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")] = "anon") -> str: + return user_id + + op = app.operations[0] + ctx = make_ctx(path="/me") + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"anon" + + +class TestPathTypeCoercion: + def test_int_path_arg_coerced(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get(item_id: int) -> int: + return item_id * 2 + + op = app.operations[0] + ctx = make_ctx(path="/items/21", path_args={"item_id": "21"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert msgspec.json.decode(body) == 42 + + def test_invalid_int_path_arg_returns_bad_request(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get(item_id: int) -> int: + return item_id + + op = app.operations[0] + ctx = make_ctx(path="/items/x", path_args={"item_id": "x"}) + status, body, _ = run_op(op, ctx) + assert status == 400 + assert b"Invalid path parameter" in body + + +class TestExplicitFromPath: + def test_explicit_from_path_overrides_query_default(self): + # Same param name as a path var → FromPath is implicit; this + # checks the explicit form still wires up correctly. + app = HttpApp() + + @app.get("/items/{item_id}") + def get(item_id: Annotated[str, FromPath()]) -> str: + return item_id + + op = app.operations[0] + ctx = make_ctx(path="/items/abc", path_args={"item_id": "abc"}) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"abc" + + +class TestOpenAPIDocCaching: + def test_doc_cached_until_new_op_registered(self): + app = HttpApp() + + @app.get("/a") + def a() -> str: + return "a" + + doc1 = app.openapi_doc + doc2 = app.openapi_doc + assert doc1 is doc2 + + @app.get("/b") + def b() -> str: + return "b" + + doc3 = app.openapi_doc + assert doc3 is not doc1 + assert "/b" in doc3.paths + + +class TestOpenAPIDocStructure: + def test_response_schemas_per_status(self): + app = HttpApp() + + @app.get("/b/{id}") + def get(id: str) -> Book | NotFound[str] | BadRequest[str]: + return Book(id=id, title="x") + + doc = app.openapi_doc.to_dict() + responses = doc["paths"]["/b/{id}"]["get"]["responses"] + assert set(responses) == {"200", "400", "404"} + assert responses["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/Book") + assert responses["404"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_request_body_schema_emitted(self): + app = HttpApp() + + @app.post("/b") + def create(book: Book) -> Book: + return book + + doc = app.openapi_doc.to_dict() + request_body = doc["paths"]["/b"]["post"]["requestBody"] + assert request_body["required"] is True + schema = request_body["content"]["application/json"]["schema"] + assert schema["$ref"].endswith("/Book") + + def test_components_populated(self): + app = HttpApp() + + @app.get("/b/{id}") + def get(id: str) -> Book: + return Book(id=id, title="x") + + doc = app.openapi_doc.to_dict() + assert "Book" in doc["components"]["schemas"] + + +class TestPydantic: + def test_pydantic_body_is_parsed_and_serialized(self): + pytest.importorskip("pydantic") + + app = HttpApp() + + @app.post("/pets") + def create(pet: Pet) -> Pet: + return pet + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/pets", body=b'{"name":"Rex","age":3}') + status, body, headers = run_op(op, ctx) + assert status == 200, body + assert msgspec.json.decode(body) == {"name": "Rex", "age": 3} + assert headers["content-type"] == "application/json" diff --git a/tests/openapi/conftest.py b/tests/openapi/conftest.py new file mode 100644 index 0000000..252e145 --- /dev/null +++ b/tests/openapi/conftest.py @@ -0,0 +1,96 @@ +"""Test fixtures for ``localpost.openapi`` integration tests. + +Tests use a synchronous in-thread server (no worker pool, no anyio host) +to keep the harness small. The operation handlers run on the selector +thread; this is fine for tests since the handlers are short and +non-blocking. +""" + +from __future__ import annotations + +import socket +import threading +from collections.abc import Callable, Iterator + +import pytest + +from localpost.http import RequestHandler, ServerConfig, start_http_server +from localpost.openapi import HttpApp + + +@pytest.fixture +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _ServerCM: + def __init__(self, config: ServerConfig, handler: RequestHandler) -> None: + self._stop = threading.Event() + self._cm = start_http_server(config, handler) + self._thread: threading.Thread | None = None + self._error: BaseException | None = None + + def __enter__(self) -> int: + server = self._cm.__enter__() + stop = self._stop + + def loop() -> None: + while not stop.is_set(): + try: + server.run(timeout=0.05) + except OSError: + return + except BaseException as e: # noqa: BLE001 + self._error = e + return + + self._thread = threading.Thread(target=loop, daemon=True) + self._thread.start() + return server.port + + def __exit__(self, exc_type, exc, tb) -> None: + self._stop.set() + try: + t = self._thread + assert t is not None + t.join(timeout=5) + finally: + self._cm.__exit__(exc_type, exc, tb) + if exc_type is None and self._error is not None: + raise self._error + + +ServeApp = Callable[[HttpApp], _ServerCM] + + +@pytest.fixture +def serve_app() -> Iterator[ServeApp]: + """Spin up an :class:`HttpApp` on a random local port. Use as ``with``:: + + def test_x(serve_app): + app = HttpApp() + ... + with serve_app(app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/...") + """ + active: list[_ServerCM] = [] + + def make(app: HttpApp) -> _ServerCM: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + config = ServerConfig(host="127.0.0.1", port=port) + handler = app._build_router_handler() + cm = _ServerCM(config, handler) + active.append(cm) + return cm + + yield make + + for cm in active: + t = cm._thread + if t is not None and t.is_alive(): + cm._stop.set() + t.join(timeout=5) diff --git a/tests/openapi/integration.py b/tests/openapi/integration.py new file mode 100644 index 0000000..3a6eb9d --- /dev/null +++ b/tests/openapi/integration.py @@ -0,0 +1,181 @@ +"""End-to-end HTTP tests against a real ``localpost.openapi.HttpApp``. + +These spin up the server in-thread (no worker pool — see +``tests/openapi/conftest.py``) and exercise it with httpx. They cover the +plumbing tests in ``tests/openapi/app.py`` can't reach: actual socket +I/O, the built-in ``/openapi.json`` and ``/docs`` routes, and round-trip +serialisation of the OpenAPI 3.2 doc. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Annotated + +import httpx +import pytest + +from localpost.openapi import ( + BadRequest, + Created, + FromHeader, + HttpApp, + NotFound, + spec, +) + + +@dataclass +class Book: + id: str + title: str + + +@pytest.fixture +def library_app() -> HttpApp: + app = HttpApp(info=spec.Info(title="Library API", version="1.0.0")) + library: dict[str, Book] = { + "42": Book(id="42", title="HHGTTG"), + } + + @app.get("/hello/{name}") + def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: + book = library.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + @app.post("/books") + def create_book(book: Book) -> Created[Book]: + library[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + @app.get("/me") + def me(user_id: Annotated[str, FromHeader("X-User-Id")]) -> str: + return user_id + + return app + + +class TestPathAndQuery: + def test_hello_world(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + assert resp.status_code == 200 + assert resp.text == "Hello, world!" + assert resp.headers["content-type"].startswith("text/plain") + + def test_bad_request_short_circuit(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/Donald", timeout=5) + assert resp.status_code == 400 + assert resp.text == "Sorry, you are not welcome here" + + +class TestOpResultUnion: + def test_200_branch(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/42", timeout=5) + assert resp.status_code == 200 + assert resp.json() == {"id": "42", "title": "HHGTTG"} + + def test_404_branch(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/missing", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Book not found: missing" + + +class TestRequestBody: + def test_json_body_and_created_response(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + json={"id": "7", "title": "Dune"}, + timeout=5, + ) + assert resp.status_code == 201 + assert resp.headers["Location"] == "/books/7" + assert resp.json() == {"id": "7", "title": "Dune"} + + def test_invalid_body(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + content=b"not json", + headers={"Content-Type": "application/json"}, + timeout=5, + ) + assert resp.status_code == 400 + assert b"Invalid request body" in resp.content + + +class TestHeaderResolver: + def test_header_present(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get( + f"http://127.0.0.1:{port}/me", + headers={"X-User-Id": "alice"}, + timeout=5, + ) + assert resp.status_code == 200 + assert resp.text == "alice" + + def test_missing_header(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/me", timeout=5) + assert resp.status_code == 400 + assert b"Missing required header" in resp.content + + +class TestBuiltInRoutes: + def test_openapi_json_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + doc = resp.json() + assert doc["openapi"] == "3.2.0" + assert doc["info"]["title"] == "Library API" + assert "/hello/{name}" in doc["paths"] + assert "/books/{book_id}" in doc["paths"] + # Per-status response schemas land in the doc. + responses = doc["paths"]["/books/{book_id}"]["get"]["responses"] + assert set(responses) == {"200", "404"} + # Components for named types. + assert "Book" in doc["components"]["schemas"] + + def test_swagger_ui_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs", timeout=5) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + assert b"Swagger UI" in resp.content + + def test_redoc_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs/redoc", timeout=5) + assert resp.status_code == 200 + assert b"redoc" in resp.content.lower() + + def test_scalar_served(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs/scalar", timeout=5) + assert resp.status_code == 200 + assert b"scalar" in resp.content.lower() + + +class TestOpenAPIDocStructure: + def test_openapi_doc_is_valid_json(self, serve_app, library_app): + with serve_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + # Round-trip check. + doc = json.loads(resp.content) + assert doc["openapi"].startswith("3.2") diff --git a/tests/openapi/schemas.py b/tests/openapi/schemas.py new file mode 100644 index 0000000..1d7ac6a --- /dev/null +++ b/tests/openapi/schemas.py @@ -0,0 +1,130 @@ +"""Tests for the msgspec-backed JSON Schema registry.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Literal + +import msgspec +import pytest + +from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry, is_pydantic_model + + +@dataclass +class Book: + id: str + title: str + + +class Author(msgspec.Struct): + name: str + age: int + + +class Status(str, Enum): + OPEN = "open" + CLOSED = "closed" + + +class TestPrimitives: + def test_int(self): + registry = SchemaRegistry() + assert registry.schema_for(int) == {"type": "integer"} + + def test_str(self): + registry = SchemaRegistry() + assert registry.schema_for(str) == {"type": "string"} + + def test_bool(self): + registry = SchemaRegistry() + assert registry.schema_for(bool) == {"type": "boolean"} + + def test_none(self): + registry = SchemaRegistry() + assert registry.schema_for(None) == {"type": "null"} + assert registry.schema_for(type(None)) == {"type": "null"} + + +class TestNamedTypes: + def test_dataclass_returns_ref(self): + registry = SchemaRegistry() + schema = registry.schema_for(Book) + assert schema == {"$ref": REF_TEMPLATE.format(name="Book")} + + def test_struct_returns_ref(self): + registry = SchemaRegistry() + schema = registry.schema_for(Author) + assert schema == {"$ref": REF_TEMPLATE.format(name="Author")} + + def test_components_includes_referenced_types(self): + registry = SchemaRegistry() + registry.schema_for(Book) + registry.schema_for(Author) + components = registry.components() + + assert "Book" in components + assert "Author" in components + assert components["Book"]["type"] == "object" + assert "title" in components["Book"]["properties"] + + +class TestEnums: + def test_str_enum_emits_values(self): + registry = SchemaRegistry() + registry.schema_for(Status) + components = registry.components() + assert "Status" in components + assert set(components["Status"]["enum"]) == {"open", "closed"} + + +class TestLiteral: + def test_literal_inlines_enum(self): + registry = SchemaRegistry() + schema = registry.schema_for(Literal["a", "b"]) + # Literal types are inlined by msgspec, not registered as components. + assert "enum" in schema or "const" in schema or "$ref" in schema + + +class TestComponentCacheInvalidation: + def test_components_recomputed_after_new_registration(self): + registry = SchemaRegistry() + registry.schema_for(Book) + assert "Book" in registry.components() + + registry.schema_for(Author) + components = registry.components() + assert "Book" in components + assert "Author" in components + + +class TestPydanticDetection: + def test_dataclass_is_not_pydantic(self): + assert is_pydantic_model(Book) is False + + def test_msgspec_struct_is_not_pydantic(self): + assert is_pydantic_model(Author) is False + + def test_pydantic_model_detected(self): + BaseModel = pytest.importorskip("pydantic").BaseModel + + class Pet(BaseModel): + name: str + + assert is_pydantic_model(Pet) is True + + def test_pydantic_schema_registered_in_components(self): + BaseModel = pytest.importorskip("pydantic").BaseModel + + class Pet(BaseModel): + name: str + age: int + + registry = SchemaRegistry() + schema = registry.schema_for(Pet) + assert schema == {"$ref": REF_TEMPLATE.format(name="Pet")} + + components = registry.components() + assert "Pet" in components + assert "name" in components["Pet"]["properties"] diff --git a/tests/openapi/spec.py b/tests/openapi/spec.py new file mode 100644 index 0000000..579d088 --- /dev/null +++ b/tests/openapi/spec.py @@ -0,0 +1,108 @@ +"""Round-trip tests for the OpenAPI 3.2 dataclasses.""" + +from __future__ import annotations + +import json + +from localpost.openapi import spec + + +class TestOpenAPIVersion: + def test_default_version_is_3_2(self): + assert spec.OpenAPI().openapi == "3.2.0" + + +class TestOperationOnPath: + def test_add_operation_creates_path_item(self): + doc = spec.OpenAPI() + doc = doc.add_operation("/foo", "get", spec.Operation(operation_id="get_foo")) + + assert "/foo" in doc.paths + assert "get" in doc.paths["/foo"].operations + assert doc.paths["/foo"].operations["get"].operation_id == "get_foo" + + def test_add_operation_returns_new_instance(self): + doc = spec.OpenAPI() + doc2 = doc.add_operation("/foo", "get", spec.Operation()) + + assert doc is not doc2 + assert "/foo" not in doc.paths + assert "/foo" in doc2.paths + + def test_add_operation_lowercases_method(self): + doc = spec.OpenAPI().add_operation("/foo", "POST", spec.Operation()) + + assert "post" in doc.paths["/foo"].operations + assert "POST" not in doc.paths["/foo"].operations + + def test_add_operation_merges_methods_on_same_path(self): + doc = spec.OpenAPI() + doc = doc.add_operation("/foo", "get", spec.Operation(operation_id="get")) + doc = doc.add_operation("/foo", "post", spec.Operation(operation_id="post")) + + ops = doc.paths["/foo"].operations + assert set(ops) == {"get", "post"} + + +class TestSerialization: + def test_minimal_doc(self): + d = spec.OpenAPI().to_dict() + assert d == {"openapi": "3.2.0", "info": {"title": "API", "version": "0.1.0"}} + + def test_to_json_round_trips(self): + doc = spec.OpenAPI(info=spec.Info(title="X", version="1")) + loaded = json.loads(doc.to_json()) + assert loaded["openapi"] == "3.2.0" + assert loaded["info"] == {"title": "X", "version": "1"} + + def test_components_omitted_when_empty(self): + d = spec.OpenAPI().to_dict() + assert "components" not in d + + def test_components_emitted_when_populated(self): + doc = spec.OpenAPI() + doc = doc.with_components(spec.Components(schemas={"Book": {"type": "object"}})) + d = doc.to_dict() + assert d["components"] == {"schemas": {"Book": {"type": "object"}}} + + def test_parameter_emits_in_field(self): + param = spec.Parameter(name="id", location="path", required=True, schema={"type": "string"}) + assert param.to_dict() == { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + + +class TestTagGroups: + """tagGroups is a 3.2 addition.""" + + def test_tag_groups_emitted(self): + doc = spec.OpenAPI( + tags=(spec.Tag(name="A"), spec.Tag(name="B")), + tag_groups=(spec.TagGroup(name="Group", tags=("A", "B")),), + ) + d = doc.to_dict() + assert d["tagGroups"] == [{"name": "Group", "tags": ["A", "B"]}] + + +class TestSecurityScheme: + def test_http_bearer(self): + scheme = spec.SecurityScheme(type="http", scheme="bearer", bearer_format="JWT") + assert scheme.to_dict() == {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + + def test_oauth2_with_device_flow(self): + scheme = spec.SecurityScheme( + type="oauth2", + flows=spec.OAuthFlows( + device_authorization=spec.OAuthFlow( + device_authorization_url="https://auth.example/device", + token_url="https://auth.example/token", + scopes={"read": "Read access"}, + ) + ), + ) + d = scheme.to_dict() + assert d["type"] == "oauth2" + assert d["flows"]["deviceAuthorization"]["deviceAuthorizationUrl"] == "https://auth.example/device" diff --git a/uv.lock b/uv.lock index 3e15530..7e7e8fb 100644 --- a/uv.lock +++ b/uv.lock @@ -699,11 +699,15 @@ cron = [ { name = "croniter" }, ] http = [ + { name = "click" }, { name = "h11" }, ] http-fast = [ { name = "httptools" }, ] +openapi = [ + { name = "msgspec" }, +] scheduler = [ { name = "humanize" }, { name = "pytimeparse2" }, @@ -734,6 +738,9 @@ dev-http = [ { name = "flask" }, { name = "httpx" }, ] +dev-openapi = [ + { name = "pydantic" }, +] dev-otel = [ { name = "opentelemetry-exporter-otlp" }, ] @@ -763,13 +770,15 @@ tests-unit = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = "~=4.12" }, + { name = "click", marker = "extra == 'http'", specifier = "~=8.0" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, + { name = "msgspec", marker = "extra == 'openapi'", specifier = "~=0.19" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http", "http-fast"] +provides-extras = ["cron", "scheduler", "http", "http-fast", "openapi"] [package.metadata.requires-dev] bench = [ @@ -796,6 +805,7 @@ dev-http = [ { name = "flask", specifier = "~=3.1" }, { name = "httpx" }, ] +dev-openapi = [{ name = "pydantic", specifier = "~=2.7" }] dev-otel = [{ name = "opentelemetry-exporter-otlp" }] dev-sentry = [{ name = "sentry-sdk", specifier = "~=2.51" }] dev-types = [ @@ -888,6 +898,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, ] +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.41.1" From 74651ce0a0e2e67475c053a56f04582af3174203 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 17:51:25 +0400 Subject: [PATCH 165/286] fix(http): strip trailing OWS in httptools header values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit httptools strips leading OWS but leaves trailing SP/HTAB on the value; h11 strips both per RFC 7230 §3.2.4. Trim in on_header to restore cross-backend parity, and mirror the same normalisation in the parity property test's expected shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/server_httptools.py | 5 +++++ tests/http/parser_parity_props.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 634e963..1f73100 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -202,6 +202,11 @@ def on_url(self, url: bytes) -> None: def on_header(self, name: bytes, value: bytes) -> None: n = name.lower() + # httptools strips leading OWS but leaves trailing SP/HTAB intact; + # RFC 7230 §3.2.4 requires both sides stripped, and h11 does so. Trim + # trailing OWS here to keep cross-backend parity. + if value.endswith((b" ", b"\t")): + value = value.rstrip(b" \t") self._cur_headers.append((n, value)) if n == b"expect" and value.lower() == b"100-continue": self._cur_expect_100 = True diff --git a/tests/http/parser_parity_props.py b/tests/http/parser_parity_props.py index 42d5578..45cab1f 100644 --- a/tests/http/parser_parity_props.py +++ b/tests/http/parser_parity_props.py @@ -120,12 +120,14 @@ def _request_wire(draw) -> tuple[bytes, dict]: header_block = b"".join(name + b": " + value + b"\r\n" for name, value in headers) wire = request_line + header_block + b"\r\n" + # Per RFC 7230 § 3.2.4, OWS (SP / HTAB) surrounding field-value is stripped + # by both backends; mirror that normalisation in the expected shape. expected = { "method": method, "target": target, "path": path, "query_string": query[1:] if query else b"", - "headers": [(name.lower(), value) for name, value in headers], + "headers": [(name.lower(), value.strip(b" \t")) for name, value in headers], "http_version": b"1.1", } return wire, expected From 481cdfa7c6bed2b69e325fd042b0483894e22def Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 18:27:19 +0400 Subject: [PATCH 166/286] feat(openapi): wire OpFilter end-to-end (app-level + per-op) The protocol existed but nothing called it. Now: - OpFilter has two contribution hooks (contribute_root for app-level things like security schemes, contribute_operation for per-op things like 401 responses + security requirements). Replaces the awkward single update_doc(doc, op=None) that forced filters to know their own path/method to find themselves in the doc. - HttpApp(filters=...) for app-wide filters that run on every request. - @app.get(path, filters=[...]) for per-operation filters. - Operation runs filters before resolvers; build_spec applies contribute_operation. App's openapi_doc dedupes by identity so a filter attached to multiple ops registers its scheme once. This closes the design gap that motivated the package vs FastAPI: auth and the OpenAPI doc come from a single declaration. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/app.py | 60 +++++++-- localpost/openapi/filter.py | 56 +++++---- localpost/openapi/operation.py | 27 +++- tests/openapi/conftest.py | 10 +- tests/openapi/filter.py | 221 +++++++++++++++++++++++++++++++++ 5 files changed, 332 insertions(+), 42 deletions(-) create mode 100644 tests/openapi/filter.py diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 0e47653..64fa1d7 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -26,6 +26,7 @@ def hello(name: str) -> str: import threading from collections.abc import Callable, Sequence +from dataclasses import replace from http import HTTPMethod from typing import Any, Literal @@ -38,6 +39,7 @@ def hello(name: str) -> str: from localpost.http.server import HTTPReqCtx, RequestHandler from localpost.openapi import spec as openapi_spec from localpost.openapi._docs import redoc_html, scalar_html, swagger_html +from localpost.openapi.filter import OpFilter from localpost.openapi.operation import Operation from localpost.openapi.schemas import SchemaRegistry @@ -53,6 +55,11 @@ class HttpApp: Args: info: Top-level OpenAPI :class:`Info` block. + filters: App-level :class:`OpFilter` s. Run before the per-operation + resolvers on every request, in order. Their + :meth:`OpFilter.contribute_root` is called once at spec build; + their :meth:`OpFilter.contribute_operation` is called for every + operation. max_concurrency: Worker-pool size used by :func:`thread_pool_handler`. Default 32. backlog: Extra channel buffer between the selector and the worker @@ -69,6 +76,7 @@ def __init__( self, *, info: openapi_spec.Info | None = None, + filters: Sequence[OpFilter] = (), max_concurrency: int = 32, backlog: int = 0, openapi_path: str | None = "/openapi.json", @@ -80,6 +88,7 @@ def __init__( if backlog < 0: raise ValueError("backlog must be >= 0") self._info = info or openapi_spec.Info() + self._filters = tuple(filters) self._max_concurrency = max_concurrency self._backlog = backlog self._openapi_path = openapi_path @@ -93,24 +102,27 @@ def __init__( # ----- Decorators ----- - def get(self, path: str) -> _FluentDecorator: - return self._decorator(HTTPMethod.GET, path) + def get(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.GET, path, filters) - def post(self, path: str) -> _FluentDecorator: - return self._decorator(HTTPMethod.POST, path) + def post(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.POST, path, filters) - def put(self, path: str) -> _FluentDecorator: - return self._decorator(HTTPMethod.PUT, path) + def put(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PUT, path, filters) - def delete(self, path: str) -> _FluentDecorator: - return self._decorator(HTTPMethod.DELETE, path) + def delete(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.DELETE, path, filters) - def patch(self, path: str) -> _FluentDecorator: - return self._decorator(HTTPMethod.PATCH, path) + def patch(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PATCH, path, filters) + + def _decorator(self, method: HTTPMethod, path: str, op_filters: Sequence[OpFilter]) -> _FluentDecorator: + # App-level filters run first; per-op filters extend them. + combined = (*self._filters, *op_filters) - def _decorator(self, method: HTTPMethod, path: str) -> _FluentDecorator: def deco(fn: Callable[..., Any]) -> Callable[..., Any]: - op = Operation.create(method, path, fn) + op = Operation.create(method, path, fn, filters=combined) with self._lock: self._operations.append(op) self._cached_spec = None @@ -134,13 +146,35 @@ def openapi_doc(self) -> openapi_spec.OpenAPI: return cached registry = SchemaRegistry() doc = openapi_spec.OpenAPI(info=self._info) + # Every filter (app-level and per-op) gets its contribute_root + # called exactly once. We dedupe by identity so the same filter + # attached to several ops registers its securityScheme just once. + seen: set[int] = set() + for f in self._all_filters(): + key = id(f) + if key in seen: + continue + seen.add(key) + doc = f.contribute_root(doc, registry) for op in self._operations: spec_op = op.build_spec(registry) doc = doc.add_operation(op.path, op.method.value, spec_op) - doc = doc.with_components(openapi_spec.Components(schemas=registry.components())) + # Merge in the schemas the registry collected. Filters may have + # already populated other Components fields above; preserve them + # by replacing only ``schemas``. + doc = doc.with_components( + replace(doc.components, schemas={**doc.components.schemas, **registry.components()}) + ) self._cached_spec = doc return doc + def _all_filters(self): + """Yield every filter that participates in the doc — app-level + first, then per-op (in registration order).""" + yield from self._filters + for op in self._operations: + yield from op.filters + def _openapi_bytes(self) -> bytes: with self._lock: cached = self._cached_spec_bytes diff --git a/localpost/openapi/filter.py b/localpost/openapi/filter.py index 1f07f7b..f50f71a 100644 --- a/localpost/openapi/filter.py +++ b/localpost/openapi/filter.py @@ -1,17 +1,28 @@ -"""``OpFilter`` — middleware that also contributes to the OpenAPI doc. +"""``OpFilter`` — middleware that also describes itself in the OpenAPI doc. A filter is a callable that runs before the operation handler. It can short-circuit the request by returning an :class:`OpResult` (e.g. a ``Unauthorized`` from auth) or return ``None`` to let the operation proceed. -The OpenAPI improvement vs. FastAPI: the same object that runs at request -time also describes its contribution to the OpenAPI spec — security -schemes, extra parameters, extra response codes. There's no second place -to declare auth. +The OpenAPI improvement vs. FastAPI: the *same* object that runs at +request time also describes its contribution to the spec — security +schemes at the root, additional response codes / security requirements +on each operation. There's no second place to declare auth. -v1 ships only the protocol; concrete filters (``HttpBasicAuth``, -``HttpBearerAuth``, ``OpenIDConnectAuth``) come later. +Two contribution hooks: + +- :meth:`OpFilter.contribute_root` — called once at spec build time. The + typical use is to register a :class:`SecurityScheme` under + ``components.securitySchemes``. +- :meth:`OpFilter.contribute_operation` — called per operation that has + this filter attached (app-level filters are attached to *every* op). + The typical use is to add a 401 / 403 response and a ``security`` + requirement. + +Filters typically override one or both of these; both have a default +implementation that returns the input unchanged so a filter that only +needs runtime behaviour can ignore the doc side. """ from __future__ import annotations @@ -22,32 +33,33 @@ from localpost.http.server import HTTPReqCtx from localpost.openapi import spec from localpost.openapi.results import OpResult + from localpost.openapi.schemas import SchemaRegistry @runtime_checkable class OpFilter(Protocol): - """Pre-handler middleware that knows how to describe itself in OpenAPI. - - Filters can be applied app-wide (in :class:`HttpApp(filters=...)`) or - per-operation (decorator on the same function as ``@app.get(...)``). - Either way, the filter's :meth:`update_doc` is called when the spec is - built so the doc stays consistent with the runtime behaviour. - """ + """Pre-handler middleware that knows how to describe itself in OpenAPI.""" def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: """Run pre-handler. Return :class:`OpResult` to short-circuit, or ``None`` to let the operation proceed.""" ... - def update_doc(self, doc: spec.OpenAPI, op: spec.Operation | None = None, /) -> spec.OpenAPI: - """Contribute to the OpenAPI doc. + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + """App-level OpenAPI contribution. + + Called once when the spec is built. Typical use: register a + ``SecurityScheme`` under ``components.securitySchemes``. Default: + return ``doc`` unchanged. + """ + ... - Called once at spec-build time. ``op`` is ``None`` for app-level - filters (where the contribution is typically a - ``components.securitySchemes`` entry) and the relevant operation - for op-level filters (where the contribution is typically an - extra response code and a ``security`` requirement). + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + """Per-operation OpenAPI contribution. - Returns a new :class:`spec.OpenAPI` (the doc is immutable). + Called once per operation that this filter is attached to (every + operation for app-level filters). Typical use: add a ``401`` / + ``403`` response and a ``security`` requirement. Default: return + ``op`` unchanged. """ ... diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index fd85ac5..0db2fb4 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -22,6 +22,7 @@ from localpost.http.router import URITemplate from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.openapi import spec as openapi_spec +from localpost.openapi.filter import OpFilter from localpost.openapi.resolvers import ( ArgResolver, ArgResolverFactory, @@ -62,13 +63,25 @@ class Operation: ``None`` for the ``HTTPReqCtx`` pass-through, which contributes nothing to the OpenAPI doc.""" + filters: tuple[OpFilter, ...] + """Filters to run before the resolvers. Includes both app-level filters + (injected by :class:`HttpApp`) and per-operation filters.""" + return_shapes: tuple[_ResponseShape, ...] summary: str operation_id: str description: str @classmethod - def create(cls, method: HTTPMethod, path: str, fn: Callable[..., Any], /) -> Self: + def create( + cls, + method: HTTPMethod, + path: str, + fn: Callable[..., Any], + /, + *, + filters: tuple[OpFilter, ...] = (), + ) -> Self: template = URITemplate.parse(path) path_var_names = set(template.variable_names) @@ -117,6 +130,7 @@ def create(cls, method: HTTPMethod, path: str, fn: Callable[..., Any], /) -> Sel target=fn, arg_resolvers=tuple(arg_resolvers), arg_resolver_factories=tuple(arg_factories), + filters=filters, return_shapes=return_shapes, summary=summary, operation_id=operation_id, @@ -142,6 +156,12 @@ def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: return pre_body def _run(self, ctx: HTTPReqCtx) -> None: + for f in self.filters: + short_circuit = f(ctx) + if short_circuit is not None: + response, body = _build_http_response(short_circuit) + ctx.complete(response, body) + return kwargs: dict[str, object] = {} for name, resolver in self.arg_resolvers: value = resolver(ctx) @@ -174,7 +194,10 @@ def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: continue op = factory.update_doc(param, op, registry) responses = _build_responses(self.return_shapes, registry) - return replace(op, responses=responses) + op = replace(op, responses=responses) + for f in self.filters: + op = f.contribute_operation(op, registry) + return op # --- Helpers ------------------------------------------------------------- diff --git a/tests/openapi/conftest.py b/tests/openapi/conftest.py index 252e145..6faa28d 100644 --- a/tests/openapi/conftest.py +++ b/tests/openapi/conftest.py @@ -69,11 +69,11 @@ def __exit__(self, exc_type, exc, tb) -> None: def serve_app() -> Iterator[ServeApp]: """Spin up an :class:`HttpApp` on a random local port. Use as ``with``:: - def test_x(serve_app): - app = HttpApp() - ... - with serve_app(app) as port: - resp = httpx.get(f"http://127.0.0.1:{port}/...") + def test_x(serve_app): + app = HttpApp() + ... + with serve_app(app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/...") """ active: list[_ServerCM] = [] diff --git a/tests/openapi/filter.py b/tests/openapi/filter.py new file mode 100644 index 0000000..4637815 --- /dev/null +++ b/tests/openapi/filter.py @@ -0,0 +1,221 @@ +"""Tests for ``OpFilter`` wiring through ``HttpApp`` and ``Operation``.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from localpost.openapi import HttpApp, OpFilter, Unauthorized, spec +from tests.openapi.app import make_ctx, run_op + +if TYPE_CHECKING: + from localpost.http.server import HTTPReqCtx + from localpost.openapi.results import OpResult + from localpost.openapi.schemas import SchemaRegistry + + +# --- Test filter helpers ------------------------------------------------- + + +class _RecordingFilter: + """Filter that just records calls; never short-circuits.""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + self.calls.append("run") + return None + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + self.calls.append("contribute_root") + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + self.calls.append("contribute_operation") + return op + + +class _DenyFilter: + """Filter that always short-circuits with 401.""" + + def __init__(self, message: str = "denied") -> None: + self.message = message + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + return Unauthorized(self.message) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + new_components = replace( + doc.components, + security_schemes={ + **doc.components.security_schemes, + "denyAuth": spec.SecurityScheme(type="http", scheme="bearer"), + }, + ) + return replace(doc, components=new_components) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return replace( + op, + security=(*op.security, {"denyAuth": ()}), + responses={**op.responses, "401": spec.Response(description="Unauthorized")}, + ) + + +class TestFilterIsRuntimeProtocol: + def test_recording_satisfies_protocol(self): + assert isinstance(_RecordingFilter(), OpFilter) + + def test_deny_satisfies_protocol(self): + assert isinstance(_DenyFilter(), OpFilter) + + +class TestAppLevelFilterRuntime: + def test_filter_runs_before_handler(self): + rec = _RecordingFilter() + app = HttpApp(filters=[rec]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/foo") + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"ok" + # The filter ran for the request, plus contribute_operation at registration. + # contribute_root only fires when openapi_doc is built. + assert rec.calls == ["run"] + + def test_filter_short_circuits(self): + app = HttpApp(filters=[_DenyFilter("nope")]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/foo") + status, body, _ = run_op(op, ctx) + assert status == 401 + assert body == b"nope" + + +class TestPerOpFilter: + def test_per_op_filter_only_affects_that_op(self): + deny = _DenyFilter("op-only") + app = HttpApp() + + @app.get("/protected", filters=[deny]) + def protected() -> str: + return "secret" + + @app.get("/public") + def public() -> str: + return "hi" + + protected_op = app.operations[0] + public_op = app.operations[1] + + status, _, _ = run_op(protected_op, make_ctx(path="/protected")) + assert status == 401 + + status, body, _ = run_op(public_op, make_ctx(path="/public")) + assert status == 200 + assert body == b"hi" + + def test_app_and_per_op_filters_compose(self): + order: list[str] = [] + + class _Tagging: + def __init__(self, tag: str) -> None: + self.tag = tag + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + order.append(self.tag) + return None + + def contribute_root(self, doc, registry): # noqa: ANN001 + return doc + + def contribute_operation(self, op, registry): # noqa: ANN001 + return op + + app = HttpApp(filters=[_Tagging("app")]) + + @app.get("/foo", filters=[_Tagging("op")]) + def foo() -> str: + return "ok" + + run_op(app.operations[0], make_ctx(path="/foo")) + # App-level runs before per-op. + assert order == ["app", "op"] + + +class TestSpecContribution: + def test_contribute_root_called_once_per_doc_build(self): + rec = _RecordingFilter() + app = HttpApp(filters=[rec]) + + @app.get("/a") + def a() -> str: + return "a" + + @app.get("/b") + def b() -> str: + return "b" + + rec.calls.clear() + _ = app.openapi_doc + # contribute_root once, contribute_operation per operation. + assert rec.calls.count("contribute_root") == 1 + assert rec.calls.count("contribute_operation") == 2 + + def test_contribute_root_appears_in_doc(self): + app = HttpApp(filters=[_DenyFilter()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + assert "denyAuth" in doc["components"]["securitySchemes"] + assert doc["components"]["securitySchemes"]["denyAuth"] == { + "type": "http", + "scheme": "bearer", + } + + def test_contribute_operation_appears_in_doc(self): + app = HttpApp(filters=[_DenyFilter()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + op = doc["paths"]["/foo"]["get"] + assert op["security"] == [{"denyAuth": []}] + assert "401" in op["responses"] + assert op["responses"]["401"] == {"description": "Unauthorized"} + + def test_per_op_filter_doc_contribution_is_op_scoped(self): + app = HttpApp() + + @app.get("/protected", filters=[_DenyFilter()]) + def protected() -> str: + return "secret" + + @app.get("/public") + def public() -> str: + return "hi" + + doc = app.openapi_doc.to_dict() + # Per-op filter contributes to BOTH root (because contribute_root + # is always called once per filter that sees the doc) and only to + # its own operation. + assert "denyAuth" in doc["components"]["securitySchemes"] + assert "security" in doc["paths"]["/protected"]["get"] + assert "security" not in doc["paths"]["/public"]["get"] + assert "401" not in doc["paths"]["/public"]["get"]["responses"] From ef76ef2f99999ade6f85e726cbb72c8f09a9c045 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 18:30:12 +0400 Subject: [PATCH 167/286] feat(openapi): HttpBearerAuth + HttpBasicAuth concrete filters Concrete OpFilter implementations that prove the abstraction. Each runs at request time and contributes to the OpenAPI doc: - contribute_root registers the SecurityScheme under components.securitySchemes (bearer / basic) - contribute_operation adds a 401 response and a security requirement On success the validated principal is stashed on ctx.attrs[] so user code can pull it via a custom resolver. HttpBasicAuth also sends a WWW-Authenticate challenge on 401. OpenIDConnectAuth is the next obvious sibling. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/README.md | 56 ++++++- localpost/openapi/__init__.py | 3 + localpost/openapi/auth.py | 178 ++++++++++++++++++++++ tests/openapi/auth.py | 272 ++++++++++++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 localpost/openapi/auth.py create mode 100644 tests/openapi/auth.py diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index 00122e8..9917b9b 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -126,19 +126,60 @@ Use the *class* in return annotations (`Book | NotFound[str]`) so the OpenAPI doc picks up the body type per status code. Returning a bare `localpost.http.Response` is the escape hatch (no schema contribution). -### `OpFilter` (designed; concrete impls land later) +### `OpFilter` + +A filter is middleware that knows how to describe itself in the OpenAPI doc: ```python class OpFilter(Protocol): def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: ... - def update_doc( - self, doc: spec.OpenAPI, op: spec.Operation | None = None, / - ) -> spec.OpenAPI: ... + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: ... + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: ... +``` + +- `__call__` runs before the resolvers; return an `OpResult` to short-circuit. +- `contribute_root` is called once at spec-build time (typically to register + a `SecurityScheme`). +- `contribute_operation` is called for each operation that the filter is + attached to (typically to add a 401 response and a `security` requirement). + +Apply app-wide: + +```python +app = HttpApp(filters=[my_filter]) +``` + +or per-operation: + +```python +@app.get("/admin", filters=[my_filter]) +def admin() -> str: ... +``` + +### Built-in auth filters + +```python +from localpost.openapi import HttpApp, HttpBearerAuth, HttpBasicAuth + +def validate_token(token: str) -> dict | None: + # Return any truthy principal on success, None on failure. + # The principal is stashed on ctx.attrs[] for handlers to read. + return decode_jwt(token) + + +app = HttpApp(filters=[HttpBearerAuth(validator=validate_token)]) + + +@app.get("/me") +def me() -> dict: + # Pull the principal from a custom resolver, or just inject the ctx. + ... ``` -App-level filters get `op=None`; per-operation filters get the relevant -`Operation`. `update_doc` returns a new (immutable) doc. Concrete -`HttpBasicAuth` / `HttpBearerAuth` / `OpenIDConnectAuth` are a follow-up. +`HttpBearerAuth` registers an HTTP `bearer` security scheme; `HttpBasicAuth` +registers `basic` and sends a `WWW-Authenticate` challenge on 401. Both +attach a 401 response and a `security` requirement to every operation they +cover. `OpenIDConnectAuth` is a follow-up. ## Hosting @@ -165,6 +206,7 @@ focused on user-facing concepts. | `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories | | `results.py` | `OpResult` hierarchy | | `filter.py` | `OpFilter` protocol | +| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth filters | | `spec.py` | OpenAPI 3.2 dataclasses | | `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | | `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index c9681f8..31c69a2 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -37,6 +37,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: from localpost.openapi import spec from localpost.openapi.app import HttpApp +from localpost.openapi.auth import HttpBasicAuth, HttpBearerAuth from localpost.openapi.filter import OpFilter from localpost.openapi.operation import Operation from localpost.openapi.resolvers import ( @@ -72,6 +73,8 @@ def get_book(book_id: str) -> Book | NotFound[str]: "spec", # filters "OpFilter", + "HttpBearerAuth", + "HttpBasicAuth", # arg resolvers "ArgResolver", "ArgResolverFactory", diff --git a/localpost/openapi/auth.py b/localpost/openapi/auth.py new file mode 100644 index 0000000..9c18ae1 --- /dev/null +++ b/localpost/openapi/auth.py @@ -0,0 +1,178 @@ +"""Concrete :class:`OpFilter` implementations for HTTP authentication. + +Each filter runs at request time *and* contributes to the OpenAPI doc: + +- :meth:`contribute_root` registers a ``SecurityScheme`` under + ``components.securitySchemes``. +- :meth:`contribute_operation` adds a ``security`` requirement and a + ``401`` response. + +Identity (the validated principal) is stashed on ``ctx.attrs[filter]`` +so user code can pull it via a tiny custom resolver — see the README +for the pattern. The validator is a sync callable returning either the +principal (any object) on success or ``None`` on rejection. + +Use either app-wide: + + app = HttpApp(filters=[HttpBearerAuth(validate_token)]) + +or per-operation: + + @app.get("/me", filters=[HttpBearerAuth(validate_token)]) + def me(ctx: HTTPReqCtx) -> dict: ... +""" + +from __future__ import annotations + +import base64 +import binascii +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, Any + +from localpost.openapi import spec +from localpost.openapi.results import OpResult, Unauthorized + +if TYPE_CHECKING: + from localpost.http.server import HTTPReqCtx + from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["HttpBearerAuth", "HttpBasicAuth"] + + +_AUTHORIZATION_HEADER = b"authorization" + + +def _read_authorization(ctx: HTTPReqCtx) -> bytes | None: + for name, value in ctx.request.headers: + if name == _AUTHORIZATION_HEADER: + return value + return None + + +def _add_security_scheme( + doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme +) -> spec.OpenAPI: + components = replace( + doc.components, + security_schemes={**doc.components.security_schemes, name: scheme}, + ) + return replace(doc, components=components) + + +def _add_security_requirement( + op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = () +) -> spec.Operation: + requirement: dict[str, tuple[str, ...]] = {scheme_name: scopes} + return replace( + op, + security=(*op.security, requirement), + responses={ + "401": spec.Response(description="Unauthorized"), + **op.responses, + }, + ) + + +@dataclass(slots=True, eq=False) +class HttpBearerAuth: + """``Authorization: Bearer `` filter. + + Args: + validator: ``token_str -> principal | None``. Called for every + request that carries a ``Bearer`` Authorization header. Return + anything truthy on success (it gets stashed on + ``ctx.attrs[self]``); return ``None`` to reject with 401. + scheme_name: Key under ``components.securitySchemes``. Default + ``"bearerAuth"``. + bearer_format: Hint shown in the OpenAPI doc (e.g. ``"JWT"``). + Default ``"JWT"``. + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str], Any | None] + scheme_name: str = "bearerAuth" + bearer_format: str = "JWT" + description: str = "" + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + raw = _read_authorization(ctx) + if raw is None or not raw.startswith(b"Bearer "): + return Unauthorized("Missing or malformed Authorization header") + token = raw[7:].decode("ascii", errors="replace") + principal = self.validator(token) + if principal is None: + return Unauthorized("Invalid token") + ctx.attrs[self] = principal + return None + + def contribute_root( + self, doc: spec.OpenAPI, registry: SchemaRegistry, / + ) -> spec.OpenAPI: + scheme = spec.SecurityScheme( + type="http", + scheme="bearer", + bearer_format=self.bearer_format, + description=self.description, + ) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation( + self, op: spec.Operation, registry: SchemaRegistry, / + ) -> spec.Operation: + return _add_security_requirement(op, self.scheme_name) + + +@dataclass(slots=True, eq=False) +class HttpBasicAuth: + """``Authorization: Basic `` filter. + + Args: + validator: ``(username, password) -> principal | None``. Called + for every request that carries a ``Basic`` Authorization + header. Return anything truthy on success (stashed on + ``ctx.attrs[self]``); ``None`` to reject with 401. + scheme_name: Key under ``components.securitySchemes``. Default + ``"basicAuth"``. + realm: Realm sent in the ``WWW-Authenticate`` header on 401. + Default ``"localpost"``. + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str, str], Any | None] + scheme_name: str = "basicAuth" + realm: str = "localpost" + description: str = "" + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + raw = _read_authorization(ctx) + challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} + if raw is None or not raw.startswith(b"Basic "): + return Unauthorized( + "Missing or malformed Authorization header", headers=challenge + ) + try: + decoded = base64.b64decode(raw[6:], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return Unauthorized("Malformed Basic credentials", headers=challenge) + username, sep, password = decoded.partition(":") + if not sep: + return Unauthorized("Malformed Basic credentials", headers=challenge) + principal = self.validator(username, password) + if principal is None: + return Unauthorized("Invalid credentials", headers=challenge) + ctx.attrs[self] = principal + return None + + def contribute_root( + self, doc: spec.OpenAPI, registry: SchemaRegistry, / + ) -> spec.OpenAPI: + scheme = spec.SecurityScheme( + type="http", scheme="basic", description=self.description + ) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation( + self, op: spec.Operation, registry: SchemaRegistry, / + ) -> spec.Operation: + return _add_security_requirement(op, self.scheme_name) diff --git a/tests/openapi/auth.py b/tests/openapi/auth.py new file mode 100644 index 0000000..2af334d --- /dev/null +++ b/tests/openapi/auth.py @@ -0,0 +1,272 @@ +"""Tests for ``localpost.openapi.auth`` (HttpBearerAuth, HttpBasicAuth).""" + +from __future__ import annotations + +import base64 + +from localpost.openapi import HttpApp, HttpBasicAuth, HttpBearerAuth +from tests.openapi.app import make_ctx, run_op + + +def _basic_header(user: str, password: str) -> bytes: + return b"Basic " + base64.b64encode(f"{user}:{password}".encode()) + + +# --- HttpBearerAuth ------------------------------------------------------ + + +class TestHttpBearerAuthRuntime: + def test_valid_token_passes(self): + bearer = HttpBearerAuth(validator=lambda t: {"sub": t} if t == "good" else None) + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", b"Bearer good")]) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"hello" + assert ctx.attrs[bearer] == {"sub": "good"} + + def test_invalid_token_returns_401(self): + bearer = HttpBearerAuth(validator=lambda _t: None) + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", b"Bearer anything")]) + status, body, _ = run_op(op, ctx) + assert status == 401 + assert body == b"Invalid token" + + def test_missing_header_returns_401(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/me")) + assert status == 401 + assert b"Missing or malformed" in body + + def test_non_bearer_scheme_returns_401(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_ctx( + path="/me", headers=[(b"authorization", _basic_header("u", "p"))] + ) + status, _, _ = run_op(op, ctx) + assert status == 401 + + +class TestHttpBearerAuthSpec: + def test_security_scheme_in_components(self): + bearer = HttpBearerAuth(validator=lambda _t: True, bearer_format="opaque") + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + doc = app.openapi_doc.to_dict() + assert doc["components"]["securitySchemes"]["bearerAuth"] == { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque", + } + + def test_operation_gets_security_and_401(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] + assert op["security"] == [{"bearerAuth": []}] + assert op["responses"]["401"] == {"description": "Unauthorized"} + + def test_per_op_does_not_protect_other_ops(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + app = HttpApp() + + @app.get("/protected", filters=[bearer]) + def protected() -> str: + return "x" + + @app.get("/public") + def public() -> str: + return "y" + + doc = app.openapi_doc.to_dict() + assert "security" in doc["paths"]["/protected"]["get"] + assert "security" not in doc["paths"]["/public"]["get"] + # Scheme is registered once at root regardless of attachment scope. + assert "bearerAuth" in doc["components"]["securitySchemes"] + + def test_custom_scheme_name(self): + bearer = HttpBearerAuth(validator=lambda _t: True, scheme_name="apiToken") + app = HttpApp(filters=[bearer]) + + @app.get("/me") + def me() -> str: + return "hello" + + doc = app.openapi_doc.to_dict() + assert "apiToken" in doc["components"]["securitySchemes"] + assert doc["paths"]["/me"]["get"]["security"] == [{"apiToken": []}] + + +# --- HttpBasicAuth ------------------------------------------------------- + + +class TestHttpBasicAuthRuntime: + def test_valid_credentials_pass(self): + basic = HttpBasicAuth( + validator=lambda u, p: u if (u, p) == ("alice", "wonder") else None + ) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx( + path="/me", + headers=[(b"authorization", _basic_header("alice", "wonder"))], + ) + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"ok" + assert ctx.attrs[basic] == "alice" + + def test_invalid_credentials_returns_401_with_challenge(self): + basic = HttpBasicAuth(validator=lambda _u, _p: None) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx( + path="/me", + headers=[(b"authorization", _basic_header("u", "p"))], + ) + status, _, headers = run_op(op, ctx) + assert status == 401 + assert headers["WWW-Authenticate"].startswith("Basic realm=") + + def test_missing_header_returns_401_with_challenge(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + status, _, headers = run_op(op, make_ctx(path="/me")) + assert status == 401 + assert "WWW-Authenticate" in headers + + def test_malformed_base64_returns_401(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/me", headers=[(b"authorization", b"Basic !!!not-b64!!!")]) + status, body, _ = run_op(op, ctx) + assert status == 401 + assert b"Malformed" in body + + def test_credentials_without_colon_returns_401(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.operations[0] + creds = base64.b64encode(b"no-colon-here").decode() + ctx = make_ctx( + path="/me", + headers=[(b"authorization", f"Basic {creds}".encode())], + ) + status, body, _ = run_op(op, ctx) + assert status == 401 + assert b"Malformed" in body + + +class TestHttpBasicAuthSpec: + def test_security_scheme_in_components(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + assert doc["components"]["securitySchemes"]["basicAuth"] == { + "type": "http", + "scheme": "basic", + } + + def test_operation_gets_security_and_401(self): + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp(filters=[basic]) + + @app.get("/me") + def me() -> str: + return "ok" + + op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] + assert op["security"] == [{"basicAuth": []}] + assert op["responses"]["401"] == {"description": "Unauthorized"} + + +# --- Integration: mixing two filters in one app -------------------------- + + +class TestMixedAuth: + def test_two_filters_register_both_schemes(self): + bearer = HttpBearerAuth(validator=lambda _t: True) + basic = HttpBasicAuth(validator=lambda _u, _p: True) + app = HttpApp() + + @app.get("/jwt", filters=[bearer]) + def jwt() -> str: + return "j" + + @app.get("/basic", filters=[basic]) + def b() -> str: + return "b" + + doc = app.openapi_doc.to_dict() + schemes = doc["components"]["securitySchemes"] + assert {"bearerAuth", "basicAuth"} <= set(schemes) + assert doc["paths"]["/jwt"]["get"]["security"] == [{"bearerAuth": []}] + assert doc["paths"]["/basic"]["get"]["security"] == [{"basicAuth": []}] From 94fa52f8a051a7abaada505919b14fc89d99a4b7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 18:33:20 +0400 Subject: [PATCH 168/286] =?UTF-8?q?feat(openapi):=20polish=20=E2=80=94=20O?= =?UTF-8?q?ptional=20resolvers,=20fn-name=20operationId,=20path=20validati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small improvements driven by usage: - FromQuery / FromHeader now treat ``T | None`` (without a default) the same as a defaulted param: not required at the wire, not required in the OpenAPI doc, returns None on absence. Was previously rejecting with 400 unless an explicit default was supplied. - operationId is now ``fn.__name__`` when usable (matches FastAPI and what users expect to see in their codebase) instead of the path- mangled ``get_books_book_id``. Falls back to the path id for lambdas. - Registration-time validation: FromPath() targeting a name absent from the path template, or a {var} in the template with no matching parameter, both error at decoration time instead of failing at request time with a confusing 400. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/auth.py | 32 ++----- localpost/openapi/operation.py | 33 +++++++- localpost/openapi/resolvers.py | 65 +++++++++++---- tests/openapi/auth.py | 8 +- tests/openapi/polish.py | 147 +++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 tests/openapi/polish.py diff --git a/localpost/openapi/auth.py b/localpost/openapi/auth.py index 9c18ae1..635b43a 100644 --- a/localpost/openapi/auth.py +++ b/localpost/openapi/auth.py @@ -50,9 +50,7 @@ def _read_authorization(ctx: HTTPReqCtx) -> bytes | None: return None -def _add_security_scheme( - doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme -) -> spec.OpenAPI: +def _add_security_scheme(doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme) -> spec.OpenAPI: components = replace( doc.components, security_schemes={**doc.components.security_schemes, name: scheme}, @@ -60,9 +58,7 @@ def _add_security_scheme( return replace(doc, components=components) -def _add_security_requirement( - op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = () -) -> spec.Operation: +def _add_security_requirement(op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = ()) -> spec.Operation: requirement: dict[str, tuple[str, ...]] = {scheme_name: scopes} return replace( op, @@ -106,9 +102,7 @@ def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: ctx.attrs[self] = principal return None - def contribute_root( - self, doc: spec.OpenAPI, registry: SchemaRegistry, / - ) -> spec.OpenAPI: + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: scheme = spec.SecurityScheme( type="http", scheme="bearer", @@ -117,9 +111,7 @@ def contribute_root( ) return _add_security_scheme(doc, self.scheme_name, scheme) - def contribute_operation( - self, op: spec.Operation, registry: SchemaRegistry, / - ) -> spec.Operation: + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: return _add_security_requirement(op, self.scheme_name) @@ -148,9 +140,7 @@ def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: raw = _read_authorization(ctx) challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} if raw is None or not raw.startswith(b"Basic "): - return Unauthorized( - "Missing or malformed Authorization header", headers=challenge - ) + return Unauthorized("Missing or malformed Authorization header", headers=challenge) try: decoded = base64.b64decode(raw[6:], validate=True).decode("utf-8") except (binascii.Error, UnicodeDecodeError): @@ -164,15 +154,9 @@ def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: ctx.attrs[self] = principal return None - def contribute_root( - self, doc: spec.OpenAPI, registry: SchemaRegistry, / - ) -> spec.OpenAPI: - scheme = spec.SecurityScheme( - type="http", scheme="basic", description=self.description - ) + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme(type="http", scheme="basic", description=self.description) return _add_security_scheme(doc, self.scheme_name, scheme) - def contribute_operation( - self, op: spec.Operation, registry: SchemaRegistry, / - ) -> spec.Operation: + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: return _add_security_requirement(op, self.scheme_name) diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index 0db2fb4..ad54bc2 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -103,6 +103,7 @@ def create( arg_resolvers: list[tuple[str, ArgResolver]] = [] arg_factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] + bound_path_vars: set[str] = set() for name, param in sig.parameters.items(): if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): @@ -113,15 +114,31 @@ def create( arg_resolvers.append((name, _resolve_ctx)) arg_factories.append((name, param, None)) else: + if isinstance(factory, FromPath): + bound = factory.name or name + if bound not in path_var_names: + raise ValueError( + f"handler {_qualname(fn)!r}: parameter {name!r} uses FromPath" + f"({bound!r}) but path template {path!r} has no such variable" + f" (available: {sorted(path_var_names) or 'none'})" + ) + bound_path_vars.add(bound) arg_resolvers.append((name, factory(param))) arg_factories.append((name, param, factory)) + unbound = path_var_names - bound_path_vars + if unbound: + raise ValueError( + f"handler {_qualname(fn)!r}: path template {path!r} declares variable(s)" + f" {sorted(unbound)!r} that no parameter resolves" + ) + return_shapes = tuple(_extract_response_shapes(sig.return_annotation)) doc = inspect.getdoc(fn) or "" summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" description = doc[len(summary) :].lstrip("\n") if doc else "" - operation_id = f"{method.value.lower()}_{_path_to_id(path)}" + operation_id = _operation_id(method, path, fn) return cls( method=method, @@ -247,6 +264,20 @@ def _path_to_id(path: str) -> str: return cleaned or "root" +def _operation_id(method: HTTPMethod, path: str, fn: Callable[..., Any]) -> str: + """Pick an operationId. + + Prefer ``fn.__name__`` when it's a real, non-anonymous identifier — it + matches what users see in their codebase (and what FastAPI emits, modulo + the path suffix). Fall back to a path-mangled id for lambdas / wrapped + callables / anything without a usable name. + """ + name = getattr(fn, "__name__", "") or "" + if name and name not in {"", "_", ""}: + return name + return f"{method.value.lower()}_{_path_to_id(path)}" + + def _pick_factory( name: str, param: inspect.Parameter, diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py index 94f9351..03be4f9 100644 --- a/localpost/openapi/resolvers.py +++ b/localpost/openapi/resolvers.py @@ -15,7 +15,8 @@ import inspect from collections.abc import Callable, Sequence from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Annotated, Any, Protocol, get_args, get_origin +from types import NoneType, UnionType +from typing import TYPE_CHECKING, Annotated, Any, Protocol, Union, get_args, get_origin from urllib.parse import parse_qs import msgspec @@ -72,10 +73,42 @@ def _unwrap_annotated(t: Any) -> Any: return t +def _unwrap_optional(t: Any) -> tuple[Any, bool]: + """Strip ``T | None`` / ``Optional[T]`` to ``(T, True)``. + + Returns ``(t, False)`` for non-optional types. + """ + origin = get_origin(t) + if origin is Union or origin is UnionType: + args = [a for a in get_args(t) if a is not NoneType] + if len(args) < len(get_args(t)): + # ``T | None`` collapses to T; ``T | U | None`` keeps the union. + inner: Any = args[0] if len(args) == 1 else Union[tuple(args)] # noqa: UP007 + return inner, True + return t, False + + def _has_default(param: inspect.Parameter) -> bool: return param.default is not inspect.Parameter.empty +def _is_optional_param(param: inspect.Parameter) -> tuple[Any, bool, Any]: + """For a param, return ``(target_type, is_optional, default_value)``. + + ``is_optional`` is true if the annotation is ``T | None`` *or* the param + has a default. ``default_value`` is the param's default, or ``None`` when + the type is optional but no default is supplied. + """ + target = _unwrap_annotated(param.annotation) + inner, is_nullable = _unwrap_optional(target) + has_default = _has_default(param) + if has_default: + return inner, True, param.default + if is_nullable: + return inner, True, None + return target, False, None + + def _cast_str(value: str, target: Any) -> Any: """Coerce a single string value into ``target``. @@ -189,6 +222,10 @@ class FromQuery: For ``list[T]`` / ``Sequence[T]`` annotations all values for the key are returned. For scalar annotations the *first* value is taken and coerced. + + Optional handling: if the annotation is ``T | None`` *or* the param + has a default, the parameter is not required. When absent it returns + the default (``None`` if the type was simply made nullable). """ name: str | None = None @@ -196,16 +233,14 @@ class FromQuery: def __call__(self, param: inspect.Parameter, /) -> ArgResolver: param_name = self.name or param.name - target = _unwrap_annotated(param.annotation) + target, optional, default = _is_optional_param(param) elem_type = _list_element_type(target) is_list = elem_type is not None - has_default = _has_default(param) - default = param.default if has_default else None def resolve(ctx: HTTPReqCtx) -> object | OpResult: values = _query_args(ctx).get(param_name) if not values: - if has_default: + if optional: return default return BadRequest(f"Missing required query parameter: {param_name}") try: @@ -224,12 +259,12 @@ def update_doc( registry: SchemaRegistry, /, ) -> openapi_spec.Operation: - target = _unwrap_annotated(param.annotation) + target, optional, _ = _is_optional_param(param) schema = registry.schema_for(target) if target is not Any else {"type": "string"} parameter = openapi_spec.Parameter( name=self.name or param.name, location="query", - required=not _has_default(param), + required=not optional, description=self.description, schema=schema, ) @@ -241,16 +276,18 @@ def update_doc( @dataclass(frozen=True, slots=True) class FromHeader: - """Resolve a parameter from a request header.""" + """Resolve a parameter from a request header. + + Optional handling: if the annotation is ``T | None`` *or* the param + has a default, the header is not required. + """ name: str | None = None description: str = "" def __call__(self, param: inspect.Parameter, /) -> ArgResolver: header_name = (self.name or param.name.replace("_", "-")).lower().encode("ascii") - target = _unwrap_annotated(param.annotation) - has_default = _has_default(param) - default = param.default if has_default else None + target, optional, default = _is_optional_param(param) def resolve(ctx: HTTPReqCtx) -> object | OpResult: value: str | None = None @@ -259,7 +296,7 @@ def resolve(ctx: HTTPReqCtx) -> object | OpResult: value = val.decode("iso-8859-1") break if value is None: - if has_default: + if optional: return default return BadRequest(f"Missing required header: {header_name.decode()}") try: @@ -276,12 +313,12 @@ def update_doc( registry: SchemaRegistry, /, ) -> openapi_spec.Operation: - target = _unwrap_annotated(param.annotation) + target, optional, _ = _is_optional_param(param) schema = registry.schema_for(target) if target is not Any else {"type": "string"} parameter = openapi_spec.Parameter( name=self.name or param.name.replace("_", "-"), location="header", - required=not _has_default(param), + required=not optional, description=self.description, schema=schema, ) diff --git a/tests/openapi/auth.py b/tests/openapi/auth.py index 2af334d..fa08544 100644 --- a/tests/openapi/auth.py +++ b/tests/openapi/auth.py @@ -67,9 +67,7 @@ def me() -> str: return "hello" op = app.operations[0] - ctx = make_ctx( - path="/me", headers=[(b"authorization", _basic_header("u", "p"))] - ) + ctx = make_ctx(path="/me", headers=[(b"authorization", _basic_header("u", "p"))]) status, _, _ = run_op(op, ctx) assert status == 401 @@ -138,9 +136,7 @@ def me() -> str: class TestHttpBasicAuthRuntime: def test_valid_credentials_pass(self): - basic = HttpBasicAuth( - validator=lambda u, p: u if (u, p) == ("alice", "wonder") else None - ) + basic = HttpBasicAuth(validator=lambda u, p: u if (u, p) == ("alice", "wonder") else None) app = HttpApp(filters=[basic]) @app.get("/me") diff --git a/tests/openapi/polish.py b/tests/openapi/polish.py new file mode 100644 index 0000000..240e7b8 --- /dev/null +++ b/tests/openapi/polish.py @@ -0,0 +1,147 @@ +"""Tests for the resolver / Operation polish: Optional handling, +operationId derivation, registration-time validation.""" + +from __future__ import annotations + +from typing import Annotated + +import msgspec +import pytest + +from localpost.openapi import FromHeader, FromPath, FromQuery, HttpApp +from tests.openapi.app import make_ctx, run_op + + +# --- Optional handling --------------------------------------------------- + + +class TestOptionalQuery: + def test_optional_query_param_returns_none_when_absent(self): + app = HttpApp() + + @app.get("/items") + def items(q: str | None = None) -> str: + return "missing" if q is None else q + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items")) + assert status == 200 + assert body == b"missing" + + def test_optional_via_union_without_default(self): + app = HttpApp() + + @app.get("/items") + def items(q: str | None) -> str: + return "missing" if q is None else q + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items")) + assert status == 200 + assert body == b"missing" + + def test_optional_query_in_doc_marked_not_required(self): + app = HttpApp() + + @app.get("/items") + def items(q: str | None = None) -> str: + return q or "" + + doc = app.openapi_doc.to_dict() + params = doc["paths"]["/items"]["get"]["parameters"] + assert params[0]["name"] == "q" + # OpenAPI omits ``required`` when false. + assert params[0].get("required") is not True + + def test_optional_int_coerced_when_present(self): + app = HttpApp() + + @app.get("/items") + def items(limit: int | None = None) -> int: + return limit if limit is not None else -1 + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items", query=b"limit=5")) + assert status == 200 + assert msgspec.json.decode(body) == 5 + + +class TestOptionalHeader: + def test_optional_header_returns_none_when_absent(self): + app = HttpApp() + + @app.get("/me") + def me(x_id: Annotated[str | None, FromHeader("X-Id")] = None) -> str: + return "anon" if x_id is None else x_id + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/me")) + assert status == 200 + assert body == b"anon" + + def test_optional_header_in_doc(self): + app = HttpApp() + + @app.get("/me") + def me(x_id: Annotated[str | None, FromHeader("X-Id")] = None) -> str: + return x_id or "" + + params = app.openapi_doc.to_dict()["paths"]["/me"]["get"]["parameters"] + assert params[0]["name"] == "X-Id" + assert params[0].get("required") is not True + + +# --- operationId derivation ---------------------------------------------- + + +class TestOperationId: + def test_operation_id_from_function_name(self): + app = HttpApp() + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> str: + return book_id + + doc = app.openapi_doc.to_dict() + assert doc["paths"]["/books/{book_id}"]["get"]["operationId"] == "get_book" + + def test_lambda_falls_back_to_path_id(self): + app = HttpApp() + app.get("/foo")(lambda: "x") + + doc = app.openapi_doc.to_dict() + assert doc["paths"]["/foo"]["get"]["operationId"] == "get_foo" + + +# --- Registration-time validation ---------------------------------------- + + +class TestRegistrationValidation: + def test_from_path_with_unknown_var_errors(self): + app = HttpApp() + + with pytest.raises(ValueError, match="no such variable"): + + @app.get("/items") + def items(item_id: Annotated[str, FromPath("item_id")]) -> str: + return item_id + + def test_path_template_var_without_matching_param_errors(self): + app = HttpApp() + + with pytest.raises(ValueError, match="no parameter resolves"): + + @app.get("/items/{item_id}") + def items() -> str: + return "x" + + def test_explicit_from_query_for_path_var_errors_with_unbound(self): + # If the user accidentally uses FromQuery for a path var, the + # path var goes unbound — caught at registration time. + app = HttpApp() + + with pytest.raises(ValueError, match="no parameter resolves"): + + @app.get("/items/{item_id}") + def items(item_id: Annotated[str, FromQuery("item_id")]) -> str: + return item_id From ec0be34c32145d411ee572eb16032bebf2322ee6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 2 May 2026 19:07:22 +0400 Subject: [PATCH 169/286] =?UTF-8?q?feat(openapi):=20Server-Sent=20Events?= =?UTF-8?q?=20=E2=80=94=20generator-typed=20handlers=20stream=20as=20SSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operations whose return type is a Generator[T, ...] / Iterator[T, ...] / EventStream[T] now auto-stream as SSE: text/event-stream content type, chunked transfer encoding, one event per yielded value. - localpost/openapi/sse.py: Event[T] msgspec.Struct (data, event, id, retry, comment), EventStream[T] explicit wrapper, encoder per the WHATWG SSE spec (multi-line data: handling, blank-line terminator). - Operation.create now classifies the return as SSE-shaped and routes via start_response + send + finish_response instead of the buffered ctx.complete path. check_cancelled() between events terminates the stream cleanly on client disconnect (when running under thread_pool_handler). - _ResponseShape gained a content_type so the OpenAPI doc emits text/event-stream (with the schema for the yielded type) instead of application/json for SSE operations. - Example app gained a /books/{id}/pages SSE endpoint. Note: Event[T] required dropping ``from __future__ import annotations`` in sse.py because msgspec eval'd the PEP 695 type parameter against module globals where T isn't bound. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/openapi/app.py | 38 +++++++- localpost/openapi/README.md | 42 +++++++++ localpost/openapi/__init__.py | 4 + localpost/openapi/operation.py | 115 +++++++++++++++++++++-- localpost/openapi/sse.py | 115 +++++++++++++++++++++++ tests/openapi/sse.py | 161 +++++++++++++++++++++++++++++++++ 6 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 localpost/openapi/sse.py create mode 100644 tests/openapi/sse.py diff --git a/examples/openapi/app.py b/examples/openapi/app.py index 0199dea..21a0007 100644 --- a/examples/openapi/app.py +++ b/examples/openapi/app.py @@ -11,6 +11,7 @@ curl -X POST http://localhost:8000/books \\ -H 'Content-Type: application/json' \\ -d '{"id": "1", "title": "Dune", "author": "Frank Herbert"}' + curl -N http://localhost:8000/books/42/pages # streams as SSE Docs UIs: http://localhost:8000/docs (Swagger UI) @@ -19,11 +20,20 @@ """ import sys +import time +from collections.abc import Generator from dataclasses import dataclass from localpost import hosting from localpost.http import ServerConfig -from localpost.openapi import BadRequest, Created, HttpApp, NotFound, spec +from localpost.openapi import ( + BadRequest, + Created, + Event, + HttpApp, + NotFound, + spec, +) @dataclass @@ -33,6 +43,13 @@ class Book: author: str +@dataclass +class BookPage: + book_id: str + number: int + content: str + + _LIBRARY: dict[str, Book] = { "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy", author="Douglas Adams"), } @@ -65,5 +82,24 @@ def create_book(book: Book) -> Created[Book]: return Created(book, headers={"Location": f"/books/{book.id}"}) +@app.get("/books/{book_id}/pages") +def stream_pages(book_id: str) -> Generator[Event[BookPage]]: + """Stream the book's pages as Server-Sent Events. + + The Generator return type auto-promotes to ``text/event-stream``. + """ + book = _LIBRARY.get(book_id) + if book is None: + # SSE handlers can only return events; surface "not found" as a + # single error event rather than a 404. (Mid-stream switching to + # JSON requires returning before the first yield — different + # design.) + yield Event(data=BookPage(book_id=book_id, number=0, content="not found"), event="error") + return + for n in range(1, 6): + yield Event(data=BookPage(book_id=book_id, number=n, content=f"page {n} of {book.title}"), id=str(n)) + time.sleep(0.5) + + if __name__ == "__main__": sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index 9917b9b..425cb95 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -156,6 +156,47 @@ or per-operation: def admin() -> str: ... ``` +### Server-Sent Events (SSE) + +Return a generator (or any iterator) and the operation auto-promotes to an +SSE stream — `Content-Type: text/event-stream`, chunked transfer encoding, +one event per yielded value: + +```python +from collections.abc import Generator +from dataclasses import dataclass + +from localpost.openapi import HttpApp, Event + + +@dataclass +class Tick: + n: int + + +app = HttpApp() + + +@app.get("/clock") +def clock() -> Generator[Event[Tick]]: + for n in range(60): + yield Event(data=Tick(n=n), id=str(n)) + time.sleep(1) +``` + +Yield bare values for `data:`-only events; yield `Event` instances for +control over `event:` / `id:` / `retry:` / comment fields. The OpenAPI doc +emits `text/event-stream` (with the schema for `Event[Tick]`) instead of +`application/json` for that operation. + +Cancellation: the framework calls `localpost.http.check_cancelled` between +events, so client disconnects (and pool shutdowns) terminate the stream +without leaking workers — provided the app runs under +`thread_pool_handler` (the default for `HttpApp.service(...)`). + +For an iterator you've already constructed, wrap it in `EventStream(...)` +to be explicit; otherwise just return the generator directly. + ### Built-in auth filters ```python @@ -207,6 +248,7 @@ focused on user-facing concepts. | `results.py` | `OpResult` hierarchy | | `filter.py` | `OpFilter` protocol | | `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth filters | +| `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events | | `spec.py` | OpenAPI 3.2 dataclasses | | `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | | `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index 31c69a2..0dad60d 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -64,6 +64,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: UnprocessableEntity, ) from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry, is_pydantic_model +from localpost.openapi.sse import Event, EventStream __all__ = [ # core @@ -100,4 +101,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: "SchemaRegistry", "REF_TEMPLATE", "is_pydantic_model", + # SSE + "Event", + "EventStream", ] diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index ad54bc2..087eec3 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -10,7 +10,13 @@ from __future__ import annotations import inspect -from collections.abc import Callable, Mapping +from collections.abc import ( + Callable, + Generator, + Iterable, + Iterator, + Mapping, +) from dataclasses import dataclass, replace from http import HTTPMethod from types import UnionType @@ -18,6 +24,7 @@ import msgspec +from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._types import Response as _Response from localpost.http.router import URITemplate from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler @@ -33,6 +40,7 @@ ) from localpost.openapi.results import NoContent, Ok, OpResult from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model +from localpost.openapi.sse import EventStream, iter_events __all__ = ["Operation"] @@ -44,11 +52,12 @@ def _resolve_ctx(ctx: HTTPReqCtx) -> HTTPReqCtx: @dataclass(frozen=True, slots=True) class _ResponseShape: - """One branch of the return type — a (status, body type) pair.""" + """One branch of the return type — a (status, body type, content type).""" status_code: int description: str body_type: Any | None + content_type: str = "application/json" @dataclass(frozen=True, slots=True) @@ -191,6 +200,9 @@ def _run(self, ctx: HTTPReqCtx) -> None: if isinstance(result, _Response): ctx.complete(result, b"") return + if _is_sse_payload(result): + _stream_sse(ctx, result) + return if isinstance(result, OpResult): op_result: OpResult = result else: @@ -333,13 +345,19 @@ def _extract_response_shapes(return_annotation: Any) -> list[_ResponseShape]: continue member_origin = get_origin(member) cls = member_origin if member_origin is not None else member - if isinstance(cls, type) and issubclass(cls, OpResult): + sse_payload = _sse_payload_type(member) + if sse_payload is not _NOT_SSE: + code = 200 + description = "Successful response" + body_type = sse_payload + content_type = "text/event-stream" + has_success = True + elif isinstance(cls, type) and issubclass(cls, OpResult): code = cls._status_code description = cls._description - body_type: Any | None - if cls is NoContent: - body_type = None - else: + body_type = None + content_type = "application/json" + if cls is not NoContent: generic_args = get_args(member) if member_origin is not None else () body_type = generic_args[0] if generic_args else None if code < 400: @@ -352,17 +370,43 @@ def _extract_response_shapes(return_annotation: Any) -> list[_ResponseShape]: code = 200 description = "Successful response" body_type = member + content_type = "application/json" has_success = True if code in seen_codes: continue seen_codes.add(code) - shapes.append(_ResponseShape(code, description, body_type)) + shapes.append(_ResponseShape(code, description, body_type, content_type)) if not has_success and not shapes: shapes.append(_ResponseShape(200, "Successful response", None)) return shapes +# Sentinel: unique object so callers can distinguish "not an SSE return" +# from "SSE return with no payload type" (e.g. a bare Iterator). +_NOT_SSE: Any = object() + + +def _sse_payload_type(annotation: Any) -> Any: + """If ``annotation`` is a ``Generator[T, ...]`` / ``Iterator[T, ...]`` / + ``Iterable[T]`` / ``EventStream[T]``, return ``T`` (or ``_NOT_SSE``). + """ + origin = get_origin(annotation) + if origin is None: + return _NOT_SSE + # ``EventStream[T]`` — origin is ``EventStream`` itself. + if origin is EventStream: + args = get_args(annotation) + return args[0] if args else None + # ``Generator[T, send, return]`` / ``Iterator[T]`` / ``Iterable[T]`` + # all live in ``collections.abc``; their generic origins are the abc + # classes themselves. + if origin not in (Generator, Iterator, Iterable): + return _NOT_SSE + args = get_args(annotation) + return args[0] if args else None + + def _build_responses(shapes: tuple[_ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: responses: dict[str, openapi_spec.Response] = {} for shape in shapes: @@ -372,7 +416,7 @@ def _build_responses(shapes: tuple[_ResponseShape, ...], registry: SchemaRegistr schema = registry.schema_for(shape.body_type) responses[str(shape.status_code)] = openapi_spec.Response( description=shape.description, - content={"application/json": openapi_spec.MediaType(schema=schema)}, + content={shape.content_type: openapi_spec.MediaType(schema=schema)}, ) return responses @@ -420,3 +464,56 @@ def _build_http_response(result: OpResult) -> tuple[_Response, bytes]: def _iter_headers(headers: Mapping[str, str]): for name, value in headers.items(): yield name.encode("ascii"), value.encode("iso-8859-1") + + +# --- SSE streaming ------------------------------------------------------- + +_SSE_RESPONSE_HEADERS: list[tuple[bytes, bytes]] = [ + (b"content-type", b"text/event-stream; charset=utf-8"), + (b"cache-control", b"no-cache"), + (b"x-accel-buffering", b"no"), # Disable proxy buffering (nginx). +] + + +def _is_sse_payload(value: object) -> bool: + """True if ``value`` should be streamed as SSE. + + Recognises explicit :class:`EventStream` and any ``Iterator`` (the + result of calling a generator function or building one explicitly). + Bare ``Iterable`` is too broad — ``list`` / ``tuple`` / ``dict`` are + all iterable but should land in JSON. + """ + return isinstance(value, (EventStream, Iterator)) + + +def _stream_sse(ctx: HTTPReqCtx, source: object) -> None: + """Run ``source`` as an SSE stream over ``ctx``. + + Sends the SSE response headers (chunked transfer encoding is implicit + — we don't set ``content-length`` so h11 picks chunked), then writes + one event per yielded item until the iterator exhausts or the client + disconnects (signalled via :func:`localpost.http.check_cancelled`). + """ + # ``check_cancelled`` only works when a request context is active — + # i.e. when the handler runs under the worker pool. When the SSE op + # runs directly on the selector (no pool, e.g. in tests), skip the + # check; the handler's iteration is bounded by the generator itself. + try: + check_cancelled() + cancel_supported = True + except LookupError: + cancel_supported = False + except RequestCancelled: + return + + ctx.start_response(_Response(status_code=200, headers=list(_SSE_RESPONSE_HEADERS))) + try: + for chunk in iter_events(source): + if cancel_supported: + check_cancelled() + ctx.send(chunk) + except RequestCancelled: + # Client went away; stop iterating. Don't try to finish_response + # — the connection state is uncertain. + return + ctx.finish_response() diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py new file mode 100644 index 0000000..2ca1b02 --- /dev/null +++ b/localpost/openapi/sse.py @@ -0,0 +1,115 @@ +"""Server-Sent Events (SSE) primitives for ``localpost.openapi``. + +Operations whose return type is a ``Generator[T, ...]`` / +``Iterator[T, ...]`` (or ``EventStream[T]``) are auto-promoted to an SSE +response: ``Content-Type: text/event-stream``, chunked transfer encoding, +one ``data:`` block per yielded value. + +Yield :class:`Event` instances for full control over the SSE fields +(``event``, ``id``, ``retry``); yielding a bare value emits ``data: `` +only. Encoding follows the WHATWG SSE spec (newline-prefixed, double-newline +terminator). +""" + +from collections.abc import Iterable, Iterator +from dataclasses import dataclass + +import msgspec + +__all__ = ["Event", "EventStream", "encode_event", "format_data_field"] + + +class Event[T](msgspec.Struct, eq=False, omit_defaults=True): + """One SSE event. + + Args: + data: Payload. Encoded as JSON (msgspec) unless it's already a + ``str``. + event: Optional event name (``event:`` field). + id: Optional last event id (``id:`` field). + retry: Optional reconnection delay in milliseconds (``retry:`` field). + comment: Optional comment line. Useful for keep-alives — the spec + says comment lines (``: ...``) are ignored by clients but keep + the connection alive. + """ + + data: T + event: str | None = None + id: str | None = None + retry: int | None = None + comment: str | None = None + + +@dataclass(frozen=True, slots=True, eq=False) +class EventStream[T]: + """Explicit SSE wrapper around a source iterator. + + Use when you want SSE behaviour without the framework auto-detecting + a ``Generator`` return type — e.g. when returning from an iterator + you've already constructed:: + + @app.get("/feed") + def feed() -> EventStream[Update]: + return EventStream(updates_queue) + + For most cases just write a generator function and return the generator + object directly; the framework recognises both shapes. + """ + + source: Iterable[T] | Iterable[Event[T]] + + +# --- Wire encoding ------------------------------------------------------- + + +def format_data_field(payload: object) -> str: + """Encode an event's ``data`` value into the ``data:`` lines per the + WHATWG SSE spec. + + A multi-line payload becomes one ``data:`` line per source line; a + string is sent as-is; everything else is JSON-encoded with + :func:`msgspec.json.encode`. + """ + if isinstance(payload, str): + text = payload + elif isinstance(payload, (bytes, bytearray, memoryview)): + text = bytes(payload).decode("utf-8") + else: + text = msgspec.json.encode(payload).decode("utf-8") + return "\n".join(f"data: {line}" for line in text.split("\n")) + + +def encode_event(event: Event[object] | object) -> bytes: + """Encode a single event (or bare payload) into SSE wire bytes. + + The output ends with the mandatory blank-line terminator (``\\n\\n``). + """ + if isinstance(event, Event): + lines: list[str] = [] + if event.comment is not None: + lines.extend(f": {line}" for line in event.comment.split("\n")) + if event.event is not None: + lines.append(f"event: {event.event}") + if event.id is not None: + lines.append(f"id: {event.id}") + if event.retry is not None: + lines.append(f"retry: {event.retry}") + lines.append(format_data_field(event.data)) + return ("\n".join(lines) + "\n\n").encode("utf-8") + return (format_data_field(event) + "\n\n").encode("utf-8") + + +def iter_events(source: object) -> Iterator[bytes]: + """Drive ``source`` (a generator, iterator, or :class:`EventStream`) + into a stream of SSE-encoded event bytes.""" + if isinstance(source, EventStream): + source = source.source + iterator: Iterator[object] + if isinstance(source, Iterator): + iterator = source + elif isinstance(source, Iterable): + iterator = iter(source) + else: + raise TypeError(f"SSE source must be iterable, got {type(source).__name__}") + for item in iterator: + yield encode_event(item) diff --git a/tests/openapi/sse.py b/tests/openapi/sse.py new file mode 100644 index 0000000..3bf417d --- /dev/null +++ b/tests/openapi/sse.py @@ -0,0 +1,161 @@ +"""Tests for ``localpost.openapi.sse`` — encoder + Operation wiring + +end-to-end streaming.""" + +from __future__ import annotations + +from collections.abc import Generator, Iterator +from dataclasses import dataclass + +import httpx + +from localpost.openapi import Event, EventStream, HttpApp +from localpost.openapi.sse import encode_event, format_data_field, iter_events + + +@dataclass +class Update: + msg: str + + +# --- Encoder unit tests -------------------------------------------------- + + +class TestEncoder: + def test_string_payload_passes_through(self): + out = encode_event("hi") + assert out == b"data: hi\n\n" + + def test_dict_payload_json_encoded(self): + out = encode_event({"k": "v"}) + assert out == b'data: {"k":"v"}\n\n' + + def test_event_with_id_and_event_field(self): + out = encode_event(Event(data="payload", event="update", id="42")) + # Order: comment, event, id, retry, data + assert out == b"event: update\nid: 42\ndata: payload\n\n" + + def test_event_with_retry(self): + out = encode_event(Event(data="x", retry=5000)) + assert out == b"retry: 5000\ndata: x\n\n" + + def test_event_with_comment(self): + out = encode_event(Event(data="ping", comment="keep-alive")) + assert out == b": keep-alive\ndata: ping\n\n" + + def test_multi_line_payload_emits_multiple_data_lines(self): + out = encode_event("line1\nline2\nline3") + assert out == b"data: line1\ndata: line2\ndata: line3\n\n" + + def test_format_data_field_handles_bytes(self): + assert format_data_field(b"hi") == "data: hi" + + def test_dataclass_payload_json_encoded(self): + out = encode_event(Update(msg="hello")) + assert out == b'data: {"msg":"hello"}\n\n' + + +class TestIterEvents: + def test_iterates_events_from_generator(self): + def gen(): + yield "a" + yield "b" + + chunks = list(iter_events(gen())) + assert chunks == [b"data: a\n\n", b"data: b\n\n"] + + def test_iterates_events_from_event_stream(self): + def gen(): + yield Event(data="x", id="1") + yield Event(data="y", id="2") + + chunks = list(iter_events(EventStream(gen()))) + assert chunks == [b"id: 1\ndata: x\n\n", b"id: 2\ndata: y\n\n"] + + +# --- OpenAPI doc tests --------------------------------------------------- + + +class TestSseSpec: + def test_generator_return_emits_text_event_stream(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> Generator[Update]: + yield Update(msg="x") + + doc = app.openapi_doc.to_dict() + responses = doc["paths"]["/feed"]["get"]["responses"] + assert "text/event-stream" in responses["200"]["content"] + assert "application/json" not in responses["200"]["content"] + + def test_event_typed_generator_uses_event_schema(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> Generator[Event[Update]]: + yield Event(data=Update(msg="x")) + + doc = app.openapi_doc.to_dict() + schema = doc["paths"]["/feed"]["get"]["responses"]["200"]["content"]["text/event-stream"]["schema"] + assert schema["$ref"].startswith("#/components/schemas/Event") + + def test_iterator_return_also_works(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> Iterator[Update]: + return iter([Update(msg="x")]) + + doc = app.openapi_doc.to_dict() + assert "text/event-stream" in (doc["paths"]["/feed"]["get"]["responses"]["200"]["content"]) + + def test_event_stream_return_also_works(self): + app = HttpApp() + + @app.get("/feed") + def feed() -> EventStream[Update]: + return EventStream(iter([Update(msg="x")])) + + doc = app.openapi_doc.to_dict() + assert "text/event-stream" in (doc["paths"]["/feed"]["get"]["responses"]["200"]["content"]) + + +# --- End-to-end integration ---------------------------------------------- + + +class TestSseIntegration: + def test_streams_events_over_http(self, serve_app): + app = HttpApp() + + @app.get("/feed") + def feed() -> Generator[Event[Update]]: + for i in range(3): + yield Event(data=Update(msg=f"tick {i}"), id=str(i)) + + with serve_app(app) as port: + with httpx.stream("GET", f"http://127.0.0.1:{port}/feed", timeout=5) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + body = b"".join(resp.iter_bytes()) + + # Three events, separated by blank lines, in order. + events = body.split(b"\n\n") + non_empty = [e for e in events if e] + assert len(non_empty) == 3 + assert b"tick 0" in non_empty[0] + assert b"id: 0" in non_empty[0] + assert b"tick 2" in non_empty[2] + + def test_finite_generator_terminates_response(self, serve_app): + app = HttpApp() + + @app.get("/done") + def done() -> Generator[str]: + yield "first" + yield "last" + + with serve_app(app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/done", timeout=5) + + assert resp.status_code == 200 + assert resp.content == b"data: first\n\ndata: last\n\n" From 487e0a7bbf55a92a5221fe72afa3a596ef813e77 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 3 May 2026 01:39:10 +0400 Subject: [PATCH 170/286] refactor(http): externalise stale-conn cleanup via op queue Selector.run() no longer sweeps stale conns on every iteration. Cleanup is driven by _OpCleanup posted through the existing cross-thread op queue: _drain_ops coalesces duplicates and runs _cleanup_stale once per drain pass. Selector.post_cleanup() is the public trigger. The bare start_http_server CM owns a small daemon thread that ticks at config.select_timeout and posts cleanup ops, so existing callers keep their cleanup behaviour with no wiring changes. The acceptor topology in _service.py spawns one anyio _periodic_cleanup task driving all worker selectors plus the acceptor; the single-/multi-selector hosted modes go through the bare CM and inherit the daemon-thread driver. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_base.py | 89 ++++++++++++++++++++++---------------- localpost/http/_service.py | 31 ++++++++++++- 2 files changed, 81 insertions(+), 39 deletions(-) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 9ac71a2..038b299 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -58,7 +58,6 @@ "TrackHere", "compose", "start_http_server", - "start_http_server_base", ] @@ -88,7 +87,13 @@ class _OpClose: fd: int -_Op = _OpTrack | _OpClose +@final +@dataclass(frozen=True, slots=True, eq=False) +class _OpCleanup: + """Cleanup stale keep-alive connections.""" + + +_Op = _OpTrack | _OpClose | _OpCleanup # -------------------------------------------------------------------------- @@ -614,17 +619,6 @@ def __init__(self, config: ServerConfig, *, port: int, logger: logging.Logger) - """Cached on the first ``run()`` call. Used to route ``track`` / ``close`` calls inline when invoked from the selector thread itself (e.g. from the accept callback).""" - # Stale-connection cleanup runs lazily — at most once per - # ``_cleanup_interval``. Avoids walking the selector map on - # every iteration when nothing's actually stale (which is the - # common case under load: keep_alive_timeout=15s, rw_timeout=1s). - # Floor at 100 ms so users with degenerate tiny timeouts don't - # pathologically over-cleanup. - self._cleanup_interval: float = max( - min(config.rw_timeout, config.keep_alive_timeout) / 2, - 0.1, - ) - self._last_cleanup_at: float = 0.0 # ----- Public registration API (selector-thread only) ----- @@ -727,6 +721,16 @@ def post_close(self, fd: int) -> None: self._ops.append(_OpClose(fd)) self._wake() + def post_cleanup(self) -> None: + """Cross-thread: ask the selector thread to sweep stale conns. + + Coalesced inside :meth:`_drain_ops` — multiple posts before the + next drain pass run :meth:`_cleanup_stale` once. Cheap when no + conns are stale (single dict iteration). + """ + self._ops.append(_OpCleanup()) + self._wake() + # ----- Op queue + self-pipe helpers ----- def _wake(self) -> None: @@ -750,12 +754,17 @@ def _drain_wakeup(self) -> None: pass def _drain_ops(self) -> None: - """Apply all pending ops on the selector thread.""" + """Apply all pending ops on the selector thread. + + ``_OpCleanup`` is coalesced — multiple posts in the same drain pass + run :meth:`_cleanup_stale` once at the end. + """ + cleanup_due = False while True: try: op = self._ops.popleft() except IndexError: - return + break try: if isinstance(op, _OpTrack): self._apply_track(op.conn) @@ -764,8 +773,12 @@ def _drain_ops(self) -> None: self._sel.unregister(op.fd) except (KeyError, ValueError): pass + elif isinstance(op, _OpCleanup): + cleanup_due = True except Exception: self.logger.exception("Op handler raised for %r", op) + if cleanup_due: + self._cleanup_stale() def _apply_track(self, conn: BaseHTTPConn) -> None: """Selector-thread handler for ``_OpTrack``. @@ -802,25 +815,6 @@ def _find_stale(self) -> Iterator[BaseHTTPConn]: def _cleanup_stale(self) -> None: # Selector-thread only. Lock-free: ``self._sel`` and ``conn.tracked`` # are owned by the selector thread. - # - # We deliberately keep this on the selector thread rather than a - # dedicated cleanup thread. With the lazy gate below the common - # case is an O(1) timestamp compare (no walk); the actual O(N) - # sweep runs at most every ``_cleanup_interval``, ~twice a second - # by default. A separate thread would either need a lock around - # ``self._sel.get_map()`` (losing the lock-free property of the - # current op-queue model) or maintain a parallel data structure - # — both add machinery without buying anything visible. - # - # Lazy: skip the O(N) walk over registered conns when not enough - # time has passed since the last sweep. Worst-case extra detection - # latency is ``_cleanup_interval`` (default 0.5 s) on top of the - # configured ``rw_timeout`` / ``keep_alive_timeout`` — both already - # measure non-fatal conditions. - now = time.monotonic() - if now - self._last_cleanup_at < self._cleanup_interval: - return - self._last_cleanup_at = now stale = list(self._find_stale()) for conn in stale: try: @@ -855,7 +849,6 @@ def run(self, *, timeout: float | None = None) -> None: if timeout is None: timeout = self.config.select_timeout self._drain_ops() - self._cleanup_stale() for key, _ in self._sel.select(timeout=timeout): cb: SelectorCallback = key.data try: @@ -986,8 +979,19 @@ def shutdown(self) -> None: # -------------------------------------------------------------------------- +def _cleanup_ticker(period: float, selector: Selector, stop: threading.Event) -> None: + """Daemon-thread cleanup driver for the bare ``start_http_server`` CM. + + Posts an ``_OpCleanup`` to ``selector`` every ``period`` seconds until + ``stop`` is set. Hosted callers use the anyio variant in ``_service.py`` + instead — this exists so the bare context manager remains self-contained. + """ + while not stop.wait(period): + selector.post_cleanup() + + @contextmanager -def start_http_server_base( +def _start_http_server( config: ServerConfig, handler: RequestHandler, conn_factory: ConnFactory, @@ -1009,12 +1013,23 @@ def start_http_server_base( selector = Selector(config, port=port, logger=logger) conn_handler = TrackHere(handler, conn_factory) + cleanup_stop = threading.Event() + cleanup_thread = threading.Thread( + target=_cleanup_ticker, + args=(config.select_timeout, selector, cleanup_stop), + name=f"localpost-http-cleanup-{port}", + daemon=True, + ) + with closing(server_sock): server = BaseServer(config, conn_handler, logger, server_sock, selector) logger.info("Serving on %s:%d", config.host, server.port) + cleanup_thread.start() try: yield server finally: + cleanup_stop.set() + cleanup_thread.join(timeout=config.select_timeout * 2) server.shutdown() selector.close() @@ -1038,4 +1053,4 @@ def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Abstr ) from e else: raise ValueError(f"unknown backend {backend!r} (expected 'h11' or 'httptools')") - return start_http_server_base(config, handler, HTTPConn) + return _start_http_server(config, handler, HTTPConn) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 93ca5d8..aac6a2c 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -7,9 +7,11 @@ from contextlib import closing from wsgiref.types import WSGIApplication +import anyio from anyio import Event, create_task_group, from_thread, to_thread from localpost import hosting +from localpost._utils import AnyEventView from localpost.hosting import ServiceLifetime from localpost.http._base import ( BaseServer, @@ -37,6 +39,24 @@ def _resolve_ephemeral_port(host: str) -> int: return probe.getsockname()[1] +async def _periodic_cleanup(period: float, shutdown: AnyEventView, *selectors: Selector) -> None: + """Drive stale-conn cleanup on every selector at a fixed cadence. + + One coroutine per selector group is enough — the per-selector op queue + coalesces duplicates, and the sweep itself is cheap when nothing is + expired. Returns cleanly when ``shutdown`` is set so the surrounding + task group can finish; the wait is checkpointed every ``period`` seconds + via ``anyio.move_on_after``. + """ + while not shutdown.is_set(): + with anyio.move_on_after(period): + await shutdown.wait() + if shutdown.is_set(): + return + for sel in selectors: + sel.post_cleanup() + + def _pick_conn_factory(backend: str): """Match :func:`start_http_server`'s backend selection — used by the acceptor topology, where we wire :class:`RoundRobinAcceptor` directly @@ -132,7 +152,7 @@ def run_server() -> None: # Multi-selector: N independent ``BaseServer`` threads bound to the # same address via ``SO_REUSEPORT`` (already enabled in - # ``start_http_server_base``). The kernel hashes incoming SYNs across + # ``_start_http_server``). The kernel hashes incoming SYNs across # the listening sockets; established conns stay with one selector for # their lifetime. The ``handler`` instance is shared — when wrapped # with ``thread_pool_handler`` the underlying channel naturally accepts @@ -179,7 +199,7 @@ async def run(lt: ServiceLifetime) -> None: logger = logging.getLogger(LOGGER_NAME) # Bind the listen socket once on the calling (anyio) thread so we # can compute ``port`` deterministically before any worker spins - # up. This mirrors what ``start_http_server_base`` does internally. + # up. This mirrors what ``_start_http_server`` does internally. listen_sock = socket.create_server( (config.host, config.port), backlog=config.backlog, @@ -234,6 +254,13 @@ async def wait_started() -> None: try: async with create_task_group() as tg: tg.start_soon(wait_started) + tg.start_soon( + _periodic_cleanup, + config.select_timeout, + lt.shutting_down, + acceptor_selector, + *worker_selectors, + ) tg.start_soon(to_thread.run_sync, run_acceptor) for i in range(workers): tg.start_soon(to_thread.run_sync, run_worker, i) From 4f56c514b6720e9c0e85c56076780035e088d61e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 11:53:52 +0400 Subject: [PATCH 171/286] chore: refresh .gitignore from upstream Python template, comment out CLI dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore: pull in latest github/gitignore Python template (Pixi, Marimo, ruff cache, broker artifacts, etc.). - pyproject.toml: comment out the ``click`` dep in ``http`` extra — the HTTP server CLI isn't used at the moment. - sse.py: docstring reflow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 90 +++++++++++++++++++++++++++++++--------- localpost/openapi/sse.py | 8 ++-- pyproject.toml | 2 +- uv.lock | 2 - 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 383853f..d324fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ +.venv-* + +# --- GitHub template, see https://github.com/github/gitignore/blob/main/Python.gitignore --- + # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] +*.py[codz] *$py.class # C extensions @@ -27,8 +31,8 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -46,7 +50,8 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover +*.py.cover +*.lcov .hypothesis/ .pytest_cache/ cover/ @@ -92,45 +97,66 @@ ipython_config.py # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. -#Pipfile.lock +# Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. -#uv.lock +# uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +# poetry.lock +# poetry.toml # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml .pdm-python .pdm-build/ +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff -celerybeat-schedule +celerybeat-schedule* celerybeat.pid +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + # SageMath parsed files *.sage.py # Environments .env +.envrc .venv -.venv-bench/ env/ venv/ ENV/ @@ -162,11 +188,37 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ # PyPI configuration file .pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py index 2ca1b02..190eb04 100644 --- a/localpost/openapi/sse.py +++ b/localpost/openapi/sse.py @@ -23,14 +23,12 @@ class Event[T](msgspec.Struct, eq=False, omit_defaults=True): """One SSE event. Args: - data: Payload. Encoded as JSON (msgspec) unless it's already a - ``str``. + data: Payload. Encoded as JSON (msgspec) unless it's already a ``str``. event: Optional event name (``event:`` field). id: Optional last event id (``id:`` field). retry: Optional reconnection delay in milliseconds (``retry:`` field). - comment: Optional comment line. Useful for keep-alives — the spec - says comment lines (``: ...``) are ignored by clients but keep - the connection alive. + comment: Optional comment line. Useful for keep-alives — the spec says comment lines (``: ...``) are ignored + by clients but keep the connection alive. """ data: T diff --git a/pyproject.toml b/pyproject.toml index aed3ec1..7ebf8bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ scheduler = [ ] http = [ "h11 ~=0.16", - "click ~=8.0", +# "click ~=8.0", # HTTP Server CLI ] http-fast = [ # C-based HTTP/1.1 parser (llhttp) diff --git a/uv.lock b/uv.lock index 7e7e8fb..002ef5d 100644 --- a/uv.lock +++ b/uv.lock @@ -699,7 +699,6 @@ cron = [ { name = "croniter" }, ] http = [ - { name = "click" }, { name = "h11" }, ] http-fast = [ @@ -770,7 +769,6 @@ tests-unit = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = "~=4.12" }, - { name = "click", marker = "extra == 'http'", specifier = "~=8.0" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, From 821d57738d919f55542433c764668a0aeebd5c32 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 11:54:07 +0400 Subject: [PATCH 172/286] feat(openapi): map T | None returns to 404 NotFound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler annotated as ``-> Book | None`` is the natural Python way to say "a book or nothing"; we now make that match the HTTP semantics: - Spec: when a return-type union has both a non-None success type and ``None``, ``_extract_response_shapes`` adds an implicit ``404 Not Found`` (description-only, no body schema). Skipped when the user already declared ``NotFound[X]`` explicitly — that more specific shape wins. A bare ``-> None`` (no Union) is left alone: it still means "200 / empty success", not 404. - Runtime: when the user fn returns ``None`` and the operation was flagged null_is_not_found, ``_run`` builds a ``NotFound(None)`` response instead of ``Ok(None)``. Example app updated: ``get_book`` now returns ``Book | None`` and the framework handles the missing case. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/openapi/app.py | 5 +-- localpost/openapi/operation.py | 31 ++++++++++++--- tests/openapi/polish.py | 69 +++++++++++++++++++++++++++++++++- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/examples/openapi/app.py b/examples/openapi/app.py index 21a0007..4db98c1 100644 --- a/examples/openapi/app.py +++ b/examples/openapi/app.py @@ -67,11 +67,10 @@ def hello(name: str) -> str | BadRequest[str]: @app.get("/books/{book_id}") -def get_book(book_id: str) -> Book | NotFound[str]: +def get_book(book_id: str) -> Book | None: """Look up a book by ID.""" book = _LIBRARY.get(book_id) - if book is None: - return NotFound(f"Book not found: {book_id}") + # Optional[Book] is treated like "a book or NotFound" return book diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index 087eec3..ffd3938 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -38,7 +38,7 @@ FromQuery, is_body_type, ) -from localpost.openapi.results import NoContent, Ok, OpResult +from localpost.openapi.results import NoContent, NotFound, Ok, OpResult from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model from localpost.openapi.sse import EventStream, iter_events @@ -77,6 +77,10 @@ class Operation: (injected by :class:`HttpApp`) and per-operation filters.""" return_shapes: tuple[_ResponseShape, ...] + null_is_not_found: bool + """True when the return annotation was ``T | None`` and we therefore + map a runtime ``None`` to a 404 instead of a 200 with empty body.""" + summary: str operation_id: str description: str @@ -142,7 +146,8 @@ def create( f" {sorted(unbound)!r} that no parameter resolves" ) - return_shapes = tuple(_extract_response_shapes(sig.return_annotation)) + shapes_list, null_is_not_found = _extract_response_shapes(sig.return_annotation) + return_shapes = tuple(shapes_list) doc = inspect.getdoc(fn) or "" summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" @@ -158,6 +163,7 @@ def create( arg_resolver_factories=tuple(arg_factories), filters=filters, return_shapes=return_shapes, + null_is_not_found=null_is_not_found, summary=summary, operation_id=operation_id, description=description, @@ -205,6 +211,9 @@ def _run(self, ctx: HTTPReqCtx) -> None: return if isinstance(result, OpResult): op_result: OpResult = result + elif result is None and self.null_is_not_found: + # ``T | None`` returning None → implicit 404. + op_result = NotFound(None) else: op_result = Ok(result) response, body = _build_http_response(op_result) @@ -330,9 +339,10 @@ def _is_resolver_factory(arg: Any) -> ArgResolverFactory | None: return None -def _extract_response_shapes(return_annotation: Any) -> list[_ResponseShape]: +def _extract_response_shapes(return_annotation: Any) -> tuple[list[_ResponseShape], bool]: + """Return ``(shapes, null_is_not_found)`` from a function's return annotation.""" if return_annotation is inspect.Signature.empty: - return [_ResponseShape(200, "Successful response", None)] + return [_ResponseShape(200, "Successful response", None)], False origin = get_origin(return_annotation) members = list(get_args(return_annotation)) if origin is Union or origin is UnionType else [return_annotation] @@ -340,8 +350,10 @@ def _extract_response_shapes(return_annotation: Any) -> list[_ResponseShape]: shapes: list[_ResponseShape] = [] seen_codes: set[int] = set() has_success = False + has_none = False for member in members: if member is type(None): + has_none = True continue member_origin = get_origin(member) cls = member_origin if member_origin is not None else member @@ -377,9 +389,18 @@ def _extract_response_shapes(return_annotation: Any) -> list[_ResponseShape]: seen_codes.add(code) shapes.append(_ResponseShape(code, description, body_type, content_type)) + # ``T | None`` is the implicit "T or 404 NotFound" — only when paired + # with at least one non-None success type (a bare ``-> None`` annotation + # still means "204 / empty success", not 404), and only when the user + # didn't already declare 404 explicitly via ``NotFound[X]``. + null_is_not_found = has_none and has_success and 404 not in seen_codes + if null_is_not_found: + shapes.append(_ResponseShape(404, "Not Found", None)) + seen_codes.add(404) + if not has_success and not shapes: shapes.append(_ResponseShape(200, "Successful response", None)) - return shapes + return shapes, null_is_not_found # Sentinel: unique object so callers can distinguish "not an SSE return" diff --git a/tests/openapi/polish.py b/tests/openapi/polish.py index 240e7b8..8882b53 100644 --- a/tests/openapi/polish.py +++ b/tests/openapi/polish.py @@ -8,7 +8,7 @@ import msgspec import pytest -from localpost.openapi import FromHeader, FromPath, FromQuery, HttpApp +from localpost.openapi import FromHeader, FromPath, FromQuery, HttpApp, NotFound from tests.openapi.app import make_ctx, run_op @@ -94,6 +94,73 @@ def me(x_id: Annotated[str | None, FromHeader("X-Id")] = None) -> str: # --- operationId derivation ---------------------------------------------- +class TestOptionalReturnIsNotFound: + """``T | None`` return → implicit 404 NotFound when ``None`` is returned.""" + + def test_none_return_yields_404(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | None: + return None if item_id == "missing" else item_id + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items/missing", path_args={"item_id": "missing"})) + assert status == 404 + assert body == b"" + + def test_value_return_still_yields_200(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | None: + return item_id + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/items/x", path_args={"item_id": "x"})) + assert status == 200 + assert body == b"x" + + def test_doc_includes_both_200_and_404(self): + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | None: + return item_id + + responses = app.openapi_doc.to_dict()["paths"]["/items/{item_id}"]["get"]["responses"] + assert set(responses) == {"200", "404"} + assert responses["404"] == {"description": "Not Found"} + + def test_explicit_not_found_takes_precedence(self): + # If the user already declared NotFound[X], the implicit None→404 + # shouldn't overwrite it (and shouldn't add a body-less duplicate). + app = HttpApp() + + @app.get("/items/{item_id}") + def get_item(item_id: str) -> str | NotFound[str] | None: + return None if item_id == "missing" else item_id + + responses = app.openapi_doc.to_dict()["paths"]["/items/{item_id}"]["get"]["responses"] + # Only one 404 — the explicit NotFound[str] wins (has a JSON body schema). + assert "404" in responses + assert "content" in responses["404"] + assert responses["404"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_bare_none_return_is_not_404(self): + # ``-> None`` (no Union) should still mean "200 / empty success", + # not 404. + app = HttpApp() + + @app.get("/ping") + def ping() -> None: + return None + + op = app.operations[0] + status, _, _ = run_op(op, make_ctx(path="/ping")) + assert status == 200 + + class TestOperationId: def test_operation_id_from_function_name(self): app = HttpApp() From f96b5d527df8b4a4049cc2f1e1b37cf7de8268bd Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 13:04:51 +0400 Subject: [PATCH 173/286] =?UTF-8?q?feat(openapi):=20@op=5Ffilter=20?= =?UTF-8?q?=E2=80=94=20declare=20filters=20as=20tiny=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filter can now be written exactly like a handler: declare your inputs (FromHeader / FromQuery / FromBody / HTTPReqCtx), declare your possible failure responses in the return-type union, and the framework infers the OpenAPI contribution from the same annotations. @op_filter def rate_limit( x_client: Annotated[str, FromHeader("X-Client-Id")], ) -> None | TooManyRequests[str]: if quota_exhausted(x_client): return TooManyRequests("Slow down") return None Every operation using this filter automatically picks up: - the X-Client-Id header parameter, - a 429 response with the str body schema. Op-level responses still win over filter-contributed ones (so a handler's own 400 isn't shadowed by a filter's 400). To enable this: - Extracted build_arg_resolvers + extract_response_shapes from Operation.create as public helpers (and renamed _ResponseShape → ResponseShape) so filter.py can reuse the same signature inspection. - Refactored HttpBearerAuth / HttpBasicAuth on top of @op_filter — they now only hand-roll the SecurityScheme registration; the Authorization parameter and 401 response come from the wrapped function's signature. Auth class instances retain stable identity so ``ctx.attrs[]`` still works for principal pickup. - Filters must return None or OpResult; anything else raises TypeError at request time. Test count: 120 → 131. README + example app updated to demonstrate. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/openapi/app.py | 39 +++++- localpost/openapi/README.md | 33 +++++ localpost/openapi/__init__.py | 3 +- localpost/openapi/auth.py | 129 +++++++++-------- localpost/openapi/filter.py | 124 ++++++++++++++++- localpost/openapi/operation.py | 125 ++++++++++------- tests/openapi/auth.py | 8 +- tests/openapi/op_filter_tests.py | 232 +++++++++++++++++++++++++++++++ 8 files changed, 580 insertions(+), 113 deletions(-) create mode 100644 tests/openapi/op_filter_tests.py diff --git a/examples/openapi/app.py b/examples/openapi/app.py index 4db98c1..06c3da6 100644 --- a/examples/openapi/app.py +++ b/examples/openapi/app.py @@ -23,6 +23,7 @@ import time from collections.abc import Generator from dataclasses import dataclass +from typing import Annotated from localpost import hosting from localpost.http import ServerConfig @@ -30,8 +31,11 @@ BadRequest, Created, Event, + FromHeader, HttpApp, - NotFound, + TooManyRequests, + Unauthorized, + op_filter, spec, ) @@ -58,6 +62,30 @@ class BookPage: app = HttpApp(info=spec.Info(title="Library API", version="1.0.0")) +# A filter as a tiny operation: the framework reads the input +# (``X-API-Key`` header) and the failure response (``Unauthorized``) from +# this signature and threads them into every operation that uses it. +@op_filter +def require_api_key( + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", +) -> None | Unauthorized[str]: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return None + + +# Another tiny filter — every-N-requests rate limiter, just for the demo. +_call_counter = {"n": 0} + + +@op_filter +def rate_limit() -> None | TooManyRequests[str]: + _call_counter["n"] += 1 + if _call_counter["n"] % 10 == 0: + return TooManyRequests("Slow down") + return None + + @app.get("/hello/{name}") def hello(name: str) -> str | BadRequest[str]: """Greet someone.""" @@ -74,9 +102,14 @@ def get_book(book_id: str) -> Book | None: return book -@app.post("/books") +@app.post("/books", filters=[require_api_key, rate_limit]) def create_book(book: Book) -> Created[Book]: - """Add a new book to the library.""" + """Add a new book to the library. + + Requires ``X-API-Key: secret``; subject to a (silly) every-10th-request + rate limit. Both filters' input headers and failure responses appear in + the OpenAPI doc automatically — no spec annotations needed here. + """ _LIBRARY[book.id] = book return Created(book, headers={"Location": f"/books/{book.id}"}) diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index 425cb95..aa091ce 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -156,6 +156,39 @@ or per-operation: def admin() -> str: ... ``` +### `@op_filter` — filters as tiny operations + +For the common case, write a filter the same way you'd write a handler: +declare your inputs, declare your possible failure responses in the return +type, and let the framework do the OpenAPI bookkeeping: + +```python +from typing import Annotated + +from localpost.openapi import FromHeader, TooManyRequests, op_filter + + +@op_filter +def rate_limit( + x_client: Annotated[str, FromHeader("X-Client-Id")], +) -> None | TooManyRequests[str]: + if quota_exhausted(x_client): + return TooManyRequests("Slow down") + return None + + +@app.get("/expensive", filters=[rate_limit]) +def expensive() -> str: ... +``` + +Without writing any spec code, every operation that uses this filter gets: +- the `X-Client-Id` header in its `parameters`, +- `429 Too Many Requests` in its `responses` (with the body schema for `str`). + +The filter's return type union *is* the OpenAPI contract. Filters must +return `None` or an `OpResult`; anything else raises `TypeError` at +request time. + ### Server-Sent Events (SSE) Return a generator (or any iterator) and the operation auto-promotes to an diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index 0dad60d..ac09359 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -38,7 +38,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: from localpost.openapi import spec from localpost.openapi.app import HttpApp from localpost.openapi.auth import HttpBasicAuth, HttpBearerAuth -from localpost.openapi.filter import OpFilter +from localpost.openapi.filter import OpFilter, op_filter from localpost.openapi.operation import Operation from localpost.openapi.resolvers import ( ArgResolver, @@ -74,6 +74,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: "spec", # filters "OpFilter", + "op_filter", "HttpBearerAuth", "HttpBasicAuth", # arg resolvers diff --git a/localpost/openapi/auth.py b/localpost/openapi/auth.py index 635b43a..da8dac0 100644 --- a/localpost/openapi/auth.py +++ b/localpost/openapi/auth.py @@ -1,13 +1,12 @@ """Concrete :class:`OpFilter` implementations for HTTP authentication. -Each filter runs at request time *and* contributes to the OpenAPI doc: +Both filters are thin wrappers around a :func:`@op_filter`-decorated +inner function that reads the ``Authorization`` header and validates it. +The OpenAPI parameter (``Authorization``) and the ``401`` response are +contributed *automatically* by ``@op_filter`` — only the +:class:`SecurityScheme` registration at the root is custom code here. -- :meth:`contribute_root` registers a ``SecurityScheme`` under - ``components.securitySchemes``. -- :meth:`contribute_operation` adds a ``security`` requirement and a - ``401`` response. - -Identity (the validated principal) is stashed on ``ctx.attrs[filter]`` +On success the validated principal is stashed on ``ctx.attrs[]`` so user code can pull it via a tiny custom resolver — see the README for the pattern. The validator is a sync callable returning either the principal (any object) on success or ``None`` on rejection. @@ -27,29 +26,19 @@ def me(ctx: HTTPReqCtx) -> dict: ... import base64 import binascii from collections.abc import Callable -from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass, field, replace +from typing import Annotated, Any +from localpost.http.server import HTTPReqCtx from localpost.openapi import spec +from localpost.openapi.filter import _FunctionFilter, op_filter +from localpost.openapi.resolvers import FromHeader from localpost.openapi.results import OpResult, Unauthorized - -if TYPE_CHECKING: - from localpost.http.server import HTTPReqCtx - from localpost.openapi.schemas import SchemaRegistry +from localpost.openapi.schemas import SchemaRegistry __all__ = ["HttpBearerAuth", "HttpBasicAuth"] -_AUTHORIZATION_HEADER = b"authorization" - - -def _read_authorization(ctx: HTTPReqCtx) -> bytes | None: - for name, value in ctx.request.headers: - if name == _AUTHORIZATION_HEADER: - return value - return None - - def _add_security_scheme(doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme) -> spec.OpenAPI: components = replace( doc.components, @@ -60,14 +49,7 @@ def _add_security_scheme(doc: spec.OpenAPI, name: str, scheme: spec.SecuritySche def _add_security_requirement(op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = ()) -> spec.Operation: requirement: dict[str, tuple[str, ...]] = {scheme_name: scopes} - return replace( - op, - security=(*op.security, requirement), - responses={ - "401": spec.Response(description="Unauthorized"), - **op.responses, - }, - ) + return replace(op, security=(*op.security, requirement)) @dataclass(slots=True, eq=False) @@ -91,16 +73,32 @@ class HttpBearerAuth: bearer_format: str = "JWT" description: str = "" + _wrapped: _FunctionFilter = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + principal_key = self # stable identity across requests + + @op_filter + def _bearer( + ctx: HTTPReqCtx, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> None | Unauthorized[str]: + # Default ``""`` makes the header optional at the resolver level so + # absence is reported as 401 (with the spec-aware Unauthorized) rather + # than a generic 400 from FromHeader's "missing required" branch. + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + principal = validator(authorization[7:]) + if principal is None: + return Unauthorized("Invalid token") + ctx.attrs[principal_key] = principal + return None + + self._wrapped = _bearer + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - raw = _read_authorization(ctx) - if raw is None or not raw.startswith(b"Bearer "): - return Unauthorized("Missing or malformed Authorization header") - token = raw[7:].decode("ascii", errors="replace") - principal = self.validator(token) - if principal is None: - return Unauthorized("Invalid token") - ctx.attrs[self] = principal - return None + return self._wrapped(ctx) def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: scheme = spec.SecurityScheme( @@ -112,6 +110,10 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return _add_security_scheme(doc, self.scheme_name, scheme) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + # ``@op_filter`` already adds the Authorization header parameter + # and the 401 response from the wrapped function's signature. + # We only add the security requirement on top. + op = self._wrapped.contribute_operation(op, registry) return _add_security_requirement(op, self.scheme_name) @@ -136,27 +138,42 @@ class HttpBasicAuth: realm: str = "localpost" description: str = "" - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - raw = _read_authorization(ctx) + _wrapped: _FunctionFilter = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} - if raw is None or not raw.startswith(b"Basic "): - return Unauthorized("Missing or malformed Authorization header", headers=challenge) - try: - decoded = base64.b64decode(raw[6:], validate=True).decode("utf-8") - except (binascii.Error, UnicodeDecodeError): - return Unauthorized("Malformed Basic credentials", headers=challenge) - username, sep, password = decoded.partition(":") - if not sep: - return Unauthorized("Malformed Basic credentials", headers=challenge) - principal = self.validator(username, password) - if principal is None: - return Unauthorized("Invalid credentials", headers=challenge) - ctx.attrs[self] = principal - return None + principal_key = self + + @op_filter + def _basic( + ctx: HTTPReqCtx, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> None | Unauthorized[str]: + if not authorization.startswith("Basic "): + return Unauthorized("Missing or malformed Authorization header", headers=challenge) + try: + decoded = base64.b64decode(authorization[6:], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return Unauthorized("Malformed Basic credentials", headers=challenge) + username, sep, password = decoded.partition(":") + if not sep: + return Unauthorized("Malformed Basic credentials", headers=challenge) + principal = validator(username, password) + if principal is None: + return Unauthorized("Invalid credentials", headers=challenge) + ctx.attrs[principal_key] = principal + return None + + self._wrapped = _basic + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + return self._wrapped(ctx) def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: scheme = spec.SecurityScheme(type="http", scheme="basic", description=self.description) return _add_security_scheme(doc, self.scheme_name, scheme) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + op = self._wrapped.contribute_operation(op, registry) return _add_security_requirement(op, self.scheme_name) diff --git a/localpost/openapi/filter.py b/localpost/openapi/filter.py index f50f71a..f2f2f1a 100644 --- a/localpost/openapi/filter.py +++ b/localpost/openapi/filter.py @@ -23,11 +23,35 @@ Filters typically override one or both of these; both have a default implementation that returns the input unchanged so a filter that only needs runtime behaviour can ignore the doc side. + +For the common case — a filter built like a tiny operation, with +arg resolvers and a typed return — use :func:`op_filter`. It does the +signature inspection for you and the OpenAPI contribution comes from +the same annotations that run the resolvers:: + + from localpost.openapi import FromHeader, Unauthorized, op_filter + + + @op_filter + def require_token( + authorization: Annotated[str, FromHeader("Authorization")], + ) -> None | Unauthorized[str]: + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + if not validate(authorization[7:]): + return Unauthorized("Invalid token") + return None + + + @app.get("/me", filters=[require_token]) + def me() -> User: ... """ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: from localpost.http.server import HTTPReqCtx @@ -35,6 +59,8 @@ from localpost.openapi.results import OpResult from localpost.openapi.schemas import SchemaRegistry +__all__ = ["OpFilter", "op_filter"] + @runtime_checkable class OpFilter(Protocol): @@ -63,3 +89,99 @@ def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) ``op`` unchanged. """ ... + + +@dataclass(eq=False, slots=True) +class _FunctionFilter: + """Concrete :class:`OpFilter` produced by :func:`op_filter`. + + Inspects the wrapped function's signature once: arg resolvers come + from the parameters (just like an :class:`Operation`), and the + declared response codes come from the return-type union. The OpenAPI + contribution mirrors what an operation with the same signature would + emit. + """ + + target: Callable[..., Any] + arg_resolvers: tuple[tuple[str, Any], ...] + arg_factories: tuple[tuple[str, Any, Any | None], ...] + return_shapes: tuple[Any, ...] + + # Optional root-level contribution (set by ``with_root``); auth filters + # use this to register their securityScheme. Defaults to a no-op. + root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) + + def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: + from localpost.openapi.results import OpResult as _OpResult # noqa: PLC0415 + + kwargs: dict[str, object] = {} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, _OpResult): + return value + kwargs[name] = value + result = self.target(**kwargs) + if result is None or isinstance(result, _OpResult): + return result + name = getattr(self.target, "__qualname__", None) or repr(self.target) + raise TypeError( + f"@op_filter {name!r} returned {type(result).__name__}; filters must return None or an OpResult" + ) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + if self.root_contribution is None: + return doc + return self.root_contribution(doc, registry) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + from localpost.openapi.operation import _build_responses # noqa: PLC0415 + + # Parameters / requestBody come from the resolver factories. + for _name, param, factory in self.arg_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + # Response codes come from the return-type union. Don't overwrite + # codes the operation already declared (the op's own response is + # more specific than the filter's generic one). + for code, response in _build_responses(self.return_shapes, registry).items(): + if code in op.responses: + continue + op = replace(op, responses={**op.responses, code: response}) + return op + + def with_root(self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI]) -> _FunctionFilter: + """Return a copy that also contributes ``contribution`` at the root. + + Used by auth filters to register their ``SecurityScheme`` while + still inheriting the parameter / response contribution from the + wrapped function's signature. + """ + return replace(self, root_contribution=contribution) + + +def op_filter(fn: Callable[..., Any]) -> _FunctionFilter: + """Wrap ``fn`` as an :class:`OpFilter`. + + The wrapped function looks like an operation handler: its parameters + are resolved from the request (via :class:`FromHeader`, + :class:`FromQuery`, :class:`FromBody`, …) and its return must be + ``None`` (let the operation proceed) or an :class:`OpResult` (short- + circuit with that response). + + The OpenAPI contribution is inferred from the same annotations: the + declared parameters land on every operation that uses this filter, + and the response codes from the return-type union are added to those + operations' ``responses`` (without overwriting codes the operation + already declared). + """ + from localpost.openapi.operation import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + + sig, runtime, factories = build_arg_resolvers(fn) + shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) + return _FunctionFilter( + target=fn, + arg_resolvers=tuple(runtime), + arg_factories=tuple(factories), + return_shapes=tuple(shapes_list), + ) diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index ffd3938..51982e7 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -42,7 +42,7 @@ from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model from localpost.openapi.sse import EventStream, iter_events -__all__ = ["Operation"] +__all__ = ["Operation", "ResponseShape", "build_arg_resolvers", "extract_response_shapes"] # Sentinel — we expose the request ctx for params explicitly typed as HTTPReqCtx. @@ -51,7 +51,7 @@ def _resolve_ctx(ctx: HTTPReqCtx) -> HTTPReqCtx: @dataclass(frozen=True, slots=True) -class _ResponseShape: +class ResponseShape: """One branch of the return type — a (status, body type, content type).""" status_code: int @@ -76,7 +76,7 @@ class Operation: """Filters to run before the resolvers. Includes both app-level filters (injected by :class:`HttpApp`) and per-operation filters.""" - return_shapes: tuple[_ResponseShape, ...] + return_shapes: tuple[ResponseShape, ...] null_is_not_found: bool """True when the return annotation was ``T | None`` and we therefore map a runtime ``None`` to a 404 instead of a 200 with empty body.""" @@ -98,47 +98,22 @@ def create( template = URITemplate.parse(path) path_var_names = set(template.variable_names) - # Resolve PEP 563 string annotations (``from __future__ import - # annotations`` is in effect for most callers) into the real types - # before feeding them to the resolver factories. We build a localns - # from the function's closure cells so types defined in an enclosing - # scope (common in tests, factories) resolve too. - localns = _closure_locals(fn) - try: - sig = inspect.signature(fn, eval_str=True, locals=localns) - except Exception: # noqa: BLE001 - sig = inspect.signature(fn) - try: - hints = get_type_hints(fn, localns=localns, include_extras=True) - except Exception: # noqa: BLE001 - hints = {} - sig = _signature_with_hints(sig, hints) + sig, arg_resolvers, arg_factories = build_arg_resolvers(fn, path_var_names=path_var_names) - arg_resolvers: list[tuple[str, ArgResolver]] = [] - arg_factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] + # Validate path bindings: every {var} in the template must be + # claimed by a parameter (whether implicitly via name match or + # explicitly via Annotated[..., FromPath("var")]). bound_path_vars: set[str] = set() - - for name, param in sig.parameters.items(): - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - raise ValueError(f"handler {_qualname(fn)!r}: *args / **kwargs not supported") - factory = _pick_factory(name, param, path_var_names) - if factory is None: - # HTTPReqCtx pass-through. - arg_resolvers.append((name, _resolve_ctx)) - arg_factories.append((name, param, None)) - else: - if isinstance(factory, FromPath): - bound = factory.name or name - if bound not in path_var_names: - raise ValueError( - f"handler {_qualname(fn)!r}: parameter {name!r} uses FromPath" - f"({bound!r}) but path template {path!r} has no such variable" - f" (available: {sorted(path_var_names) or 'none'})" - ) - bound_path_vars.add(bound) - arg_resolvers.append((name, factory(param))) - arg_factories.append((name, param, factory)) - + for _name, param, factory in arg_factories: + if isinstance(factory, FromPath): + bound = factory.name or param.name + if bound not in path_var_names: + raise ValueError( + f"handler {_qualname(fn)!r}: parameter {param.name!r} uses FromPath" + f"({bound!r}) but path template {path!r} has no such variable" + f" (available: {sorted(path_var_names) or 'none'})" + ) + bound_path_vars.add(bound) unbound = path_var_names - bound_path_vars if unbound: raise ValueError( @@ -146,7 +121,7 @@ def create( f" {sorted(unbound)!r} that no parameter resolves" ) - shapes_list, null_is_not_found = _extract_response_shapes(sig.return_annotation) + shapes_list, null_is_not_found = extract_response_shapes(sig.return_annotation) return_shapes = tuple(shapes_list) doc = inspect.getdoc(fn) or "" @@ -245,6 +220,56 @@ def _qualname(fn: Callable[..., Any]) -> str: return getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) +def build_arg_resolvers( + fn: Callable[..., Any], + *, + path_var_names: set[str] | None = None, +) -> tuple[ + inspect.Signature, + list[tuple[str, ArgResolver]], + list[tuple[str, inspect.Parameter, ArgResolverFactory | None]], +]: + """Inspect ``fn`` and return ``(resolved_signature, runtime_resolvers, + spec_factories)`` for use by :class:`Operation` or :func:`op_filter`. + + Path-template binding is *not* validated here — pass ``path_var_names`` + so :class:`FromPath` is auto-picked for matching parameter names; the + caller is responsible for any "unbound var" error. + """ + path_vars = path_var_names or set() + + # Resolve PEP 563 string annotations (``from __future__ import + # annotations`` is in effect for most callers) into the real types + # before feeding them to the resolver factories. We build a localns + # from the function's closure cells so types defined in an enclosing + # scope (common in tests, factories) resolve too. + localns = _closure_locals(fn) + try: + sig = inspect.signature(fn, eval_str=True, locals=localns) + except Exception: # noqa: BLE001 + sig = inspect.signature(fn) + try: + hints = get_type_hints(fn, localns=localns, include_extras=True) + except Exception: # noqa: BLE001 + hints = {} + sig = _signature_with_hints(sig, hints) + + runtime: list[tuple[str, ArgResolver]] = [] + factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] + for name, param in sig.parameters.items(): + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + raise ValueError(f"handler {_qualname(fn)!r}: *args / **kwargs not supported") + factory = _pick_factory(name, param, path_vars) + if factory is None: + # HTTPReqCtx pass-through. + runtime.append((name, _resolve_ctx)) + factories.append((name, param, None)) + else: + runtime.append((name, factory(param))) + factories.append((name, param, factory)) + return sig, runtime, factories + + def _closure_locals(fn: Callable[..., Any]) -> dict[str, Any]: """Return a name → value dict from ``fn``'s closure cells. @@ -339,15 +364,15 @@ def _is_resolver_factory(arg: Any) -> ArgResolverFactory | None: return None -def _extract_response_shapes(return_annotation: Any) -> tuple[list[_ResponseShape], bool]: +def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape], bool]: """Return ``(shapes, null_is_not_found)`` from a function's return annotation.""" if return_annotation is inspect.Signature.empty: - return [_ResponseShape(200, "Successful response", None)], False + return [ResponseShape(200, "Successful response", None)], False origin = get_origin(return_annotation) members = list(get_args(return_annotation)) if origin is Union or origin is UnionType else [return_annotation] - shapes: list[_ResponseShape] = [] + shapes: list[ResponseShape] = [] seen_codes: set[int] = set() has_success = False has_none = False @@ -387,7 +412,7 @@ def _extract_response_shapes(return_annotation: Any) -> tuple[list[_ResponseShap if code in seen_codes: continue seen_codes.add(code) - shapes.append(_ResponseShape(code, description, body_type, content_type)) + shapes.append(ResponseShape(code, description, body_type, content_type)) # ``T | None`` is the implicit "T or 404 NotFound" — only when paired # with at least one non-None success type (a bare ``-> None`` annotation @@ -395,11 +420,11 @@ def _extract_response_shapes(return_annotation: Any) -> tuple[list[_ResponseShap # didn't already declare 404 explicitly via ``NotFound[X]``. null_is_not_found = has_none and has_success and 404 not in seen_codes if null_is_not_found: - shapes.append(_ResponseShape(404, "Not Found", None)) + shapes.append(ResponseShape(404, "Not Found", None)) seen_codes.add(404) if not has_success and not shapes: - shapes.append(_ResponseShape(200, "Successful response", None)) + shapes.append(ResponseShape(200, "Successful response", None)) return shapes, null_is_not_found @@ -428,7 +453,7 @@ def _sse_payload_type(annotation: Any) -> Any: return args[0] if args else None -def _build_responses(shapes: tuple[_ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: +def _build_responses(shapes: tuple[ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: responses: dict[str, openapi_spec.Response] = {} for shape in shapes: if shape.body_type is None: diff --git a/tests/openapi/auth.py b/tests/openapi/auth.py index fa08544..bdac142 100644 --- a/tests/openapi/auth.py +++ b/tests/openapi/auth.py @@ -98,7 +98,10 @@ def me() -> str: op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] assert op["security"] == [{"bearerAuth": []}] - assert op["responses"]["401"] == {"description": "Unauthorized"} + # 401 response is contributed by the @op_filter wrapper from the + # filter's `Unauthorized[str]` return annotation — body schema included. + assert op["responses"]["401"]["description"] == "Unauthorized" + assert op["responses"]["401"]["content"]["application/json"]["schema"] == {"type": "string"} def test_per_op_does_not_protect_other_ops(self): bearer = HttpBearerAuth(validator=lambda _t: True) @@ -241,7 +244,8 @@ def me() -> str: op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] assert op["security"] == [{"basicAuth": []}] - assert op["responses"]["401"] == {"description": "Unauthorized"} + assert op["responses"]["401"]["description"] == "Unauthorized" + assert op["responses"]["401"]["content"]["application/json"]["schema"] == {"type": "string"} # --- Integration: mixing two filters in one app -------------------------- diff --git a/tests/openapi/op_filter_tests.py b/tests/openapi/op_filter_tests.py new file mode 100644 index 0000000..6c4e2b5 --- /dev/null +++ b/tests/openapi/op_filter_tests.py @@ -0,0 +1,232 @@ +"""Tests for the ``@op_filter`` decorator.""" + +from __future__ import annotations + +from typing import Annotated + +from localpost.http import HTTPReqCtx +from localpost.openapi import ( + BadRequest, + FromHeader, + FromQuery, + HttpApp, + OpFilter, + TooManyRequests, + Unauthorized, + op_filter, +) +from tests.openapi.app import make_ctx, run_op + + +# --- Wrapper smoke ------------------------------------------------------- + + +class TestProtocolConformance: + def test_decorator_produces_op_filter(self): + @op_filter + def f() -> None: + return None + + assert isinstance(f, OpFilter) + + +# --- Runtime semantics --------------------------------------------------- + + +class TestRuntime: + def test_filter_runs_resolved_args(self): + seen: list[str] = [] + + @op_filter + def record(authorization: Annotated[str, FromHeader("Authorization")]) -> None: + seen.append(authorization) + return None + + app = HttpApp(filters=[record]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/x", headers=[(b"authorization", b"Bearer abc")]) + status, _, _ = run_op(op, ctx) + assert status == 200 + assert seen == ["Bearer abc"] + + def test_filter_short_circuit_with_op_result(self): + @op_filter + def deny(token: Annotated[str, FromQuery("token")] = "") -> None | Unauthorized[str]: + if token != "good": + return Unauthorized("Invalid token") + return None + + app = HttpApp(filters=[deny]) + + @app.get("/x") + def x() -> str: + return "secret" + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/x", query=b"token=bad")) + assert status == 401 + assert body == b"Invalid token" + + def test_filter_short_circuit_via_resolver(self): + # When a resolver itself returns an OpResult (e.g. missing required + # header → BadRequest), the filter short-circuits with that result. + @op_filter + def needs_header(x_id: Annotated[str, FromHeader("X-Id")]) -> None: + return None + + app = HttpApp(filters=[needs_header]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + # No X-Id header → resolver short-circuits with 400. + status, body, _ = run_op(op, make_ctx(path="/x")) + assert status == 400 + assert b"Missing required header" in body + + def test_non_none_non_op_result_return_is_error(self): + import pytest + + @op_filter + def bad() -> str: + return "not allowed" # type: ignore[return-value] + + app = HttpApp(filters=[bad]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + with pytest.raises(TypeError, match="filters must return None"): + run_op(op, make_ctx(path="/x")) + + +# --- Spec contribution --------------------------------------------------- + + +class TestSpecContribution: + def test_header_param_appears_on_operation(self): + @op_filter + def require_header( + x_api_key: Annotated[str, FromHeader("X-API-Key")], + ) -> None | Unauthorized[str]: + return None if x_api_key else Unauthorized("missing") + + app = HttpApp(filters=[require_header]) + + @app.get("/x") + def x() -> str: + return "ok" + + params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] + names = [p["name"] for p in params] + assert "X-API-Key" in names + # Coming from the filter, the param is required. + x_api_key_param = next(p for p in params if p["name"] == "X-API-Key") + assert x_api_key_param.get("required") is True + + def test_query_param_appears_on_operation(self): + @op_filter + def require_token(token: Annotated[str, FromQuery("token")]) -> None | Unauthorized[str]: + return None if token else Unauthorized("missing") + + app = HttpApp(filters=[require_token]) + + @app.get("/x") + def x() -> str: + return "ok" + + params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] + names = [p["name"] for p in params] + assert "token" in names + + def test_response_codes_from_return_type_union(self): + @op_filter + def rate_limit() -> None | TooManyRequests[str]: + return None + + app = HttpApp(filters=[rate_limit]) + + @app.get("/x") + def x() -> str: + return "ok" + + responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] + assert "429" in responses + assert responses["429"]["description"] == "Too Many Requests" + + def test_filter_response_does_not_overwrite_operation_response(self): + # If the operation already declares 400 for its own reason, the + # filter's 400 (if any) shouldn't clobber it. + @op_filter + def always_pass() -> None | BadRequest[int]: # different body type + return None + + app = HttpApp(filters=[always_pass]) + + @app.get("/x") + def x() -> str | BadRequest[str]: # the op's own 400 — wins + return "ok" + + responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] + assert "400" in responses + # The op's BadRequest[str] wins over the filter's BadRequest[int]. + assert responses["400"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_per_op_filter_only_contributes_to_its_op(self): + @op_filter + def deny(token: Annotated[str, FromQuery("token")] = "") -> None | Unauthorized[str]: + return Unauthorized("nope") if token != "good" else None + + app = HttpApp() + + @app.get("/protected", filters=[deny]) + def protected() -> str: + return "x" + + @app.get("/public") + def public() -> str: + return "y" + + doc = app.openapi_doc.to_dict() + # /protected has the filter's parameter and 401 response + assert any(p["name"] == "token" for p in doc["paths"]["/protected"]["get"]["parameters"]) + assert "401" in doc["paths"]["/protected"]["get"]["responses"] + # /public doesn't + assert "parameters" not in doc["paths"]["/public"]["get"] + assert "401" not in doc["paths"]["/public"]["get"]["responses"] + + +# --- Compose with HTTPReqCtx -------------------------------------------- + + +class TestCtxParam: + def test_ctx_param_is_passthrough_and_contributes_nothing(self): + seen: list[str] = [] + + @op_filter + def stash(ctx: HTTPReqCtx) -> None: + seen.append(ctx.request.path.decode()) + return None + + app = HttpApp(filters=[stash]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + run_op(op, make_ctx(path="/x")) + assert seen == ["/x"] + + # ctx parameter does NOT show up in the spec. + op_spec = app.openapi_doc.to_dict()["paths"]["/x"]["get"] + assert "parameters" not in op_spec From e0a7cdeeac3d030e11fa449635af9e1e21cced1a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 14:51:00 +0400 Subject: [PATCH 174/286] refactor(http): drop server.py shim, hide concrete HTTPReqCtx, unexport HttpApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `localpost/http/server.py` back-compat shim; in-tree importers now use `localpost.http` (or `localpost.http._base` from inside the package). - Rename `HTTPReqCtxH11` and `HTTPReqCtxHttptools` to `_HTTPReqCtx` — concrete impls are module-private, only the `HTTPReqCtx` Protocol is public. - Drop `HttpApp` from `localpost.http` re-exports; available only as `localpost.http.app.HttpApp`. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/http/apps/localpost_h11.py | 3 +- benchmarks/http/apps/localpost_httptools.py | 3 +- .../http/apps/localpost_httptools_inline.py | 3 +- examples/http/app_server.py | 3 +- localpost/http/README.md | 8 ++--- localpost/http/__init__.py | 12 ++++--- localpost/http/__main__.py | 3 +- localpost/http/_pool.py | 5 ++- localpost/http/_service.py | 2 +- localpost/http/app.py | 2 +- localpost/http/flask.py | 2 +- localpost/http/flask_sentry.py | 2 +- localpost/http/router.py | 4 +-- localpost/http/router_sentry.py | 2 +- localpost/http/server.py | 36 ------------------- localpost/http/server_h11.py | 10 +++--- localpost/http/server_httptools.py | 12 +++---- localpost/http/wsgi.py | 2 +- localpost/openapi/app.py | 2 +- localpost/openapi/auth.py | 2 +- localpost/openapi/filter.py | 2 +- localpost/openapi/operation.py | 2 +- localpost/openapi/resolvers.py | 2 +- tests/http/app.py | 2 +- tests/http/server.py | 2 +- tests/http/service.py | 2 +- tests/openapi/filter.py | 2 +- 27 files changed, 52 insertions(+), 80 deletions(-) delete mode 100644 localpost/http/server.py diff --git a/benchmarks/http/apps/localpost_h11.py b/benchmarks/http/apps/localpost_h11.py index 5bf6e99..0421ca4 100644 --- a/benchmarks/http/apps/localpost_h11.py +++ b/benchmarks/http/apps/localpost_h11.py @@ -8,7 +8,8 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, Response, ServerConfig +from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http.app import HttpApp def main() -> int: diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index 902b773..36d1a9a 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -11,7 +11,8 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, Response, ServerConfig +from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http.app import HttpApp def main() -> int: diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index dbb46f7..8335a6e 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -12,7 +12,8 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, Response, ServerConfig +from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http.app import HttpApp def main() -> int: diff --git a/examples/http/app_server.py b/examples/http/app_server.py index 8e24d67..cf32120 100644 --- a/examples/http/app_server.py +++ b/examples/http/app_server.py @@ -2,7 +2,8 @@ import sys from localpost import hosting -from localpost.http import HttpApp, HTTPReqCtx, ServerConfig +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.http.app import HttpApp app = HttpApp() diff --git a/localpost/http/README.md b/localpost/http/README.md index 268ec0d..c8e02d5 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -71,7 +71,8 @@ The recommended path is `HttpApp`: ```python import sys from localpost.hosting import run_app -from localpost.http import HttpApp, HTTPReqCtx, ServerConfig +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.http.app import HttpApp app = HttpApp() @@ -96,8 +97,7 @@ Or stay close to the wire — `start_http_server` directly: ```python import h11 -from localpost.http.config import ServerConfig -from localpost.http.server import HTTPReqCtx, start_http_server +from localpost.http import HTTPReqCtx, ServerConfig, start_http_server def simple_app(ctx: HTTPReqCtx): @@ -184,7 +184,7 @@ thread + N worker selectors) drop in without touching the request hot path. ## Public API -### `localpost.http.server` +### Server core (`localpost.http`) | Symbol | Notes | | ------------------------- | ------------------------------------------ | diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index ccdbf5a..6a1c105 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,16 +1,21 @@ from localpost.http._base import ( + BodyHandler, ConnFactory, ConnHandler, + HTTPReqCtx, + Middleware, + RequestHandler, RoundRobinAcceptor, Selector, SelectorCallback, TrackHere, + compose, + start_http_server, ) from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, wsgi_server from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response -from localpost.http.app import HttpApp from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( Route, @@ -20,7 +25,6 @@ URITemplate, route_match, ) -from localpost.http.server import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose, start_http_server from localpost.http.wsgi import wrap_wsgi __all__ = [ @@ -53,9 +57,7 @@ "RouteMatch", "URITemplate", "route_match", - # framework - "HttpApp", - # wsgi + # WSGI adapter "wrap_wsgi", # hosting "http_server", diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py index a850d3c..95fd95e 100644 --- a/localpost/http/__main__.py +++ b/localpost/http/__main__.py @@ -9,8 +9,7 @@ import click from localpost import hosting -from localpost.http import ServerConfig, http_server, thread_pool_handler -from localpost.http.server import RequestHandler +from localpost.http import RequestHandler, ServerConfig, http_server, thread_pool_handler def _load_handler(app_str: str) -> RequestHandler: diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index ff80a30..a538e4f 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -37,11 +37,14 @@ PAYLOAD_TOO_LARGE_RESPONSE, SERVICE_UNAVAILABLE_BODY, SERVICE_UNAVAILABLE_RESPONSE, + BodyHandler, + HTTPReqCtx, + RequestHandler, + emit_handler_error, ) from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request from localpost.http._types import BodyTooLarge from localpost.http.config import LOGGER_NAME -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler, emit_handler_error # -------------------------------------------------------------------------- # Internal pool primitive diff --git a/localpost/http/_service.py b/localpost/http/_service.py index aac6a2c..a0bca58 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -15,12 +15,12 @@ from localpost.hosting import ServiceLifetime from localpost.http._base import ( BaseServer, + RequestHandler, RoundRobinAcceptor, Selector, start_http_server, ) from localpost.http.config import LOGGER_NAME, ServerConfig -from localpost.http.server import RequestHandler from localpost.http.wsgi import wrap_wsgi __all__ = ["http_server", "wsgi_server"] diff --git a/localpost/http/app.py b/localpost/http/app.py index 788226d..38c3106 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -62,12 +62,12 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from typing import Any, get_type_hints from localpost import hosting +from localpost.http._base import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose from localpost.http._pool import _Pool, _pool_context from localpost.http._service import http_server from localpost.http._types import Response from localpost.http.config import ServerConfig from localpost.http.router import Routes, URITemplate, route_match -from localpost.http.server import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose __all__ = ["HttpApp"] diff --git a/localpost/http/flask.py b/localpost/http/flask.py index ba44e34..57525e1 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -20,10 +20,10 @@ from flask import Flask +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http._service import http_server from localpost.http._types import Response as _Response from localpost.http.config import ServerConfig -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.wsgi import _build_environ __all__ = ["flask_handler", "flask_server"] diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index 7e96e2a..ce5018c 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -29,8 +29,8 @@ from flask import Flask from flask import request as flask_request +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.flask import _write_response -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.wsgi import _build_environ __all__ = ["sentry_flask_handler"] diff --git a/localpost/http/router.py b/localpost/http/router.py index b376389..a810551 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -23,9 +23,9 @@ from http.client import responses as _http_phrases from typing import Self, final +from localpost.http._base import BodyHandler, HTTPReqCtx +from localpost.http._base import RequestHandler as NativeRequestHandler from localpost.http._types import Response as _Response -from localpost.http.server import BodyHandler, HTTPReqCtx -from localpost.http.server import RequestHandler as NativeRequestHandler __all__ = [ "URITemplate", diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index cb0406d..199aab7 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -37,8 +37,8 @@ def get_book(ctx): ... import sentry_sdk +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http.router import Router, _MatchOk -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler __all__ = ["sentry_router_handler"] diff --git a/localpost/http/server.py b/localpost/http/server.py deleted file mode 100644 index 8f406a6..0000000 --- a/localpost/http/server.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Back-compat re-export for the historic ``localpost.http.server`` import path. - -The h11 server lives in :mod:`localpost.http.server_h11` and is the default -backend. New code should import from :mod:`localpost.http`. -""" - -from __future__ import annotations - -from localpost.http._base import ( - BaseServer as Server, -) -from localpost.http._base import ( - BodyHandler, - HTTPReqCtx, - Middleware, - RequestHandler, - compose, - emit_handler_error, - start_http_server, -) -from localpost.http._types import BodyTooLarge -from localpost.http.server_h11 import HTTPConn, HTTPReqCtxH11 - -__all__ = [ - "start_http_server", - "Server", - "HTTPConn", - "HTTPReqCtx", - "HTTPReqCtxH11", - "BodyHandler", - "RequestHandler", - "Middleware", - "compose", - "BodyTooLarge", - "emit_handler_error", -] diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 504fceb..ace985e 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -44,7 +44,7 @@ from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.config import DEFAULT_BUFFER_SIZE -__all__ = ["HTTPConn", "HTTPReqCtxH11"] +__all__ = ["HTTPConn"] def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11.InformationalResponse: @@ -109,7 +109,7 @@ class HTTPConn(BaseHTTPConn): # current request context shared with the handler; ``_continuation`` # is the post-body callback (None if the pre-body handler completed # inline or borrowed). - _ctx: HTTPReqCtxH11 | None = None + _ctx: _HTTPReqCtx | None = None _continuation: BodyHandler | None = None _body_buf: bytearray = field(default_factory=bytearray) @@ -243,7 +243,7 @@ def _loop(self) -> None: headers=list(event.headers), http_version=event.http_version, ) - ctx = HTTPReqCtxH11(self.selector, self, req) + ctx = _HTTPReqCtx(self.selector, self, req) self._ctx = ctx self._body_buf = bytearray() self._continuation = None @@ -303,7 +303,7 @@ def emit_stale_408(self) -> None: @dataclass(slots=True, eq=False) -class HTTPReqCtxH11: +class _HTTPReqCtx: """Per-request context for the h11 backend. Structurally satisfies :class:`localpost.http._base.HTTPReqCtx`. @@ -328,7 +328,7 @@ def borrowed(self) -> bool: return not self.conn.tracked @contextmanager - def borrow(self) -> Iterator[HTTPReqCtxH11]: + def borrow(self) -> Iterator[_HTTPReqCtx]: """Switch the conn out of selector tracking for the duration of the block.""" assert not self.borrowed self.selector.stop_tracking(self.conn) diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 1f73100..ec86231 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -47,7 +47,7 @@ from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.config import DEFAULT_BUFFER_SIZE -__all__ = ["HTTPConn", "HTTPReqCtxHttptools"] +__all__ = ["HTTPConn"] # RFC 7231 §6.1 reason phrases for the codes the server-side actually emits. @@ -154,7 +154,7 @@ class HTTPConn(BaseHTTPConn): # ``_ctx`` is built in ``on_headers_complete`` and the pre-body handler is # invoked inline. If it returns a continuation, ``_continuation`` is set # and ``on_body`` accumulates into ``_body_buf`` until ``on_message_complete``. - _ctx: HTTPReqCtxHttptools | None = None + _ctx: _HTTPReqCtx | None = None _continuation: BodyHandler | None = None _body_buf: bytearray = field(default_factory=bytearray) _message_complete: bool = False @@ -261,7 +261,7 @@ def on_headers_complete(self) -> None: headers=self._cur_headers, http_version=version, ) - ctx = HTTPReqCtxHttptools( + ctx = _HTTPReqCtx( self.selector, self, req, @@ -461,7 +461,7 @@ class _ProtocolError(Exception): @dataclass(slots=True, eq=False) -class HTTPReqCtxHttptools: +class _HTTPReqCtx: """Per-request context for the httptools backend. The response-write path auto-buffers: ``start_response`` stores the @@ -498,7 +498,7 @@ def borrowed(self) -> bool: return not self.conn.tracked @contextmanager - def borrow(self) -> Iterator[HTTPReqCtxHttptools]: + def borrow(self) -> Iterator[_HTTPReqCtx]: assert not self.borrowed self.selector.stop_tracking(self.conn) try: @@ -506,7 +506,7 @@ def borrow(self) -> Iterator[HTTPReqCtxHttptools]: finally: self._maybe_give_back() - def _defer_streaming_dispatch(self, dispatcher: Callable[[HTTPReqCtxHttptools], None]) -> None: + def _defer_streaming_dispatch(self, dispatcher: Callable[[_HTTPReqCtx], None]) -> None: """Start a streaming worker after the current parser feed returns. httptools fires callbacks from inside ``parser.feed_data``. Starting diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 1e270ff..d7ca76d 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -7,8 +7,8 @@ from urllib.parse import unquote_to_bytes from wsgiref.types import WSGIApplication +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http._types import Response as _Response -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler __all__ = ["wrap_wsgi"] diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 64fa1d7..cdd7ce9 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -31,12 +31,12 @@ def hello(name: str) -> str: from typing import Any, Literal from localpost import hosting +from localpost.http import HTTPReqCtx, RequestHandler from localpost.http._pool import thread_pool_handler from localpost.http._service import http_server from localpost.http._types import Response from localpost.http.config import ServerConfig from localpost.http.router import Routes -from localpost.http.server import HTTPReqCtx, RequestHandler from localpost.openapi import spec as openapi_spec from localpost.openapi._docs import redoc_html, scalar_html, swagger_html from localpost.openapi.filter import OpFilter diff --git a/localpost/openapi/auth.py b/localpost/openapi/auth.py index da8dac0..4f37bcc 100644 --- a/localpost/openapi/auth.py +++ b/localpost/openapi/auth.py @@ -29,7 +29,7 @@ def me(ctx: HTTPReqCtx) -> dict: ... from dataclasses import dataclass, field, replace from typing import Annotated, Any -from localpost.http.server import HTTPReqCtx +from localpost.http import HTTPReqCtx from localpost.openapi import spec from localpost.openapi.filter import _FunctionFilter, op_filter from localpost.openapi.resolvers import FromHeader diff --git a/localpost/openapi/filter.py b/localpost/openapi/filter.py index f2f2f1a..cc3c9dc 100644 --- a/localpost/openapi/filter.py +++ b/localpost/openapi/filter.py @@ -54,7 +54,7 @@ def me() -> User: ... from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: - from localpost.http.server import HTTPReqCtx + from localpost.http import HTTPReqCtx from localpost.openapi import spec from localpost.openapi.results import OpResult from localpost.openapi.schemas import SchemaRegistry diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index 51982e7..35d6746 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -24,10 +24,10 @@ import msgspec +from localpost.http import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._types import Response as _Response from localpost.http.router import URITemplate -from localpost.http.server import BodyHandler, HTTPReqCtx, RequestHandler from localpost.openapi import spec as openapi_spec from localpost.openapi.filter import OpFilter from localpost.openapi.resolvers import ( diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py index 03be4f9..949ee99 100644 --- a/localpost/openapi/resolvers.py +++ b/localpost/openapi/resolvers.py @@ -27,7 +27,7 @@ from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model if TYPE_CHECKING: - from localpost.http.server import HTTPReqCtx + from localpost.http import HTTPReqCtx __all__ = [ "ArgResolver", diff --git a/tests/http/app.py b/tests/http/app.py index 8603918..e776670 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -23,7 +23,6 @@ from localpost.hosting import ServiceLifetimeView, serve from localpost.http import ( BodyHandler, - HttpApp, HTTPReqCtx, Middleware, RequestHandler, @@ -32,6 +31,7 @@ http_server, start_http_server, ) +from localpost.http.app import HttpApp from tests.http._helpers import drain_socket pytestmark = pytest.mark.anyio diff --git a/tests/http/server.py b/tests/http/server.py index 5e1d75c..1ca4d96 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -1,4 +1,4 @@ -"""Tests for the HTTP server (localpost.http.server).""" +"""Tests for the HTTP server (localpost.http).""" from __future__ import annotations diff --git a/tests/http/service.py b/tests/http/service.py index 61d8582..8ca55ba 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -24,6 +24,7 @@ HTTPReqCtx, Request, RequestCancelled, + RequestHandler, Response, Routes, ServerConfig, @@ -33,7 +34,6 @@ streaming_pool_handler, thread_pool_handler, ) -from localpost.http.server import RequestHandler from tests.http._helpers import read_http_response pytestmark = pytest.mark.anyio diff --git a/tests/openapi/filter.py b/tests/openapi/filter.py index 4637815..1d95ef3 100644 --- a/tests/openapi/filter.py +++ b/tests/openapi/filter.py @@ -9,7 +9,7 @@ from tests.openapi.app import make_ctx, run_op if TYPE_CHECKING: - from localpost.http.server import HTTPReqCtx + from localpost.http import HTTPReqCtx from localpost.openapi.results import OpResult from localpost.openapi.schemas import SchemaRegistry From d3e90fcb23788f39674f3713c733d3245b397fc5 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 17:32:17 +0400 Subject: [PATCH 175/286] feat(http): static_handler with sendfile, ETag/Range, Cache-Control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add static_handler(root, *, prefix, cache_control, index) — serves files under root via socket.sendfile() with strong ETag (size + mtime ns), Last-Modified, single-range, and Cache-Control passthrough. Pre-body decisions (404 / 405 / 304 / 416, plus HEAD) complete inline on the selector; GET 200 / 206 returns a BodyHandler so a wrapping thread_pool_handler can run sendfile on a dedicated I/O pool — keeping slow downloads from pinning API workers. Adds HTTPReqCtx.sendfile(response, file, offset, count). The h11 backend uses h11's documented sendfile-pattern hook (placeholder Data event with __len__ only) to advance the ContentLengthWriter without allocating N zero bytes; the httptools backend has no response-side parser state, so it just writes headers + sendfile inline. Out of scope (CDN-fronted deployments handle most of these): on-the-fly compression, precompressed sidecars, multipart byte ranges. Captured as follow-ups in plans/static-files.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/static_files.py | 87 ++++++ localpost/http/README.md | 71 ++++- localpost/http/__init__.py | 3 + localpost/http/_base.py | 14 +- localpost/http/server_h11.py | 54 +++- localpost/http/server_httptools.py | 36 ++- localpost/http/static.py | 385 +++++++++++++++++++++++++ plans/static-files.md | 188 +++++++++++++ tests/http/static.py | 435 +++++++++++++++++++++++++++++ 9 files changed, 1269 insertions(+), 4 deletions(-) create mode 100644 examples/http/static_files.py create mode 100644 localpost/http/static.py create mode 100644 plans/static-files.md create mode 100644 tests/http/static.py diff --git a/examples/http/static_files.py b/examples/http/static_files.py new file mode 100644 index 0000000..21236d5 --- /dev/null +++ b/examples/http/static_files.py @@ -0,0 +1,87 @@ +"""Static-file server with a separate worker pool from the API. + +Run:: + + uv run examples/http/static_files.py + + curl http://localhost:8000/api/hello + curl http://localhost:8000/static/ + curl -I http://localhost:8000/static/ # HEAD: just headers + curl -H 'Range: bytes=0-99' http://localhost:8000/static/ + +The static handler uses ``socket.sendfile()`` for the body (zero-copy) +and lives in its own thread pool sized for I/O-bound concurrency, so a +slow client downloading a large file can't pin the small API pool. +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +from localpost.hosting import run_app, service +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + RequestHandler, + Response, + Routes, + ServerConfig, + http_server, + static_handler, + thread_pool_handler, +) + + +def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + body = b"hello from the API\n" + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body, + ) + return None + + +def build_api() -> RequestHandler: + routes = Routes() + routes.get("/api/hello")(_hello) + return routes.build().as_handler() + + +@service +async def app(): + root = Path(os.environ.get("STATIC_ROOT", ".")) + config = ServerConfig(host="127.0.0.1", port=8000) + + static = static_handler( + root, + prefix=b"/static/", + cache_control="public, max-age=3600", + ) + + async with ( + thread_pool_handler(build_api(), max_concurrency=8) as api_h, + thread_pool_handler(static, max_concurrency=64, backlog=64) as static_h, + ): + def root_handler(ctx: HTTPReqCtx) -> BodyHandler | None: + return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) + + async with http_server(config, root_handler): + yield + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/localpost/http/README.md b/localpost/http/README.md index c8e02d5..d82fd1e 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -189,7 +189,7 @@ thread + N worker selectors) drop in without touching the request hot path. | Symbol | Notes | | ------------------------- | ------------------------------------------ | | `start_http_server(cfg, handler)` | Context manager yielding a `Server` bound to ``handler`` | -| `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`) | +| `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`, `sendfile`) | | `RequestHandler` | `Callable[[HTTPReqCtx], None]` | ### Selector / accept-side topology @@ -227,6 +227,75 @@ thread + N worker selectors) drop in without touching the request hot path. | ------------------------- | ------------------------------------------ | | `wrap_wsgi(app)` | Turn a WSGI app into a `RequestHandler` | +### `localpost.http.static` + +Static file serving via `socket.sendfile()` — zero-copy from the page +cache to the socket. Designed for **CDN-fronted deployments**: pair with +proper `Cache-Control` headers and origin sees roughly one hit per file +per edge per cache lifetime, which is why we skip on-the-fly compression +(the CDN handles `gzip` / `br` at the edge). + +| Symbol | Notes | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `static_handler(root, *, prefix=b"/", cache_control=None, index="index.html")` | Build a `RequestHandler` that serves files under ``root``. | + +Behavior: + +- **Methods**: `GET` and `HEAD`. Anything else returns 405 + `Allow: GET, HEAD`. +- **Resolution**: percent-decoded URL path (with ``prefix`` stripped) is + joined under ``root``, resolved, and checked with `Path.is_relative_to`. + ``..`` segments are rejected before resolution. +- **Conditional GET**: strong `ETag` (size + mtime in nanoseconds) plus + `Last-Modified`. `If-None-Match` (with weak comparison) and + `If-Modified-Since` short-circuit to 304 inline on the selector — no + worker hop, no file open. +- **Range**: single byte-range only (`bytes=N-M`, `bytes=N-`, `bytes=-K`). + Multi-range / unparseable falls back to 200 (RFC 7233 §3.1 compliant). + Out-of-bounds → 416 with `Content-Range: bytes */`. +- **Body**: 200 / 206 GET returns a `BodyHandler` that calls + `ctx.sendfile(...)` — wrap in `thread_pool_handler` to dispatch the + syscall to a worker. HEAD success / 304 / 416 / 404 / 405 complete + inline on the selector. + +#### Recommended composition + +A static handler in its own pool, separate from the API pool, so a few +slow downloads can't pin all the API workers: + +```python +from localpost.http import ( + Routes, ServerConfig, http_server, static_handler, thread_pool_handler, +) + +routes = Routes() +# ... register API routes ... + +api = thread_pool_handler(routes.build().as_handler(), max_concurrency=8) +static = thread_pool_handler( + static_handler("/var/www", prefix=b"/static/", + cache_control="public, max-age=31536000, immutable"), + max_concurrency=128, backlog=64, +) + +async with api as api_h, static as static_h: + def root(ctx): + return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) + async with http_server(ServerConfig(), root): + ... +``` + +The API pool is small and CPU-shaped; the static pool is wide and I/O-shaped. +See [`examples/http/static_files.py`](../../examples/http/static_files.py). + +#### `HTTPReqCtx.sendfile` + +The static handler is the typical caller, but `ctx.sendfile(response, +file, offset, count)` is part of the public `HTTPReqCtx` Protocol and +can be used directly for any zero-copy body. Requires +`Content-Length: ` on the response (chunked is rejected); both +backends keep their parser state consistent with what the kernel writes +out-of-band. + ### `localpost.http.flask` Native Flask adapter — optional extra `[http-flask]`. Bypasses WSGI on both diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 6a1c105..ea012d3 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -25,6 +25,7 @@ URITemplate, route_match, ) +from localpost.http.static import static_handler from localpost.http.wsgi import wrap_wsgi __all__ = [ @@ -64,6 +65,8 @@ "wsgi_server", "thread_pool_handler", "streaming_pool_handler", + # static files + "static_handler", # cancellation "check_cancelled", "RequestCancelled", diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 038b299..4b8eddb 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -35,7 +35,7 @@ from collections.abc import Callable, Iterator from contextlib import AbstractContextManager, closing, contextmanager, suppress from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol, final +from typing import TYPE_CHECKING, Any, BinaryIO, Protocol, final from localpost.http._types import InformationalResponse, Request, Response from localpost.http.config import LOGGER_NAME, ServerConfig @@ -206,6 +206,18 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: def send(self, chunk: Buffer, /) -> None: ... def finish_response(self) -> None: ... def complete(self, response: Response, body: bytes | None = None) -> None: ... + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + """Emit ``response`` and stream ``count`` bytes from ``file`` starting + at ``offset`` via :func:`socket.sendfile` (zero-copy). Terminal — like + :meth:`complete`, no further response calls are valid afterwards. + + Requires the response to declare ``Content-Length: ``; + the backend uses that framing to keep its parser state consistent + with what the kernel actually wrote. The socket is set blocking + (with ``rw_timeout``) for the duration of the syscall — restored + on selector-thread give-back via :meth:`HTTPReqCtx.finish_response`'s + ``_maybe_give_back`` path. + """ BodyHandler = Callable[[HTTPReqCtx], None] diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index ace985e..23d504b 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -21,7 +21,7 @@ from collections.abc import Buffer, Iterator from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Any, cast, final +from typing import Any, BinaryIO, cast, final import h11 @@ -69,6 +69,25 @@ def _has_response_framing(headers) -> bool: return any(name.lower() in {b"content-length", b"transfer-encoding"} for name, _ in headers) +@final +@dataclass(frozen=True, slots=True, eq=False) +class _SendfilePlaceholder: + """Stand-in for body bytes that will be written by ``socket.sendfile``. + + h11 explicitly supports this pattern: from ``h11.Data.data``'s docstring + — *"any object that your socket writing code knows what to do with, + and for which calling len() returns the number of bytes that will be + written"*. Feeding it through ``send_with_data_passthrough`` advances + h11's ``ContentLengthWriter`` counter without forcing an allocation + of N zero bytes for a multi-MB / GB file. + """ + + n: int + + def __len__(self) -> int: + return self.n + + @final @dataclass(slots=True, eq=False) class HTTPConn(BaseHTTPConn): @@ -361,6 +380,39 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.send(body) self.finish_response() + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # ``Content-Length`` framing is required — otherwise h11's writer + # state can't reconcile what we wrote out-of-band. The static handler + # always sets it; bail loudly if a caller forgets. + if _content_length(response.headers) != count: + raise ValueError("sendfile requires Content-Length matching ``count``") + self.start_response(response) + # Advance h11's ContentLengthWriter counter via a placeholder Data + # event — h11 only calls ``len(data)`` and ``write(data)``. The + # placeholder lands in the returned data list (which we ignore); + # the real bytes go on the wire via ``socket.sendfile`` below. + # h11 supports placeholders in ``Data`` events for sendfile — see + # ``h11.Data.data`` docstring. Type-checkers see ``data: bytes``. + placeholder_event = h11.Data(data=_SendfilePlaceholder(count)) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + self.conn.parser.send_with_data_passthrough(placeholder_event) + if self._pending_header_bytes is not None: + self._sock_sendall(self._pending_header_bytes) + self._pending_header_bytes = None + sock = self.conn.sock + sock.settimeout(self.selector.config.rw_timeout) + try: + sock.sendfile(file, offset=offset, count=count) + finally: + if self.conn.tracked: + try: + sock.settimeout(0) + except OSError: + pass + # Consume EOM + drain request side + give back via the existing path. + # Counter is at 0, so EOM produces no extra wire bytes for + # Content-Length framing. + self.finish_response() + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: """Streaming-read API. Rare under the JSON-API contract — typical callers receive the buffered body via ``ctx.body``. After the diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index ec86231..0d39afc 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -29,7 +29,7 @@ from collections.abc import Buffer, Callable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Any, final +from typing import Any, BinaryIO, final import httptools @@ -542,6 +542,40 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.send(body) self.finish_response() + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # ``Content-Length`` framing is required: chunked needs per-chunk + # framing bytes that ``socket.sendfile`` can't produce, and a + # mismatched Content-Length corrupts the wire stream. + declared: int | None = None + for name, value in response.headers: + n = name.lower() + if n == b"transfer-encoding": + raise ValueError("sendfile requires Content-Length framing (no Transfer-Encoding)") + if n == b"content-length": + try: + declared = int(value) + except ValueError as e: + raise ValueError("Content-Length is not a valid integer") from e + if declared != count: + raise ValueError("sendfile requires Content-Length matching ``count``") + self.start_response(response) + if self._pending_header_bytes is not None: + _send_all(self.conn, self._pending_header_bytes) + self._pending_header_bytes = None + sock = self.conn.sock + sock.settimeout(self.selector.config.rw_timeout) + try: + sock.sendfile(file, offset=offset, count=count) + finally: + if self.conn.tracked: + try: + sock.settimeout(0) + except OSError: + pass + # No EOM / chunked terminator with Content-Length framing — just + # advance the conn lifecycle (give back / close-after-response). + self.finish_response() + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: """Streaming-read API. diff --git a/localpost/http/static.py b/localpost/http/static.py new file mode 100644 index 0000000..c310791 --- /dev/null +++ b/localpost/http/static.py @@ -0,0 +1,385 @@ +"""Static file serving via ``socket.sendfile()``. + +Serves files from a root directory with zero-copy bodies, conditional GET +(``ETag`` / ``If-None-Match``, ``Last-Modified`` / ``If-Modified-Since``), +single-range support, and ``Cache-Control`` passthrough. Designed to be +wrapped in :func:`localpost.http.thread_pool_handler` with its own +concurrency budget so slow clients can't pin API workers. + +Pre-body decisions (404 / 405 / 304 / 416, plus HEAD success) run inline +on the selector via :meth:`HTTPReqCtx.complete`. GET 200 / 206 returns a +:data:`BodyHandler` that opens the file and ``sendfile`` s it on the +worker thread. +""" + +from __future__ import annotations + +import mimetypes +import os +from collections.abc import Mapping +from email.utils import formatdate, parsedate_to_datetime +from pathlib import Path +from stat import S_ISREG +from typing import Final, Literal +from urllib.parse import unquote_to_bytes + +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._types import Response + +__all__ = ["static_handler"] + +_ALLOW_METHODS: Final = b"GET, HEAD" +_NOT_FOUND_BODY: Final = b"Not Found" +_METHOD_NOT_ALLOWED_BODY: Final = b"Method Not Allowed" +_RANGE_NOT_SATISFIABLE_BODY: Final = b"Range Not Satisfiable" + + +def static_handler( + root: str | os.PathLike[str], + *, + prefix: bytes = b"/", + cache_control: str | None = None, + index: str | None = "index.html", +) -> RequestHandler: + """Build a :data:`RequestHandler` that serves files under ``root``. + + Args: + root: Directory to serve from. Resolved at construction; every + request path is verified to stay under this directory. + prefix: URL prefix to strip from ``ctx.request.path`` before + resolving against ``root``. Default ``b"/"`` matches a + handler mounted at the URL root. + cache_control: If set, the value is sent verbatim as the + ``Cache-Control`` response header on 200 / 206 / 304. + index: Filename to serve when the resolved path is a directory. + ``None`` disables directory → index resolution (returns 404). + + Returns: + A :data:`RequestHandler`. For 200 / 206 GET, returns a + :data:`BodyHandler` so a wrapping :func:`thread_pool_handler` + can dispatch the ``sendfile`` to a worker. Other outcomes + complete inline on the selector and return ``None``. + + Example:: + + from localpost.http import http_server, thread_pool_handler + from localpost.http.static import static_handler + + h = thread_pool_handler( + static_handler("/var/www", prefix=b"/static/", + cache_control="public, max-age=31536000, immutable"), + max_concurrency=128, backlog=64, + ) + """ + root_path = Path(os.fspath(root)).resolve(strict=True) + if not root_path.is_dir(): + raise ValueError(f"root must be a directory: {root_path}") + cc_bytes = cache_control.encode("ascii") if cache_control is not None else None + + def handle(ctx: HTTPReqCtx) -> BodyHandler | None: + method = ctx.request.method + if method not in (b"GET", b"HEAD"): + # Always include the body — the caller used a non-HEAD method. + ctx.complete( + Response( + status_code=405, + headers=[ + (b"allow", _ALLOW_METHODS), + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_METHOD_NOT_ALLOWED_BODY)).encode("ascii")), + ], + ), + _METHOD_NOT_ALLOWED_BODY, + ) + return None + + url_path = ctx.request.path + if not url_path.startswith(prefix): + _send_not_found(ctx, method) + return None + + target = _resolve(root_path, url_path[len(prefix):], index=index) + if target is None: + _send_not_found(ctx, method) + return None + + try: + st = target.stat() + except (FileNotFoundError, NotADirectoryError, PermissionError): + _send_not_found(ctx, method) + return None + if not S_ISREG(st.st_mode): + _send_not_found(ctx, method) + return None + + size = st.st_size + etag = _etag(st) + last_modified = formatdate(st.st_mtime, usegmt=True).encode("ascii") + headers = _headers_index(ctx.request.headers) + + # Conditional GET — If-None-Match takes precedence over If-Modified-Since. + inm = headers.get(b"if-none-match") + ims = headers.get(b"if-modified-since") + if inm is not None and _if_none_match_matches(inm, etag): + _send_not_modified(ctx, etag, last_modified, cc_bytes) + return None + if inm is None and ims is not None and _if_modified_since_satisfied(ims, st.st_mtime): + _send_not_modified(ctx, etag, last_modified, cc_bytes) + return None + + # Range — single byte-range only. Multi-range / unparseable falls back to 200. + offset = 0 + length = size + status = 200 + content_range: bytes | None = None + if (rng := headers.get(b"range")) is not None: + parsed = _parse_range(rng, size) + if parsed == "unsatisfiable": + _send_range_not_satisfiable(ctx, method, size, etag, last_modified) + return None + if parsed is not None: + offset, length = parsed + status = 206 + content_range = f"bytes {offset}-{offset + length - 1}/{size}".encode("ascii") + + response_headers: list[tuple[bytes, bytes]] = [ + (b"content-type", _content_type(target)), + (b"content-length", str(length).encode("ascii")), + (b"etag", etag), + (b"last-modified", last_modified), + (b"accept-ranges", b"bytes"), + ] + if content_range is not None: + response_headers.append((b"content-range", content_range)) + if cc_bytes is not None: + response_headers.append((b"cache-control", cc_bytes)) + + response = Response(status_code=status, headers=response_headers) + + if method == b"HEAD" or length == 0: + ctx.complete(response) + return None + + return _SendFileBody(target, response, offset, length) + + return handle + + +# -------------------------------------------------------------------------- +# Body handler — sendfile on the worker thread +# -------------------------------------------------------------------------- + + +class _SendFileBody: + """Body handler closure: open + :meth:`HTTPReqCtx.sendfile` + finish. + + Spelled as a callable class (not a closure) so its captured state is + visible in repr — same convention as the dispatch-chain callables in + :mod:`localpost.http._base`. + """ + + __slots__ = ("length", "offset", "path", "response") + + def __init__(self, path: Path, response: Response, offset: int, length: int) -> None: + self.path = path + self.response = response + self.offset = offset + self.length = length + + def __call__(self, ctx: HTTPReqCtx) -> None: + with self.path.open("rb") as f: + ctx.sendfile(self.response, f, self.offset, self.length) + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _resolve(root: Path, rel: bytes, *, index: str | None) -> Path | None: + """Decode ``rel`` and resolve under ``root``. Returns ``None`` on + decode failure, traversal, or a directory request without an + ``index``. The returned path may not exist — caller stats it. + """ + try: + decoded = unquote_to_bytes(rel).decode("utf-8") + except UnicodeDecodeError: + return None + if "\0" in decoded: + return None + # Defensive: reject ``..`` segments before path resolution. ``Path.resolve`` + # would normalise them out, but we'd still want to refuse the request + # rather than silently serve a sibling. + if any(p == ".." for p in decoded.split("/")): + return None + candidate = (root / decoded.lstrip("/")).resolve() + if not candidate.is_relative_to(root): + return None + # Directory → append index, re-resolve so the same containment check + # applies. ``index`` is a config-supplied filename, so it can't escape, + # but the re-resolution is cheap and keeps the invariant explicit. + try: + if candidate.is_dir(): + if index is None: + return None + candidate = (candidate / index).resolve() + if not candidate.is_relative_to(root): + return None + except OSError: + return None + return candidate + + +def _etag(st: os.stat_result) -> bytes: + """Strong ETag from inode size + mtime nanoseconds. + + Cheap and stable across restarts — no need for a content hash. + """ + return f'"{st.st_size:x}-{st.st_mtime_ns:x}"'.encode("ascii") + + +def _if_none_match_matches(header: bytes, etag: bytes) -> bool: + """RFC 7232 §3.2: weak comparison. ``W/"x"`` matches ``"x"`` and vice + versa; both compare equal to our strong tag. + """ + if header.strip() == b"*": + return True + bare = etag[2:] if etag.startswith(b"W/") else etag + for tag in header.split(b","): + tag = tag.strip() + if tag.startswith(b"W/"): + tag = tag[2:] + if tag == bare: + return True + return False + + +def _if_modified_since_satisfied(header: bytes, mtime: float) -> bool: + """Return ``True`` iff the resource has *not* been modified since the + header date — i.e. caller should respond 304. + """ + try: + since = parsedate_to_datetime(header.decode("ascii")) + except (UnicodeDecodeError, TypeError, ValueError): + return False + if since is None: + return False + # HTTP-date carries whole-second precision; compare on integer seconds. + return int(mtime) <= int(since.timestamp()) + + +def _parse_range(header: bytes, size: int) -> tuple[int, int] | Literal["unsatisfiable"] | None: + """Parse a ``Range:`` header. Returns ``(offset, length)`` for a + satisfiable single byte-range, ``"unsatisfiable"`` for a syntactically + valid but out-of-bounds range (→ 416), or ``None`` for absent / + unparseable / multi-range (→ fall back to full body). + """ + unit, _, spec = header.strip().partition(b"=") + if unit.lower() != b"bytes" or not spec: + return None + if b"," in spec: + # Multi-range. RFC 7233 allows it; our writer doesn't speak + # multipart/byteranges. Falling back to 200 is RFC-compliant. + return None + start_b, sep, end_b = spec.partition(b"-") + if not sep: + return None + if size == 0: + # Any range against an empty file is unsatisfiable per RFC 7233 §2.1. + return "unsatisfiable" + try: + if not start_b: + # Suffix range: last N bytes. + if not end_b: + return None + n = int(end_b) + if n <= 0: + return None + n = min(n, size) + return size - n, n + start = int(start_b) + if start < 0: + return None + if start >= size: + return "unsatisfiable" + if not end_b: + return start, size - start + end = int(end_b) + if end < start: + return None + end = min(end, size - 1) + return start, end - start + 1 + except ValueError: + return None + + +def _content_type(path: Path) -> bytes: + mime, _ = mimetypes.guess_type(path.name) + return (mime or "application/octet-stream").encode("ascii") + + +def _headers_index(headers) -> Mapping[bytes, bytes]: + """Last-value-wins lookup over header pairs. Sufficient for the + request headers we read here (``If-None-Match``, ``If-Modified-Since``, + ``Range``) — none of them are list-valued in practice. + """ + return dict(headers) + + +# -------------------------------------------------------------------------- +# Canned non-success responses (selector-thread inline) +# -------------------------------------------------------------------------- + + +def _send_not_found(ctx: HTTPReqCtx, method: bytes) -> None: + ctx.complete( + Response( + status_code=404, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_NOT_FOUND_BODY)).encode("ascii")), + ], + ), + # HEAD: headers describe the body that GET would receive; the bytes + # themselves must not go on the wire. + None if method == b"HEAD" else _NOT_FOUND_BODY, + ) + + +def _send_not_modified( + ctx: HTTPReqCtx, + etag: bytes, + last_modified: bytes, + cache_control: bytes | None, +) -> None: + headers: list[tuple[bytes, bytes]] = [ + (b"etag", etag), + (b"last-modified", last_modified), + ] + if cache_control is not None: + headers.append((b"cache-control", cache_control)) + ctx.complete(Response(status_code=304, headers=headers)) + + +def _send_range_not_satisfiable( + ctx: HTTPReqCtx, + method: bytes, + size: int, + etag: bytes, + last_modified: bytes, +) -> None: + ctx.complete( + Response( + status_code=416, + headers=[ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(_RANGE_NOT_SATISFIABLE_BODY)).encode("ascii")), + (b"content-range", f"bytes */{size}".encode("ascii")), + (b"etag", etag), + (b"last-modified", last_modified), + ], + ), + None if method == b"HEAD" else _RANGE_NOT_SATISFIABLE_BODY, + ) + + diff --git a/plans/static-files.md b/plans/static-files.md new file mode 100644 index 0000000..0486d22 --- /dev/null +++ b/plans/static-files.md @@ -0,0 +1,188 @@ +# Static file serving + +## Context + +The HTTP server (`localpost/http/`) is tuned for the JSON-API common case +— small bodies, one-chunk responses, headers + first body chunk coalesced +into a single `sendall` (`server_h11.py:420`, `server_httptools.py:639`). +Serving static files through this path means reading every byte into +Python and shipping it through `send` — no zero-copy, no Range, no +conditionals, no cache-control story. + +The user's deployment is **CDN-fronted** (CloudFront / Cloud CDN / +Cloudflare). With `Cache-Control: public, max-age=…, immutable` on +fingerprinted assets, origin is hit roughly once per file per edge per +forever. That shrinks the optimization target: + +- **In scope (v1):** zero-copy body via `socket.sendfile()`, conditional + GET (`ETag` / `If-None-Match`, `Last-Modified` / `If-Modified-Since`), + single-range support, `Cache-Control` passthrough. +- **Out of scope (CDN handles them, or rare):** on-the-fly compression, + precompressed sidecars (`foo.css.br`, `foo.css.zst`), multipart byte + ranges, symlink-escape hardening beyond `Path.resolve()`. + +## Design + +### `static_handler` — public entry + +New module: `localpost/http/static.py`. Exports a single function: + +```python +def static_handler( + root: str | os.PathLike[str], + *, + prefix: bytes = b"/", + cache_control: str | None = None, + index: str | None = "index.html", +) -> RequestHandler: ... +``` + +`prefix` is stripped off `ctx.request.path` before resolution — lets the +caller mount the handler under any URL prefix without a router. `root` is +resolved once at construction (`Path(root).resolve(strict=True)`), and +every request path resolves under it via `is_relative_to`. + +Returns a closure that satisfies `RequestHandler` — +`Callable[[HTTPReqCtx], BodyHandler | None]`. + +### Pre-body / body split (mirrors `Router`) + +Stat happens once in pre-body. Decisions that don't need the body run +inline on the selector via `ctx.complete()`; the actual sendfile runs in +a returned `BodyHandler` (which `thread_pool_handler` will dispatch to a +worker). Open happens in the body handler — no syscall wasted on a 304. + +| Outcome | Phase | Action | +|---------|-------|--------| +| 405 wrong method | pre-body, selector | `complete()` with `Allow: GET, HEAD` | +| 404 not found / traversal / not file | pre-body, selector | `complete()` | +| 304 If-None-Match / If-Modified-Since | pre-body, selector | `complete()` | +| 416 unsatisfiable Range | pre-body, selector | `complete()` with `Content-Range: bytes */size` | +| 200 / 206 HEAD | pre-body, selector | `complete()` (no body) | +| 200 / 206 GET | returns `BodyHandler` | sendfile on worker | + +### Sendfile path + +`socket.sendfile()` requires a blocking socket — non-blocking is +documented as unsupported. The existing `_send_all` blocking-with-timeout +pattern (`_base.py:275`) and `_maybe_give_back` non-blocking reset +(`server_h11.py:340`) already cover the lifecycle: + +```python +sock = ctx.conn.sock +sock.settimeout(ctx.selector.config.rw_timeout) +ctx.start_response(Response(status, headers)) # buffers header bytes +ctx.send(b"") # flushes header bytes +with open(path, "rb") as f: + sock.sendfile(f, offset=range_start, count=range_len) +ctx.finish_response() # empty EOM under fixed CL +# give-back path resets timeout to non-blocking +``` + +`send(b"")` flushes the buffered headers on both backends — verified: +- h11: `parser.send(h11.Data(b""))` returns `b""`, the + `_pending_header_bytes is not None` branch combines and sends + (`server_h11.py:425`). +- httptools: `if not chunk and self._pending_header_bytes is None: return` + is bypassed when headers are pending, falls through to the + `_pending_header_bytes is not None` send (`server_httptools.py:639`). + +No new method on `HTTPReqCtx` Protocol. + +### Helpers in `static.py` + +- `_resolve(root: Path, url_path: bytes) -> Path | None` — percent-decode, + reject `..` segments defensively, `Path.resolve()`, then + `is_relative_to(root_resolved)`. Returns `None` on traversal / missing. +- `_etag(st) -> bytes` — `f'"{st.st_size:x}-{st.st_mtime_ns:x}"'`. +- `_if_none_match(header: bytes, etag: bytes) -> bool` — comma-split, + trim whitespace, weak-prefix-aware compare. +- `_parse_range(header: bytes, size: int) -> tuple[int, int] | None | "unsatisfiable"` + — `(start, length)`, `None` for absent / multi-range / unparseable + (200 fallback per RFC 7233 §3.1), unsatisfiable sentinel for 416. +- `_content_type(path: Path) -> bytes` — `mimetypes.guess_type`, + `application/octet-stream` fallback. +- `_serve_file(ctx, path, headers, offset, length)` — the sendfile body + handler. + +### Composition (user-visible) + +Documented in the README, not built-in: + +```python +api = thread_pool_handler(routes.build().as_handler(), max_concurrency=8) +static = thread_pool_handler( + static_handler("/var/www", prefix=b"/static/", + cache_control="public, max-age=31536000, immutable"), + max_concurrency=128, backlog=64, +) + +def root(ctx): + return (static if ctx.request.path.startswith(b"/static/") else api)(ctx) + +async with http_server(config, root): + ... +``` + +Two pools, sized for their workloads — slow-client downloads can't pin +API workers. + +## Plan + +### Step 1 — `static_handler` skeleton + selector-side outcomes + +1. Create `localpost/http/static.py` with `_resolve`, `_etag`, + `_if_none_match`, `_parse_range`, `_content_type`, and the public + `static_handler` returning a `RequestHandler` closure. +2. Implement pre-body branches: 405, 404, 304, 416, HEAD-200/206. All via + `ctx.complete()` inline. Return `None` from those paths. +3. Implement the GET 200 / 206 branch: stat + headers built pre-body, + `BodyHandler` returned that opens, sets timeout, sendfile, finish. +4. Re-export `static_handler` from `localpost/http/__init__.py`. +5. Run `just check localpost/http/static.py`. + +### Step 2 — Tests + +`tests/http/test_static.py`: +- Path traversal (`/static/../etc/passwd` → 404). +- Range: full, `0-99`, `100-199`, suffix `-50`, beyond-EOF (416), + unparseable (200 fallback), multi-range (200 fallback). +- Conditionals: `If-None-Match` hit/miss; `If-Modified-Since` before / + equal / after mtime. +- HEAD parity: same headers, no body. +- Content-Type: `.html`, `.css`, `.png`, unknown extension. +- Index: directory → `index.html`; missing index → 404. +- Method: `PUT` → 405 + `Allow: GET, HEAD`. +- `Cache-Control` passthrough verbatim. +- Large file (>1 MB) round-trip — exercises sendfile multi-iteration. + +Run via `just unit-tests`. + +### Step 3 — Docs + example + +1. New section in `localpost/http/README.md` covering the handler, the + sendfile / cache-control story, and the two-pool composition pattern. +2. New `examples/http/static_files.py` — minimal working server. + +## Followups (separate PRs / not now) + +- **Precompressed sidecars** — `foo.css.br`, `foo.css.zst`. Useful when + the CDN doesn't negotiate the encoding the client wants (zstd today). + Pick `path + ".br"` / `".zst"` / `".gz"` if it exists and the client's + `Accept-Encoding` includes it. Adds a stat-per-encoding to the + pre-body cost; keep behind an opt-in arg. +- **`compress_handler`** — on-the-fly gzip/br/zstd middleware for the + *dynamic* path (JSON API responses), for users not behind a CDN. + Wraps an inner `RequestHandler`, intercepts `complete(...)`, + compresses, adjusts `Content-Length` + `Content-Encoding`. SSE / + chunked streaming compression is a further follow-up. +- **Multipart byte ranges** — multi-range `Range:` requests serialised + as `multipart/byteranges`. Niche; current behaviour is to fall back + to a 200 full-body response, which is RFC-compliant. +- **Per-route pool API** — currently composition is the user's problem; + a `Routes` builder that takes a per-route pool argument would let the + static / API split happen behind one router instead of a hand-rolled + dispatcher. +- **Symlink-escape hardening** — current `Path.resolve()` follows + symlinks before the `is_relative_to` check, which is correct, but + some deployments want symlinks rejected outright. Opt-in flag. diff --git a/tests/http/static.py b/tests/http/static.py new file mode 100644 index 0000000..f639ddf --- /dev/null +++ b/tests/http/static.py @@ -0,0 +1,435 @@ +"""Tests for localpost.http.static — file serving via socket.sendfile().""" + +from __future__ import annotations + +import os +import time +from email.utils import formatdate +from pathlib import Path + +import httpx +import pytest + +from localpost.http import static_handler +from localpost.http.static import ( + _etag, + _if_modified_since_satisfied, + _if_none_match_matches, + _parse_range, + _resolve, +) + +# --- Helpers (unit) ------------------------------------------------------ + + +class TestResolve: + def test_simple(self, tmp_path: Path): + (tmp_path / "a.txt").write_bytes(b"x") + assert _resolve(tmp_path, b"a.txt", index=None) == tmp_path / "a.txt" + + def test_leading_slash_stripped(self, tmp_path: Path): + (tmp_path / "a.txt").write_bytes(b"x") + assert _resolve(tmp_path, b"/a.txt", index=None) == tmp_path / "a.txt" + + def test_percent_decode(self, tmp_path: Path): + (tmp_path / "a b.txt").write_bytes(b"x") + assert _resolve(tmp_path, b"a%20b.txt", index=None) == tmp_path / "a b.txt" + + def test_traversal_dotdot_segment_rejected(self, tmp_path: Path): + # Even before resolve(), explicit ``..`` segments are rejected. + assert _resolve(tmp_path, b"../etc/passwd", index=None) is None + assert _resolve(tmp_path, b"foo/../../etc/passwd", index=None) is None + + def test_traversal_via_encoded_dotdot(self, tmp_path: Path): + # %2e%2e decodes to ``..`` — caught by the segment check. + assert _resolve(tmp_path, b"%2e%2e/etc/passwd", index=None) is None + + def test_null_byte_rejected(self, tmp_path: Path): + assert _resolve(tmp_path, b"a\x00b", index=None) is None + + def test_invalid_utf8_rejected(self, tmp_path: Path): + # %ff is not valid UTF-8. + assert _resolve(tmp_path, b"%ff.txt", index=None) is None + + def test_directory_with_index(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "index.html").write_bytes(b"x") + assert _resolve(tmp_path, b"sub/", index="index.html") == tmp_path / "sub" / "index.html" + assert _resolve(tmp_path, b"sub", index="index.html") == tmp_path / "sub" / "index.html" + + def test_directory_no_index(self, tmp_path: Path): + (tmp_path / "sub").mkdir() + assert _resolve(tmp_path, b"sub/", index=None) is None + + def test_directory_index_disabled_when_missing(self, tmp_path: Path): + # Index resolution returns the joined path even when the index file + # does not exist; caller stats and 404s. + (tmp_path / "sub").mkdir() + result = _resolve(tmp_path, b"sub/", index="index.html") + assert result is not None + assert result == tmp_path / "sub" / "index.html" + assert not result.exists() + + +class TestETag: + def test_shape(self, tmp_path: Path): + p = tmp_path / "a.txt" + p.write_bytes(b"hello") + st = p.stat() + tag = _etag(st) + assert tag.startswith(b'"') + assert tag.endswith(b'"') + # Stable across re-stats of the same file. + assert _etag(p.stat()) == tag + + +class TestIfNoneMatch: + def test_strong_match(self): + assert _if_none_match_matches(b'"abc"', b'"abc"') + + def test_no_match(self): + assert not _if_none_match_matches(b'"abc"', b'"xyz"') + + def test_wildcard(self): + assert _if_none_match_matches(b"*", b'"anything"') + + def test_weak_match(self): + assert _if_none_match_matches(b'W/"abc"', b'"abc"') + assert _if_none_match_matches(b'"abc"', b'W/"abc"') + + def test_multi_value(self): + assert _if_none_match_matches(b'"x", "abc", "y"', b'"abc"') + assert not _if_none_match_matches(b'"x", "y"', b'"abc"') + + def test_whitespace_tolerant(self): + assert _if_none_match_matches(b' "abc" , "y"', b'"abc"') + + +class TestIfModifiedSince: + def test_after(self): + # File modified at t=1000, header says "since 2000" → not modified. + assert _if_modified_since_satisfied(b"Thu, 01 Jan 1970 00:00:01 GMT", 0.0) + + def test_before(self): + # File modified now, header says "since the past" → modified. + past = formatdate(time.time() - 3600, usegmt=True).encode("ascii") + assert not _if_modified_since_satisfied(past, time.time()) + + def test_invalid(self): + assert not _if_modified_since_satisfied(b"not a date", 100.0) + + +class TestParseRange: + def test_full_range(self): + assert _parse_range(b"bytes=0-99", 1000) == (0, 100) + + def test_mid_range(self): + assert _parse_range(b"bytes=100-199", 1000) == (100, 100) + + def test_open_ended(self): + assert _parse_range(b"bytes=500-", 1000) == (500, 500) + + def test_suffix(self): + assert _parse_range(b"bytes=-50", 1000) == (950, 50) + + def test_suffix_larger_than_size(self): + # Per RFC 7233 §2.1, clamped to full content. + assert _parse_range(b"bytes=-5000", 1000) == (0, 1000) + + def test_end_clamped(self): + # End past EOF clamps to size-1. + assert _parse_range(b"bytes=100-9999", 1000) == (100, 900) + + def test_start_past_eof_unsatisfiable(self): + assert _parse_range(b"bytes=2000-2999", 1000) == "unsatisfiable" + + def test_zero_size_unsatisfiable(self): + assert _parse_range(b"bytes=0-99", 0) == "unsatisfiable" + + def test_multi_range_falls_back(self): + # Multi-range → 200 fallback (we don't speak multipart/byteranges). + assert _parse_range(b"bytes=0-99,200-299", 1000) is None + + def test_unparseable(self): + assert _parse_range(b"bytes=abc-def", 1000) is None + assert _parse_range(b"bytes=", 1000) is None + assert _parse_range(b"items=0-99", 1000) is None + assert _parse_range(b"bytes=10-5", 1000) is None # end < start + + def test_case_insensitive_unit(self): + assert _parse_range(b"BYTES=0-9", 100) == (0, 10) + + +# --- Integration --------------------------------------------------------- + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + """A populated directory served by all integration tests.""" + (tmp_path / "hello.txt").write_bytes(b"Hello, world!\n") + (tmp_path / "page.html").write_bytes(b"") + (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + (tmp_path / "weird.zzzz").write_bytes(b"unknown extension") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "index.html").write_bytes(b"sub-index") + return tmp_path + + +class TestStaticBasic: + def test_get_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + assert r.headers["content-type"] == "text/plain" + assert r.headers["content-length"] == "14" + assert r.headers["accept-ranges"] == "bytes" + assert r.headers["etag"].startswith('"') + assert "last-modified" in r.headers + + def test_404_missing(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/nope", timeout=5) + assert r.status_code == 404 + + def test_404_directory_traversal(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + # httpx normalises paths; use raw URL bytes via build_request. + req = httpx.Request("GET", f"http://127.0.0.1:{port}/../etc/passwd") + with httpx.Client(timeout=5) as client: + r = client.send(req) + # Whatever path httpx ends up sending, must not leak content outside root. + assert r.status_code in (400, 404) + + def test_405_wrong_method(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.put(f"http://127.0.0.1:{port}/hello.txt", content=b"x", timeout=5) + assert r.status_code == 405 + assert r.headers["allow"] == "GET, HEAD" + + def test_prefix_stripped(self, root: Path, serve_backend_in_thread): + h = static_handler(root, prefix=b"/static/") + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/static/hello.txt", timeout=5) + r_miss = httpx.get(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + assert r_miss.status_code == 404 + + def test_index_directory(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/sub/", timeout=5) + assert r.status_code == 200 + assert r.content == b"sub-index" + + def test_index_disabled(self, root: Path, serve_backend_in_thread): + h = static_handler(root, index=None) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/sub/", timeout=5) + assert r.status_code == 404 + + +class TestStaticHead: + def test_head_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.head(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.status_code == 200 + assert r.content == b"" + assert r.headers["content-length"] == "14" + assert r.headers["etag"].startswith('"') + + def test_head_404(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.head(f"http://127.0.0.1:{port}/nope", timeout=5) + assert r.status_code == 404 + + +class TestStaticConditionals: + def test_if_none_match_hit_returns_304(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + base = f"http://127.0.0.1:{port}/hello.txt" + r1 = httpx.get(base, timeout=5) + assert r1.status_code == 200 + etag = r1.headers["etag"] + r2 = httpx.get(base, headers={"if-none-match": etag}, timeout=5) + assert r2.status_code == 304 + assert r2.content == b"" + assert r2.headers["etag"] == etag + + def test_if_none_match_miss_returns_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"if-none-match": '"deadbeef"'}, + timeout=5, + ) + assert r.status_code == 200 + + def test_if_modified_since_after_mtime(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + # Set mtime to a known past second so the comparison is deterministic. + target = root / "hello.txt" + os.utime(target, (1_000_000, 1_000_000)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"if-modified-since": formatdate(2_000_000, usegmt=True)}, + timeout=5, + ) + assert r.status_code == 304 + + def test_if_modified_since_before_mtime(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + target = root / "hello.txt" + os.utime(target, (2_000_000, 2_000_000)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"if-modified-since": formatdate(1_000_000, usegmt=True)}, + timeout=5, + ) + assert r.status_code == 200 + + +class TestStaticRange: + def test_prefix_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=0-4"}, + timeout=5, + ) + assert r.status_code == 206 + assert r.content == b"Hello" + assert r.headers["content-length"] == "5" + assert r.headers["content-range"] == "bytes 0-4/14" + + def test_mid_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=7-11"}, + timeout=5, + ) + assert r.status_code == 206 + assert r.content == b"world" + assert r.headers["content-range"] == "bytes 7-11/14" + + def test_suffix_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=-7"}, + timeout=5, + ) + assert r.status_code == 206 + assert r.content == b"world!\n" + + def test_unsatisfiable_range(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=9999-"}, + timeout=5, + ) + assert r.status_code == 416 + assert r.headers["content-range"] == "bytes */14" + + def test_invalid_range_falls_back_to_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=abc-def"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + + def test_multi_range_falls_back_to_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/hello.txt", + headers={"range": "bytes=0-4,7-11"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.content == b"Hello, world!\n" + + +class TestStaticContentType: + def test_html(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/page.html", timeout=5) + assert r.headers["content-type"] == "text/html" + + def test_png(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/image.png", timeout=5) + assert r.headers["content-type"] == "image/png" + + def test_unknown_extension_octet_stream(self, root: Path, serve_backend_in_thread): + h = static_handler(root) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/weird.zzzz", timeout=5) + assert r.headers["content-type"] == "application/octet-stream" + + +class TestStaticCacheControl: + def test_passthrough_on_200(self, root: Path, serve_backend_in_thread): + h = static_handler(root, cache_control="public, max-age=3600, immutable") + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/hello.txt", timeout=5) + assert r.headers["cache-control"] == "public, max-age=3600, immutable" + + def test_passthrough_on_304(self, root: Path, serve_backend_in_thread): + h = static_handler(root, cache_control="public, max-age=3600") + with serve_backend_in_thread(h) as port: + base = f"http://127.0.0.1:{port}/hello.txt" + etag = httpx.get(base, timeout=5).headers["etag"] + r = httpx.get(base, headers={"if-none-match": etag}, timeout=5) + assert r.status_code == 304 + assert r.headers["cache-control"] == "public, max-age=3600" + + +class TestStaticLargeFile: + def test_round_trip_1mb(self, tmp_path: Path, serve_backend_in_thread): + # ≥ 1 MB so socket.sendfile makes multiple os.sendfile iterations. + payload = os.urandom(1 << 20) + (tmp_path / "blob.bin").write_bytes(payload) + h = static_handler(tmp_path) + with serve_backend_in_thread(h) as port: + r = httpx.get(f"http://127.0.0.1:{port}/blob.bin", timeout=15) + assert r.status_code == 200 + assert int(r.headers["content-length"]) == len(payload) + assert r.content == payload + + def test_range_within_large_file(self, tmp_path: Path, serve_backend_in_thread): + payload = os.urandom(1 << 20) + (tmp_path / "blob.bin").write_bytes(payload) + h = static_handler(tmp_path) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/blob.bin", + headers={"range": "bytes=100-199"}, + timeout=10, + ) + assert r.status_code == 206 + assert r.content == payload[100:200] From b066221e6464a0f98af1daa0f614479c50f70865 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 18:06:48 +0400 Subject: [PATCH 176/286] =?UTF-8?q?feat(http):=20compress=5Fhandler=20?= =?UTF-8?q?=E2=80=94=20gzip/brotli=20middleware=20for=20dynamic=20response?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps a RequestHandler so eligible ctx.complete(response, body) responses are compressed. Skips: HEAD, sub-min_size, 1xx/204/304/206, existing Content-Encoding, Cache-Control: no-transform, and types outside an allowlist (text/*, JSON, XML, JS, SVG, YAML, …). Streaming response paths (start_response/send/finish_response) and ctx.sendfile pass through uncompressed — composition with static_handler stays zero-copy. Streaming compression and zstd are followups, captured in plans/compression-middleware.md. gzip is stdlib; brotli ships as the optional [http-compress] extra. Mismatched extras fail fast at compress_handler() construction with a clear ImportError, not mid-request. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/compressed_api.py | 83 ++++++ localpost/http/README.md | 65 +++++ localpost/http/__init__.py | 4 + localpost/http/compress.py | 417 +++++++++++++++++++++++++++++++ plans/compression-middleware.md | 195 +++++++++++++++ pyproject.toml | 4 + tests/http/compress.py | 429 ++++++++++++++++++++++++++++++++ uv.lock | 44 +++- 8 files changed, 1240 insertions(+), 1 deletion(-) create mode 100644 examples/http/compressed_api.py create mode 100644 localpost/http/compress.py create mode 100644 plans/compression-middleware.md create mode 100644 tests/http/compress.py diff --git a/examples/http/compressed_api.py b/examples/http/compressed_api.py new file mode 100644 index 0000000..bbeb335 --- /dev/null +++ b/examples/http/compressed_api.py @@ -0,0 +1,83 @@ +"""JSON API with response compression. + +Run:: + + uv run examples/http/compressed_api.py + + curl -H 'Accept-Encoding: gzip' --compressed http://localhost:8000/items + curl -H 'Accept-Encoding: br' --compressed http://localhost:8000/items + curl -i http://localhost:8000/items # uncompressed (no Accept-Encoding) + +Brotli requires:: + + pip install localpost[http-compress] + +Compression only intercepts ``ctx.complete(...)`` responses; streaming +paths and ``sendfile`` pass through unchanged. Behind a CDN you usually +don't need this — the CDN compresses at the edge from an uncompressed +origin. +""" + +from __future__ import annotations + +import json +import logging +import sys + +from localpost.hosting import run_app, service +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + Response, + Routes, + ServerConfig, + compress_handler, + http_server, + thread_pool_handler, +) + + +def _items(ctx: HTTPReqCtx) -> BodyHandler | None: + # Big-ish JSON so the response crosses the default ``min_size=1024``. + payload = {"items": [{"id": i, "name": f"item-{i}", "tag": "x" * 32} for i in range(64)]} + body = json.dumps(payload).encode("utf-8") + ctx.complete( + Response( + status_code=200, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + ), + body if ctx.request.method != b"HEAD" else None, + ) + return None + + +def build_router(): + routes = Routes() + routes.get("/items")(_items) + return routes.build() + + +@service +async def app(): + # Brotli first if available, else gzip. + try: + h = compress_handler(build_router().as_handler(), algorithms=("br", "gzip")) + except ImportError: + h = compress_handler(build_router().as_handler(), algorithms=("gzip",)) + + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(h, max_concurrency=8) as wrapped: + async with http_server(config, wrapped): + yield + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/localpost/http/README.md b/localpost/http/README.md index d82fd1e..176978f 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -296,6 +296,71 @@ can be used directly for any zero-copy body. Requires backends keep their parser state consistent with what the kernel writes out-of-band. +### `localpost.http.compress` + +Response compression middleware for the **dynamic** path — +`gzip` (stdlib, always available) + `br` (optional via `[http-compress]`). +Pair with a JSON / HTML / XML API; **not** intended for static files +(compression and zero-copy `sendfile` are at odds — see +`plans/compression-middleware.md`). + +Behind a CDN you usually don't need this: the CDN compresses at the +edge from an uncompressed origin. `compress_handler` is for deployments +*not* behind a CDN, or when the CDN doesn't compress (rare). + +| Symbol | Notes | +| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `compress_handler(inner, *, algorithms=("br","gzip"), min_size=1024, compressible_types=...)` | Wrap a `RequestHandler` so eligible `complete()` responses are compressed. | +| `DEFAULT_COMPRESSIBLE_TYPES` | Frozenset allowlist (text/*, JSON, XML, JS, SVG, YAML, …) used by default. | + +#### Decision matrix + +The middleware skips compression when any of these hold (response sent +verbatim): + +- `Accept-Encoding` doesn't list any configured `algorithms` with q>0 +- Method is `HEAD` (no body to compress) +- `body is None` or `len(body) < min_size` +- Status is `1xx` / `204` / `304` (no body) or `206` (range — compressing breaks byte semantics) +- Response already has `Content-Encoding` (other than `identity`) +- Response has `Cache-Control: no-transform` (RFC 9111) +- `Content-Type` main-type is not in `compressible_types` + +When eligible: body is compressed, `Content-Length` is replaced, +`Content-Encoding` is added, and `Accept-Encoding` is merged into +`Vary` (existing `Vary: Cookie` becomes `Vary: Cookie, Accept-Encoding`; +`Vary: *` is left alone). + +#### v1 limitations + +- **Only `complete()` is intercepted.** Streaming responses + (`start_response` / `send` / `finish_response`) and `sendfile` pass + through uncompressed. SSE compression in particular needs per-chunk + flushing and is a separate piece of work. +- **One-shot allocation.** A compressed buffer is allocated per + eligible response. Fine for typical JSON sizes; for multi-MB dynamic + payloads you want streaming compression instead (follow-up). +- **Brotli is opt-in.** `pip install localpost[http-compress]` + installs `brotli`. If `"br"` is in `algorithms` without the extra, + `compress_handler` raises `ImportError` at construction time. + +#### Composition with the static handler + +`compress_handler` and `static_handler` compose cleanly — the +compression middleware passes `sendfile` through, so static stays +zero-copy: + +```python +api = thread_pool_handler( + compress_handler(routes.build().as_handler(), algorithms=("br", "gzip")), + max_concurrency=8, +) +static = thread_pool_handler(static_handler("/var/www", prefix=b"/static/"), + max_concurrency=128, backlog=64) +``` + +See [`examples/http/compressed_api.py`](../../examples/http/compressed_api.py). + ### `localpost.http.flask` Native Flask adapter — optional extra `[http-flask]`. Bypasses WSGI on both diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index ea012d3..58ecb6e 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -16,6 +16,7 @@ from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, wsgi_server from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.compress import DEFAULT_COMPRESSIBLE_TYPES, compress_handler from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( Route, @@ -67,6 +68,9 @@ "streaming_pool_handler", # static files "static_handler", + # compression + "compress_handler", + "DEFAULT_COMPRESSIBLE_TYPES", # cancellation "check_cancelled", "RequestCancelled", diff --git a/localpost/http/compress.py b/localpost/http/compress.py new file mode 100644 index 0000000..51d2871 --- /dev/null +++ b/localpost/http/compress.py @@ -0,0 +1,417 @@ +"""Response compression middleware (dynamic responses only). + +Wraps a :data:`RequestHandler` so that responses emitted via +:meth:`HTTPReqCtx.complete` are compressed when the client accepts it, +the response is large enough to be worth compressing, and the +content type is in the allowlist. + +Streaming response paths (``start_response`` / ``send`` / +``finish_response``) and zero-copy ``sendfile`` pass through +**uncompressed** in v1 — composition with the static handler stays +zero-copy. See ``plans/compression-middleware.md`` for the rationale +and follow-ups. + +Encodings: + +- ``gzip`` — stdlib, always available +- ``br`` — optional via the ``[http-compress]`` extra (``brotli``) + +If ``"br"`` appears in ``algorithms`` but :mod:`brotli` isn't +installed, :func:`compress_handler` raises :class:`ImportError` at +construction time — fail fast rather than mid-request. +""" + +from __future__ import annotations + +import functools +import gzip +import importlib +import importlib.util +from collections.abc import Callable, Sequence +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, BinaryIO, Final + +from localpost.http._base import BaseHTTPConn, BodyHandler, HTTPReqCtx, RequestHandler, Selector +from localpost.http._types import InformationalResponse, Request, Response + +if TYPE_CHECKING: + from collections.abc import Buffer + +__all__ = [ + "DEFAULT_COMPRESSIBLE_TYPES", + "compress_handler", +] + + +DEFAULT_COMPRESSIBLE_TYPES: Final[frozenset[bytes]] = frozenset({ + b"text/html", + b"text/plain", + b"text/css", + b"text/xml", + b"text/csv", + b"text/javascript", + b"application/json", + b"application/javascript", + b"application/xml", + b"application/xhtml+xml", + b"application/manifest+json", + b"application/x-yaml", + b"application/rss+xml", + b"application/atom+xml", + b"image/svg+xml", +}) + + +# -------------------------------------------------------------------------- +# Encoder registry +# -------------------------------------------------------------------------- + + +def _compress_gzip(data: bytes) -> bytes: + return gzip.compress(data, compresslevel=6) + + +# Imported lazily — the dependency is in the optional ``[http-compress]`` +# extra. ``importlib`` keeps the static import out of ty/pyright's sight, +# so missing ``brotli`` doesn't fail type-checking. +@functools.cache +def _brotli() -> Any: + return importlib.import_module("brotli") + + +def _compress_brotli(data: bytes) -> bytes: + # quality=4 favours speed over ratio (brotli default 11 is very slow). + return _brotli().compress(data, quality=4) + + +_ENCODERS: dict[str, Callable[[bytes], bytes]] = { + "gzip": _compress_gzip, + "br": _compress_brotli, +} + + +def _check_algorithm_available(name: str) -> None: + if name == "br": + if importlib.util.find_spec("brotli") is None: + raise ImportError( + "compress_handler(algorithms=...) includes 'br' but the optional " + "[http-compress] extra (brotli) is not installed." + ) + elif name == "gzip": + return # stdlib + else: + raise ValueError(f"Unsupported compression algorithm: {name!r}") + + +# -------------------------------------------------------------------------- +# Negotiation +# -------------------------------------------------------------------------- + + +def _negotiate(accept_encoding: bytes, server_pref: Sequence[str]) -> str | None: + """Pick the best mutually-supported encoding. + + Parses ``Accept-Encoding`` per RFC 7231 §5.3.4: comma-separated + tokens, optional ``;q=``, default ``q=1.0``. ``q=0`` disables. + ``*`` is a wildcard. Returns the highest-server-preference encoding + with q>0, or ``None`` if nothing matches. + """ + qs: dict[str, float] = {} + star_q: float | None = None + for raw in accept_encoding.split(b","): + token = raw.strip() + if not token: + continue + name_b, sep, params = token.partition(b";") + name = name_b.strip().decode("ascii", errors="replace").lower() + q = 1.0 + if sep: + for param in params.split(b";"): + k, kv_sep, v = param.strip().partition(b"=") + if kv_sep and k.strip().lower() == b"q": + try: + q = float(v.strip()) + except ValueError: + q = 0.0 + break + if name == "*": + star_q = q + else: + qs[name] = q + for pref in server_pref: + q = qs.get(pref) + if q is None and star_q is not None: + q = star_q + if q is not None and q > 0: + return pref + return None + + +# -------------------------------------------------------------------------- +# Header inspection / rewrite +# -------------------------------------------------------------------------- + + +def _get_header(headers: Sequence[tuple[bytes, bytes]], name: bytes) -> bytes | None: + name_lc = name.lower() + for n, v in headers: + if n.lower() == name_lc: + return v + return None + + +def _is_compressible_response( + response: Response, + body: bytes | None, + *, + method: bytes, + min_size: int, + compressible_types: frozenset[bytes], +) -> bool: + """Apply the v1 decision matrix from the design doc.""" + if method == b"HEAD": + return False + if body is None or len(body) < min_size: + return False + status = response.status_code + if status in {204, 304} or 100 <= status < 200 or status == 206: + return False + # Header-driven exclusions. + has_compressible_type = False + for name, value in response.headers: + n = name.lower() + if n == b"content-encoding": + v = value.strip().lower() + if v and v != b"identity": + return False + elif n == b"cache-control": + # Conservative substring check — ``no-transform`` is a directive + # token, not a value, so substring matches without false positives. + if b"no-transform" in value.lower(): + return False + elif n == b"content-type" and not has_compressible_type: + main_type = value.split(b";", 1)[0].strip().lower() + if main_type in compressible_types: + has_compressible_type = True + return has_compressible_type + + +def _rewrite_headers( + headers: Sequence[tuple[bytes, bytes]], + *, + encoding: str, + new_length: int, +) -> list[tuple[bytes, bytes]]: + """Replace ``Content-Length``, add ``Content-Encoding``, merge + ``Vary: Accept-Encoding``. + """ + enc_value = encoding.encode("ascii") + out: list[tuple[bytes, bytes]] = [] + vary_seen = False + cl_replaced = False + for name, value in headers: + n = name.lower() + if n == b"content-length": + if not cl_replaced: + out.append((name, str(new_length).encode("ascii"))) + cl_replaced = True + # Drop duplicate Content-Length headers if any. + elif n == b"vary": + vary_seen = True + out.append((name, _merge_vary(value))) + else: + out.append((name, value)) + if not cl_replaced: + out.append((b"content-length", str(new_length).encode("ascii"))) + if not vary_seen: + out.append((b"vary", b"Accept-Encoding")) + out.append((b"content-encoding", enc_value)) + return out + + +def _merge_vary(existing: bytes) -> bytes: + """Merge ``Accept-Encoding`` into an existing ``Vary`` header value.""" + stripped = existing.strip() + if stripped == b"*": + return existing + tokens = [t.strip() for t in stripped.split(b",") if t.strip()] + for t in tokens: + if t.lower() == b"accept-encoding": + return existing + tokens.append(b"Accept-Encoding") + return b", ".join(tokens) + + +# -------------------------------------------------------------------------- +# Proxy ctx +# -------------------------------------------------------------------------- + + +@dataclass(slots=True, eq=False) +class _CompressedCtx: + """Forwarding proxy that intercepts :meth:`complete` to compress. + + All other methods / attributes pass through to ``_inner``. The + proxy is allocated once per request — only when the request is + actually eligible for compression — so the overhead is bounded. + """ + + _inner: HTTPReqCtx + _encoding: str + _min_size: int + _compressible_types: frozenset[bytes] + + # ----- forwarded attributes ----- + + @property + def request(self) -> Request: + return self._inner.request + + @property + def body(self) -> bytes: + return self._inner.body + + @property + def attrs(self) -> dict[Any, Any]: + return self._inner.attrs + + @property + def response_status(self) -> int | None: + return self._inner.response_status + + @property + def selector(self) -> Selector: + return self._inner.selector + + @property + def conn(self) -> BaseHTTPConn: + return self._inner.conn + + @property + def borrowed(self) -> bool: + return self._inner.borrowed + + def borrow(self) -> AbstractContextManager[HTTPReqCtx]: + return self._inner.borrow() + + def receive(self, size: int = 65536, /) -> bytes: + return self._inner.receive(size) + + # ----- forwarded streaming-response API (uncompressed in v1) ----- + + def start_response(self, response: Response | InformationalResponse, /) -> None: + self._inner.start_response(response) + + def send(self, chunk: Buffer, /) -> None: + self._inner.send(chunk) + + def finish_response(self) -> None: + self._inner.finish_response() + + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + self._inner.sendfile(response, file, offset, count) + + # ----- intercepted ----- + + def complete(self, response: Response, body: bytes | None = None) -> None: + if not _is_compressible_response( + response, + body, + method=self._inner.request.method, + min_size=self._min_size, + compressible_types=self._compressible_types, + ): + self._inner.complete(response, body) + return + assert body is not None # _is_compressible_response gates on body is not None + compressed = _ENCODERS[self._encoding](body) + new_headers = _rewrite_headers( + response.headers, + encoding=self._encoding, + new_length=len(compressed), + ) + self._inner.complete( + Response(status_code=response.status_code, headers=new_headers, reason=response.reason), + compressed, + ) + + +# -------------------------------------------------------------------------- +# Public entry +# -------------------------------------------------------------------------- + + +def compress_handler( + inner: RequestHandler, + *, + algorithms: Sequence[str] = ("br", "gzip"), + min_size: int = 1024, + compressible_types: frozenset[bytes] = DEFAULT_COMPRESSIBLE_TYPES, +) -> RequestHandler: + """Wrap ``inner`` so eligible responses are compressed. + + Args: + inner: The wrapped :data:`RequestHandler`. + algorithms: Server preference order. Each entry must be one of + ``"gzip"`` (stdlib) or ``"br"`` (requires the + ``[http-compress]`` extra). The first algorithm that the + client accepts (per ``Accept-Encoding`` q-values) is chosen. + min_size: Minimum response body size to trigger compression. Below + this, the framing / re-allocation overhead dominates the + compression gain. Default ``1024``. + compressible_types: Allowlist of lowercased ``Content-Type`` + main-types (split on ``;``). See + :data:`DEFAULT_COMPRESSIBLE_TYPES` for the default. + + Raises: + ValueError: ``algorithms`` is empty or contains an unknown name. + ImportError: ``"br"`` is requested but :mod:`brotli` is not + installed (install via ``pip install localpost[http-compress]``). + + Returns: + A :data:`RequestHandler` that wraps ``inner``. Compression only + intercepts :meth:`HTTPReqCtx.complete`; streaming responses + (``start_response`` / ``send`` / ``finish_response``) and + :meth:`HTTPReqCtx.sendfile` pass through unchanged. + """ + if not algorithms: + raise ValueError("compress_handler: ``algorithms`` must be non-empty") + for name in algorithms: + _check_algorithm_available(name) + if min_size < 0: + raise ValueError("compress_handler: ``min_size`` must be >= 0") + + server_pref = tuple(algorithms) + + def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + accept = _get_header(ctx.request.headers, b"accept-encoding") + if accept is None or ctx.request.method == b"HEAD": + return inner(ctx) + chosen = _negotiate(accept, server_pref) + if chosen is None: + return inner(ctx) + proxy = _CompressedCtx( + _inner=ctx, + _encoding=chosen, + _min_size=min_size, + _compressible_types=compressible_types, + ) + result = inner(proxy) # type: ignore[arg-type] # _CompressedCtx structurally satisfies HTTPReqCtx + if result is None: + return None + # If the inner returned a BodyHandler continuation, route it + # through the same proxy so its ``complete`` call lands here too. + body_handler: BodyHandler = result + + def proxied_body(real_ctx: HTTPReqCtx) -> None: + body_handler(_CompressedCtx( # type: ignore[arg-type] + _inner=real_ctx, + _encoding=chosen, + _min_size=min_size, + _compressible_types=compressible_types, + )) + + return proxied_body + + return wrapped diff --git a/plans/compression-middleware.md b/plans/compression-middleware.md new file mode 100644 index 0000000..e709c77 --- /dev/null +++ b/plans/compression-middleware.md @@ -0,0 +1,195 @@ +# Compression middleware (dynamic responses) + +## Context + +We added `static_handler` (zero-copy `socket.sendfile()`) in +`plans/static-files.md`. The follow-up question was whether one +middleware could compress *both* static and dynamic responses. +Answer: not without giving up sendfile — compression and zero-copy +are at odds. Cleanest split: + +- **Dynamic path (this plan):** a `compress_handler` middleware that + intercepts `complete(response, body)`, compresses the body, and + rewrites headers. +- **Static path (future, not this plan):** opt-in `precompressed=` + on `static_handler` that picks `foo.css.br` / `foo.css.zst` off + disk and `sendfile` s it. Build- or deploy-time compression, no + per-request CPU. Re-uses the same negotiation helper. + +## Design + +### Public API + +```python +def compress_handler( + inner: RequestHandler, + *, + algorithms: Sequence[str] = ("br", "gzip"), # server preference order + min_size: int = 1024, + compressible_types: frozenset[bytes] = DEFAULT_COMPRESSIBLE_TYPES, +) -> RequestHandler: ... +``` + +Standard `Middleware` shape (`Callable[[RequestHandler], RequestHandler]`). +Defaults: + +- `algorithms = ("br", "gzip")` — brotli first (better ratio for text); + gzip as universal fallback. zstd added later when `compression.zstd` + (Python 3.14 stdlib) is the floor. +- `min_size = 1024` — below this, framing overhead dominates compression + savings. +- `compressible_types` — allowlist of exact lowercased main-types + (text/*, JSON, XML, JS, SVG, YAML, manifest+json…). + +`brotli` is optional via the new `[http-compress]` extra. If `"br"` is +in `algorithms` but the dependency is missing, `compress_handler` +raises `ImportError` at construction time (fail fast — don't surprise +the user mid-request). + +### Mechanism — wrap the ctx + +The middleware can't observe `ctx.complete(...)` from outside the +handler call, so it passes a **proxy ctx** to the inner handler: + +``` +inner(proxy_ctx) + ↓ +proxy_ctx.complete(response, body) + ↓ +[decision + body compress + header rewrite] + ↓ +real_ctx.complete(rewritten_response, compressed_body) +``` + +`_CompressedCtx` forwards every attribute / method (`request`, `body`, +`attrs`, `selector`, `conn`, `borrowed`, `borrow`, `receive`, +`response_status`, `start_response`, `send`, `finish_response`, +`sendfile`) verbatim, and intercepts only `complete()`. + +`start_response` / `send` / `finish_response` / `sendfile` pass through +**uncompressed** in v1. Streaming compression (chunked-encoded SSE, +generator responses) is a separate piece of work; sendfile compression +is intentionally out of scope (zero-copy rationale). + +### Decision logic — per-request, ordered + +The middleware skips compression when any of these hold: + +| Condition | Why | +|-----------|-----| +| `Accept-Encoding` absent or no `algorithms` entry has q>0 | Client doesn't want it | +| `method == b"HEAD"` | No body to compress; faking one to compute length is silly | +| `body is None` or `len(body) < min_size` | Below threshold; framing dominates | +| Status in `{204, 304}` or `1xx` | No body | +| Status `206` | Range — compressing breaks byte-range semantics | +| Existing `Content-Encoding` (not `identity` or empty) | Don't double-compress | +| `Cache-Control: no-transform` | Honor the directive (RFC 9111 §5.2.1.6) | +| `Content-Type` main-type not in `compressible_types` | Allowlist miss | + +When eligible: compress, then **rewrite headers** — + +- replace `Content-Length` with the compressed length +- add `Content-Encoding: ` +- merge `Accept-Encoding` into `Vary` (existing `Vary: Cookie` → + `Vary: Cookie, Accept-Encoding`; existing `Vary: *` left alone) + +### Negotiation helper + +```python +def _negotiate(accept_encoding: bytes, server_pref: Sequence[str]) -> str | None: ... +``` + +Parses comma-separated tokens with optional `;q=`, default q=1.0. +`q=0` disables, `*` is a wildcard, `identity` is "no compression". +Returns the highest-server-preference encoding with q>0, or `None`. + +This same helper will back precompressed-sidecar negotiation in +`static_handler` later — the negotiation logic is identical, only the +"deliver the bytes" step differs. + +### Default compressible types + +```python +DEFAULT_COMPRESSIBLE_TYPES = frozenset({ + b"text/html", b"text/plain", b"text/css", b"text/xml", b"text/csv", + b"text/javascript", + b"application/json", b"application/javascript", b"application/xml", + b"application/xhtml+xml", b"application/manifest+json", + b"application/x-yaml", b"application/rss+xml", b"application/atom+xml", + b"image/svg+xml", +}) +``` + +Exact main-type match: split `Content-Type` on `;`, lowercase, lookup. +Users override by passing their own `frozenset` to `compress_handler`. + +## Plan + +### Step 1 — `compress.py` + +1. New module `localpost/http/compress.py` with: + - `compress_handler(inner, *, algorithms, min_size, compressible_types)` — public. + - `_CompressedCtx` — proxy. Forwards all attrs/methods to + `_inner`; overrides `complete`. + - `_negotiate`, `_rewrite_headers`, `_compress(encoding, body)`, + `_should_compress(method, response, body, min_size, + compressible_types)`. + - `DEFAULT_COMPRESSIBLE_TYPES`. +2. Re-export `compress_handler` and `DEFAULT_COMPRESSIBLE_TYPES` from + `localpost/http/__init__.py`. +3. Add `[http-compress]` extra to `pyproject.toml`: + + ```toml + http-compress = [ + "brotli ~=1.1", + ] + ``` + +4. Run `just check localpost/http/compress.py`. + +### Step 2 — Tests + +`tests/http/compress.py`: +- Negotiation: empty / single / multiple / q-values / wildcard / + identity-only / unsupported. +- Allowlist hit/miss (HTML, JSON, image/png, application/octet-stream, + user-supplied set). +- Skip conditions: HEAD, 204/304/206, `Cache-Control: no-transform`, + existing `Content-Encoding`, body < min_size, body is None. +- Header rewrite: `Content-Length` replaced; `Content-Encoding` added; + `Vary` merged (empty / single / `*` cases). +- Round-trip via httpx (auto-decodes `Content-Encoding`) — confirms + wire format works for gzip; brotli skipped if dep missing. +- Pass-through: `ctx.sendfile(...)` from a static handler stays + uncompressed when wrapped. +- Pass-through: streaming handler (`start_response` + `send` + + `finish_response`) stays uncompressed. + +Run via `just unit-tests`. + +### Step 3 — Docs + example + +- New `localpost.http.compress` section in + `localpost/http/README.md` — placement after `localpost.http.static`. + Cover the API, decision matrix, and the `Vary` semantics. Note the + v1 limitation (only `complete()` intercepted). +- New `examples/http/compressed_api.py` — wraps a small JSON router + with `compress_handler`. + +## Followups (separate PRs) + +- **Streaming compression** — wrap `start_response` / `send` / + `finish_response`. For chunked responses, switch to + `Transfer-Encoding: chunked` (httptools backend already auto-frames + chunked when `Content-Length` is absent). For SSE, per-chunk flush + is required so events aren't buffered indefinitely. +- **zstd** — add when `compression.zstd` (Python 3.14 stdlib) is the + supported floor, or as a third-party-backed extra + (`zstandard`) sooner. Same negotiation logic; one more entry in the + preference order. +- **Per-algorithm levels** — `levels: Mapping[str, int]` parameter. + Defaults today are gzip `compresslevel=6`, brotli `quality=4` (favor + speed). Adding the parameter is small but adds a knob. +- **Precompressed sidecars in `static_handler`** — the natural reuse + of `_negotiate`. When we ship that, the negotiation helper moves to + a shared location (`localpost/http/_negotiate.py` or similar). diff --git a/pyproject.toml b/pyproject.toml index 7ebf8bc..7a1d4c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,10 @@ http-fast = [ # NOTE! Under free-threaded CPython, 0.7.1 and below auto-re-enables the GIL on import. "httptools @ git+https://github.com/MagicStack/httptools.git", ] +http-compress = [ + # Brotli compression for compress_handler. Gzip is stdlib and always available. + "brotli ~=1.1", +] openapi = [ # "localpost[http]", "msgspec ~=0.19", diff --git a/tests/http/compress.py b/tests/http/compress.py new file mode 100644 index 0000000..4e144d6 --- /dev/null +++ b/tests/http/compress.py @@ -0,0 +1,429 @@ +"""Tests for localpost.http.compress — gzip/brotli response middleware.""" + +from __future__ import annotations + +import gzip +import importlib.util +from pathlib import Path + +import httpx +import pytest + +from localpost.http import ( + BodyHandler, + HTTPReqCtx, + Response, + compress_handler, + static_handler, +) +from localpost.http.compress import ( + DEFAULT_COMPRESSIBLE_TYPES, + _is_compressible_response, + _merge_vary, + _negotiate, + _rewrite_headers, +) + +_HAS_BROTLI = importlib.util.find_spec("brotli") is not None + + +# --- Negotiation --------------------------------------------------------- + + +class TestNegotiate: + def test_empty_returns_none(self): + assert _negotiate(b"", ("br", "gzip")) is None + + def test_simple(self): + assert _negotiate(b"gzip", ("br", "gzip")) == "gzip" + + def test_preference_order(self): + # Server prefers br first; client lists both → pick br. + assert _negotiate(b"gzip, br", ("br", "gzip")) == "br" + + def test_unsupported_returns_none(self): + assert _negotiate(b"deflate", ("br", "gzip")) is None + + def test_q_zero_disables(self): + assert _negotiate(b"gzip;q=0, br", ("gzip", "br")) == "br" + + def test_only_q_zero_returns_none(self): + assert _negotiate(b"gzip;q=0", ("gzip",)) is None + + def test_wildcard(self): + assert _negotiate(b"*", ("br", "gzip")) == "br" + + def test_wildcard_with_explicit_disable(self): + # ``*;q=1, gzip;q=0`` → gzip explicitly disabled, br matches via wildcard. + assert _negotiate(b"gzip;q=0, *;q=1", ("gzip", "br")) == "br" + + def test_q_value_parsing_invalid_treated_as_zero(self): + assert _negotiate(b"gzip;q=abc", ("gzip",)) is None + + def test_whitespace_tolerant(self): + assert _negotiate(b" gzip ;q= 1.0 , br ", ("br", "gzip")) == "br" + + +# --- _is_compressible_response ------------------------------------------ + + +def _r(status: int = 200, headers=None) -> Response: + return Response(status_code=status, headers=list(headers or [])) + + +class TestIsCompressible: + def test_basic_yes(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_method_head(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"HEAD", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_body_none(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, None, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_below_min_size(self): + r = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, b"x" * 100, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + @pytest.mark.parametrize("status", [100, 101, 199, 204, 304, 206]) + def test_no_body_statuses(self, status): + r = _r(status, headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_existing_content_encoding(self): + r = _r(headers=[(b"content-type", b"application/json"), (b"content-encoding", b"gzip")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_identity_encoding_treated_as_compressible(self): + r = _r(headers=[(b"content-type", b"application/json"), (b"content-encoding", b"identity")]) + assert _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_no_transform_directive(self): + r = _r(headers=[(b"content-type", b"application/json"), (b"cache-control", b"public, no-transform")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_content_type_with_charset(self): + # ``application/json; charset=utf-8`` — main type splits cleanly. + r = _r(headers=[(b"content-type", b"application/json; charset=utf-8")]) + assert _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_non_compressible_type(self): + r = _r(headers=[(b"content-type", b"image/png")]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_no_content_type(self): + r = _r(headers=[]) + assert not _is_compressible_response( + r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=DEFAULT_COMPRESSIBLE_TYPES + ) + + def test_user_supplied_allowlist(self): + custom = frozenset({b"application/x-custom"}) + r = _r(headers=[(b"content-type", b"application/x-custom")]) + assert _is_compressible_response(r, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=custom) + r2 = _r(headers=[(b"content-type", b"application/json")]) + assert not _is_compressible_response(r2, b"x" * 2048, method=b"GET", min_size=1024, compressible_types=custom) + + +# --- Header rewrite ------------------------------------------------------ + + +class TestRewriteHeaders: + def test_replaces_content_length(self): + out = _rewrite_headers( + [(b"content-type", b"application/json"), (b"content-length", b"100")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"content-length"] == b"42" + + def test_adds_content_length_if_missing(self): + out = _rewrite_headers( + [(b"content-type", b"application/json")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"content-length"] == b"42" + + def test_adds_content_encoding(self): + out = _rewrite_headers( + [(b"content-type", b"application/json"), (b"content-length", b"100")], + encoding="br", + new_length=42, + ) + d = dict(out) + assert d[b"content-encoding"] == b"br" + + def test_vary_added_if_missing(self): + out = _rewrite_headers([(b"content-length", b"100")], encoding="gzip", new_length=42) + d = dict(out) + assert d[b"vary"] == b"Accept-Encoding" + + def test_vary_merged_with_existing(self): + out = _rewrite_headers( + [(b"content-length", b"100"), (b"vary", b"Cookie")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"vary"] == b"Cookie, Accept-Encoding" + + def test_vary_star_left_alone(self): + out = _rewrite_headers( + [(b"content-length", b"100"), (b"vary", b"*")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"vary"] == b"*" + + def test_vary_no_duplicate_when_already_listed(self): + out = _rewrite_headers( + [(b"content-length", b"100"), (b"vary", b"Accept-Encoding, Cookie")], + encoding="gzip", + new_length=42, + ) + d = dict(out) + assert d[b"vary"] == b"Accept-Encoding, Cookie" + + +class TestMergeVary: + def test_empty(self): + assert _merge_vary(b"") == b"Accept-Encoding" + + def test_single(self): + assert _merge_vary(b"Cookie") == b"Cookie, Accept-Encoding" + + def test_already_present(self): + assert _merge_vary(b"Accept-Encoding, Cookie") == b"Accept-Encoding, Cookie" + assert _merge_vary(b"accept-encoding") == b"accept-encoding" # case-insensitive + + def test_star(self): + assert _merge_vary(b"*") == b"*" + + +# --- Construction -------------------------------------------------------- + + +class TestConstruction: + def test_empty_algorithms_rejected(self): + with pytest.raises(ValueError, match="non-empty"): + compress_handler(lambda ctx: None, algorithms=()) + + def test_unknown_algorithm_rejected(self): + with pytest.raises(ValueError, match="Unsupported"): + compress_handler(lambda ctx: None, algorithms=("deflate",)) + + def test_negative_min_size_rejected(self): + with pytest.raises(ValueError, match="min_size"): + compress_handler(lambda ctx: None, algorithms=("gzip",), min_size=-1) + + +# --- Integration --------------------------------------------------------- + + +def _emit(ctx: HTTPReqCtx, content_type: bytes, body: bytes, **headers: bytes) -> None: + hs: list[tuple[bytes, bytes]] = [ + (b"content-type", content_type), + (b"content-length", str(len(body)).encode("ascii")), + ] + for k, v in headers.items(): + hs.append((k.replace("_", "-").encode("ascii"), v)) + # HEAD must not carry a body on the wire; headers describe what GET would send. + payload: bytes | None = None if ctx.request.method == b"HEAD" else body + ctx.complete(Response(status_code=200, headers=hs), payload) + + +def _make_handler(content_type: bytes, body: bytes): + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + _emit(ctx, content_type, body) + return None + + return handler + + +class TestRoundTripGzip: + def test_compresses_json(self, serve_backend_in_thread): + body = b'{"data": ' + b"x" * 4096 + b"}" + h = compress_handler(_make_handler(b"application/json", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + # httpx auto-decodes Content-Encoding by default. + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "gzip" + assert r.headers.get("vary", "").lower().find("accept-encoding") >= 0 + # Content-Length describes the compressed body. + assert int(r.headers["content-length"]) < len(body) + # httpx decoded it back to the original. + assert r.content == body + + def test_skips_when_client_doesnt_accept(self, serve_backend_in_thread): + # ``Accept-Encoding: identity`` is how a client opts out of compression + # explicitly. (httpx sends a default Accept-Encoding when none is set, + # so omitting the header doesn't actually mean "no Accept-Encoding".) + body = b'{"data": ' + b"x" * 4096 + b"}" + h = compress_handler(_make_handler(b"application/json", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "identity"}, + timeout=5, + ) + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == body + + def test_skips_below_min_size(self, serve_backend_in_thread): + body = b'{"x": 1}' # tiny + h = compress_handler(_make_handler(b"application/json", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert "content-encoding" not in r.headers + assert r.content == body + + def test_skips_non_compressible_type(self, serve_backend_in_thread): + body = b"\x89PNG\r\n\x1a\n" + b"\x00" * 4096 # pretend PNG payload + h = compress_handler(_make_handler(b"image/png", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert "content-encoding" not in r.headers + assert r.content == body + + def test_head_passes_through(self, serve_backend_in_thread): + body = b"x" * 4096 + h = compress_handler(_make_handler(b"text/plain", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.head( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + # No body for HEAD; we don't pretend-compress to compute length. + assert "content-encoding" not in r.headers + + +@pytest.mark.skipif(not _HAS_BROTLI, reason="brotli optional extra not installed") +class TestRoundTripBrotli: + def test_compresses_with_br(self, serve_backend_in_thread): + body = b'{"data": ' + b"x" * 4096 + b"}" + h = compress_handler(_make_handler(b"application/json", body), algorithms=("br", "gzip")) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "br, gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "br" + assert r.content == body # httpx decodes br since brotli is installed + + +# --- Pass-through composition -------------------------------------------- + + +class TestComposeWithStatic: + def test_sendfile_passes_through_uncompressed(self, tmp_path: Path, serve_backend_in_thread): + # The static handler uses ctx.sendfile — compress_handler must not + # interpose on that path. The downloaded bytes should equal the + # file bytes verbatim, with no Content-Encoding. + payload = b"" + b"x" * 4096 + b"" + (tmp_path / "page.html").write_bytes(payload) + static = static_handler(tmp_path) + h = compress_handler(static, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/page.html", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == payload + + def test_round_trip_when_pool_wraps_compress(self, serve_backend_in_thread): + # Sanity-check: the BodyHandler proxy path (inner returns a continuation) + # also routes complete() through the proxy. We exercise it via a handler + # that uses ctx.body — selector buffers the request body, then invokes + # the BodyHandler which calls ctx.complete. + body = b'{"data": ' + b"x" * 4096 + b"}" + + def inner(ctx: HTTPReqCtx) -> BodyHandler | None: + def cont(c: HTTPReqCtx) -> None: + _emit(c, b"application/json", body) + + return cont + + h = compress_handler(inner, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.post( + f"http://127.0.0.1:{port}/", + content=b"", + headers={"accept-encoding": "gzip", "content-length": "0"}, + timeout=5, + ) + assert r.headers["content-encoding"] == "gzip" + assert r.content == body + + +# --- Manual gzip verification -------------------------------------------- + + +class TestGzipBytes: + """Decode the response body manually to verify the bytes on the wire are + actually gzip-framed (httpx auto-decodes, so the round-trip tests above + can't tell). + """ + + def test_actual_gzip_bytes_on_wire(self, serve_backend_in_thread): + import socket # noqa: PLC0415 + + body = b"hello world " * 1024 + h = compress_handler(_make_handler(b"text/plain", body), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n") + buf = b"" + while True: + chunk = s.recv(8192) + if not chunk: + break + buf += chunk + head, _, raw_body = buf.partition(b"\r\n\r\n") + assert b"content-encoding: gzip" in head.lower() + assert gzip.decompress(raw_body) == body diff --git a/uv.lock b/uv.lock index 002ef5d..77d0bf8 100644 --- a/uv.lock +++ b/uv.lock @@ -74,6 +74,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -701,6 +739,9 @@ cron = [ http = [ { name = "h11" }, ] +http-compress = [ + { name = "brotli" }, +] http-fast = [ { name = "httptools" }, ] @@ -769,6 +810,7 @@ tests-unit = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = "~=4.12" }, + { name = "brotli", marker = "extra == 'http-compress'", specifier = "~=1.1" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, @@ -776,7 +818,7 @@ requires-dist = [ { name = "msgspec", marker = "extra == 'openapi'", specifier = "~=0.19" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http", "http-fast", "openapi"] +provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "openapi"] [package.metadata.requires-dev] bench = [ From 5b18e4e691abcb75913d97a5aaff59a8a1b6f858 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 18:58:46 +0400 Subject: [PATCH 177/286] feat(http): streaming compression for compress_handler (SSE etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same compress_handler call now also intercepts the streaming path (start_response → send → finish_response). Per-request the middleware picks one-shot or streaming based on whether Content-Length was set. Streaming uses an incremental compressor with a sync-flush after each send(chunk), so SSE events reach the client decompressed and parseable without waiting for the stream to end — same approach nginx uses with gzip on; gzip_types text/event-stream. Adds text/event-stream to DEFAULT_COMPRESSIBLE_TYPES. Skipping rules for the streaming path mirror complete() plus: skip when Content-Length is declared (we'd otherwise lie about the contracted length). sendfile still passes through uncompressed. Notably, _streaming_rewrite_headers does NOT add Transfer-Encoding: chunked itself — both backends auto-add it for HTTP/1.1 peers when no framing is present, and adding it manually breaks httptools' internal _chunked detection so subsequent send()s would write raw bytes. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/sse_compressed.py | 87 ++++++++++ localpost/http/README.md | 40 +++-- localpost/http/compress.py | 185 ++++++++++++++++++++- plans/compression-middleware.md | 94 ++++++++++- tests/http/compress.py | 274 ++++++++++++++++++++++++++++++++ 5 files changed, 662 insertions(+), 18 deletions(-) create mode 100644 examples/http/sse_compressed.py diff --git a/examples/http/sse_compressed.py b/examples/http/sse_compressed.py new file mode 100644 index 0000000..640e5d4 --- /dev/null +++ b/examples/http/sse_compressed.py @@ -0,0 +1,87 @@ +"""Server-Sent Events (SSE) with response compression. + +Run:: + + uv run examples/http/sse_compressed.py + +Open another terminal:: + + curl -i --no-buffer http://localhost:8000/events # uncompressed + curl -i --no-buffer -H 'Accept-Encoding: gzip' --compressed http://localhost:8000/events + curl -i --no-buffer -H 'Accept-Encoding: br' --compressed http://localhost:8000/events + +The handler emits one event per second. With ``Accept-Encoding`` set, +``compress_handler`` switches to streaming compression and sync-flushes +each event so the client sees them as they're produced (no buffering). +``Transfer-Encoding: chunked`` is auto-framed by the HTTP backend on +HTTP/1.1. + +In a browser:: + + const es = new EventSource('http://localhost:8000/events'); + es.onmessage = (e) => console.log(e.data); + +The browser decompresses ``Content-Encoding: gzip`` / ``br`` transparently +before the ``EventSource`` parser sees the stream. +""" + +from __future__ import annotations + +import logging +import sys +import time + +from localpost.hosting import run_app, service +from localpost.http import ( + HTTPReqCtx, + Response, + ServerConfig, + check_cancelled, + compress_handler, + http_server, + streaming_pool_handler, +) + + +def _events(ctx: HTTPReqCtx) -> None: + ctx.start_response( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-cache"), + ], + ) + ) + n = 0 + while True: + check_cancelled() + msg = f"data: tick {n} at {time.time():.3f}\n\n".encode() + ctx.send(msg) + n += 1 + time.sleep(1) + + +@service +async def app(): + # Streaming SSE handlers run on a streaming pool — body is not + # pre-buffered, the worker holds the borrowed conn for the duration. + async with streaming_pool_handler(_events, max_concurrency=8) as inner: + # Same compress_handler call as for JSON APIs; the middleware + # picks the streaming path automatically when the response has no + # Content-Length and the content type is in the allowlist (which + # text/event-stream is, by default). + wrapped = compress_handler(inner, algorithms=("br", "gzip")) + + config = ServerConfig(host="127.0.0.1", port=8000) + async with http_server(config, wrapped): + yield + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + return run_app(app()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/localpost/http/README.md b/localpost/http/README.md index 176978f..56f6195 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -331,15 +331,37 @@ When eligible: body is compressed, `Content-Length` is replaced, `Vary` (existing `Vary: Cookie` becomes `Vary: Cookie, Accept-Encoding`; `Vary: *` is left alone). -#### v1 limitations - -- **Only `complete()` is intercepted.** Streaming responses - (`start_response` / `send` / `finish_response`) and `sendfile` pass - through uncompressed. SSE compression in particular needs per-chunk - flushing and is a separate piece of work. -- **One-shot allocation.** A compressed buffer is allocated per - eligible response. Fine for typical JSON sizes; for multi-MB dynamic - payloads you want streaming compression instead (follow-up). +#### Streaming responses (incl. SSE) + +`compress_handler` also intercepts the streaming-response path +(`start_response` → `send`* → `finish_response`). The middleware decides +per-request: + +- One-shot path (`complete(response, body)`) → compress the whole body + in memory; replace `Content-Length`. +- Streaming path with **no** `Content-Length` declared → use an + incremental compressor; each `send(chunk)` emits the bytes followed + by a sync-flush so the decompressor sees each chunk promptly. The + backend auto-frames `Transfer-Encoding: chunked` on HTTP/1.1. +- Streaming path **with** `Content-Length` → pass through (we'd + otherwise lie about the declared length). + +For SSE (`Content-Type: text/event-stream`, included in +`DEFAULT_COMPRESSIBLE_TYPES`), each event you `send(...)` reaches the +client decompressed and parseable by `EventSource` — same approach +nginx uses with `gzip on; gzip_types text/event-stream`. All major +browsers transparently decompress `Content-Encoding: gzip` / `br` +streams before EventSource sees them. + +`sendfile` always passes through uncompressed — composition with the +static handler stays zero-copy. + +#### Limitations + +- **One-shot path allocates a compressed buffer per response.** Fine for + typical JSON; for multi-MB single-shot payloads consider building + the response with `start_response` + `send` instead so the streaming + compressor handles it incrementally. - **Brotli is opt-in.** `pip install localpost[http-compress]` installs `brotli`. If `"br"` is in `algorithms` without the extra, `compress_handler` raises `ImportError` at construction time. diff --git a/localpost/http/compress.py b/localpost/http/compress.py index 51d2871..86d6e61 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -27,9 +27,10 @@ import gzip import importlib import importlib.util +import zlib from collections.abc import Callable, Sequence from contextlib import AbstractContextManager -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, BinaryIO, Final from localpost.http._base import BaseHTTPConn, BodyHandler, HTTPReqCtx, RequestHandler, Selector @@ -51,6 +52,7 @@ b"text/xml", b"text/csv", b"text/javascript", + b"text/event-stream", b"application/json", b"application/javascript", b"application/xml", @@ -91,6 +93,73 @@ def _compress_brotli(data: bytes) -> bytes: } +# -------------------------------------------------------------------------- +# Streaming encoders (used by the streaming-response path) +# -------------------------------------------------------------------------- + + +class _StreamEncoder: + """Per-request streaming compressor. + + ``compress(data)`` is incremental — output may be empty until the + encoder has buffered enough. ``flush()`` emits a sync-flush block so + the decompressor can produce the bytes seen so far (the SSE + invariant: each event reaches the client promptly). ``finish()`` is + the terminal flush. + """ + + def compress(self, data: bytes) -> bytes: # pragma: no cover — abstract + raise NotImplementedError + + def flush(self) -> bytes: # pragma: no cover — abstract + raise NotImplementedError + + def finish(self) -> bytes: # pragma: no cover — abstract + raise NotImplementedError + + +class _GzipStreamEncoder(_StreamEncoder): + """``zlib.compressobj(wbits=31)`` produces gzip-format output (header + + deflate + trailer). ``Z_SYNC_FLUSH`` between events; ``Z_FINISH`` at end. + """ + + def __init__(self) -> None: + self._cobj = zlib.compressobj(level=6, method=zlib.DEFLATED, wbits=31) + + def compress(self, data: bytes) -> bytes: + return self._cobj.compress(data) + + def flush(self) -> bytes: + return self._cobj.flush(zlib.Z_SYNC_FLUSH) + + def finish(self) -> bytes: + return self._cobj.flush(zlib.Z_FINISH) + + +class _BrotliStreamEncoder(_StreamEncoder): + """``brotli.Compressor.flush()`` emits a flush-block (sync-flush + semantics); ``finish()`` ends the stream. + """ + + def __init__(self) -> None: + self._c = _brotli().Compressor(quality=4) + + def compress(self, data: bytes) -> bytes: + return self._c.process(data) + + def flush(self) -> bytes: + return self._c.flush() + + def finish(self) -> bytes: + return self._c.finish() + + +_STREAM_ENCODER_FACTORIES: dict[str, Callable[[], _StreamEncoder]] = { + "gzip": _GzipStreamEncoder, + "br": _BrotliStreamEncoder, +} + + def _check_algorithm_available(name: str) -> None: if name == "br": if importlib.util.find_spec("brotli") is None: @@ -230,6 +299,77 @@ def _rewrite_headers( return out +def _is_streaming_eligible( + response: Response, + *, + method: bytes, + compressible_types: frozenset[bytes], +) -> bool: + """Decision matrix for the streaming-response path. + + No body-size check (we don't know it). Otherwise mirrors + :func:`_is_compressible_response` plus an additional rule: skip when + the handler declared a ``Content-Length`` (a known-length response + is contractually fixed; compressing mid-stream would lie about the + declared length). + """ + if method == b"HEAD": + return False + status = response.status_code + if status in {204, 304} or 100 <= status < 200 or status == 206: + return False + has_compressible_type = False + for name, value in response.headers: + n = name.lower() + if n == b"content-length": + return False # known-length stream — pass through verbatim + if n == b"content-encoding": + v = value.strip().lower() + if v and v != b"identity": + return False + elif n == b"cache-control": + if b"no-transform" in value.lower(): + return False + elif n == b"content-type" and not has_compressible_type: + main_type = value.split(b";", 1)[0].strip().lower() + if main_type in compressible_types: + has_compressible_type = True + return has_compressible_type + + +def _streaming_rewrite_headers( + headers: Sequence[tuple[bytes, bytes]], + *, + encoding: str, +) -> list[tuple[bytes, bytes]]: + """Add ``Content-Encoding``, merge ``Vary``. ``Content-Length`` is + dropped (eligibility already rejected the case where it was set). + + We deliberately **don't** add ``Transfer-Encoding: chunked`` — both + backends auto-add it for HTTP/1.1 peers when no framing is present + (h11: ``_clean_up_response_headers_for_sending``; httptools: the + ``not has_framing`` branch in ``start_response``). Adding it + ourselves prevents httptools from setting its internal ``_chunked`` + flag, which then writes raw bytes instead of chunk-framed ones. + """ + enc_value = encoding.encode("ascii") + out: list[tuple[bytes, bytes]] = [] + vary_seen = False + for name, value in headers: + n = name.lower() + if n == b"content-length": + continue # defensive — eligibility check rejects this case + if n == b"vary": + vary_seen = True + out.append((name, _merge_vary(value))) + else: + out.append((name, value)) + if not vary_seen: + out.append((b"vary", b"Accept-Encoding")) + out.append((b"content-encoding", enc_value)) + return out + + def _merge_vary(existing: bytes) -> bytes: """Merge ``Accept-Encoding`` into an existing ``Vary`` header value.""" stripped = existing.strip() @@ -261,6 +401,9 @@ class _CompressedCtx: _encoding: str _min_size: int _compressible_types: frozenset[bytes] + # Streaming-mode state. ``_stream_encoder`` is non-None between a + # final ``start_response`` (eligible) and ``finish_response``. + _stream_encoder: _StreamEncoder | None = field(default=None, init=False) # ----- forwarded attributes ----- @@ -298,18 +441,52 @@ def borrow(self) -> AbstractContextManager[HTTPReqCtx]: def receive(self, size: int = 65536, /) -> bytes: return self._inner.receive(size) - # ----- forwarded streaming-response API (uncompressed in v1) ----- + # ----- intercepted streaming-response API ----- def start_response(self, response: Response | InformationalResponse, /) -> None: - self._inner.start_response(response) + # 1xx responses pass through verbatim (multiple may precede the final). + if isinstance(response, InformationalResponse): + self._inner.start_response(response) + return + # Final Response. Decide streaming compression eligibility. + if _is_streaming_eligible( + response, + method=self._inner.request.method, + compressible_types=self._compressible_types, + ): + self._stream_encoder = _STREAM_ENCODER_FACTORIES[self._encoding]() + new_headers = _streaming_rewrite_headers(response.headers, encoding=self._encoding) + self._inner.start_response( + Response(status_code=response.status_code, headers=new_headers, reason=response.reason), + ) + else: + self._inner.start_response(response) def send(self, chunk: Buffer, /) -> None: - self._inner.send(chunk) + if self._stream_encoder is None: + self._inner.send(chunk) + return + chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk) + if not chunk_bytes: + # Empty send (sometimes used to flush headers): pass through + # so we don't emit a sync-marker for nothing. + self._inner.send(chunk_bytes) + return + out = self._stream_encoder.compress(chunk_bytes) + self._stream_encoder.flush() + if out: + self._inner.send(out) def finish_response(self) -> None: + if self._stream_encoder is not None: + tail = self._stream_encoder.finish() + self._stream_encoder = None + if tail: + self._inner.send(tail) self._inner.finish_response() def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # Pass-through: zero-copy is preserved (compression and sendfile + # are at odds; see plans/compression-middleware.md). self._inner.sendfile(response, file, offset, count) # ----- intercepted ----- diff --git a/plans/compression-middleware.md b/plans/compression-middleware.md index e709c77..2bbab4d 100644 --- a/plans/compression-middleware.md +++ b/plans/compression-middleware.md @@ -176,13 +176,97 @@ Run via `just unit-tests`. - New `examples/http/compressed_api.py` — wraps a small JSON router with `compress_handler`. +## Step 4 — Streaming compression (SSE etc.) + +`compress_handler` was originally only the `complete()` path. This +extension adds the streaming-response path through the same call site +— no new flag, no behavior change for users who don't stream. + +### Decision: enable streaming compression when… + +`start_response(response)` is called with a **final** Response (not +1xx) and: + +- Method != HEAD +- No `Content-Length` declared (a known-length response is contractually + fixed; compressing it mid-stream would make the declared length wrong) +- No existing `Content-Encoding` +- No `Cache-Control: no-transform` +- Status not in `{1xx, 204, 304, 206}` +- `Content-Type` main-type is in `compressible_types` + +Otherwise: pass `start_response` / `send` / `finish_response` through +verbatim. + +`text/event-stream` is added to `DEFAULT_COMPRESSIBLE_TYPES`. + +### Header rewrite (streaming) + +- Drop `Content-Length` (only present if user set it, in which case we + already passed through; defensive). +- Ensure `Transfer-Encoding: chunked` (httptools auto-frames; h11 + needs it explicit to switch the writer). +- Add `Content-Encoding: `. +- Merge `Vary: Accept-Encoding` (same as `_merge_vary`). + +### Per-chunk semantics + +Each `ctx.send(chunk)` (with non-empty `chunk`) is compressed and +followed by a `Z_SYNC_FLUSH`-equivalent (`brotli.Compressor.flush()`) +so the bytes reach the wire promptly: + +``` +send(chunk): + out = encoder.compress(chunk) + encoder.flush() + if out: inner.send(out) + +finish_response(): + tail = encoder.finish() + if tail: inner.send(tail) + inner.finish_response() +``` + +Empty `send(b"")` (sometimes used to flush headers without body bytes) +passes through verbatim — no sync marker injected. + +This is the same approach nginx uses (`gzip on; gzip_types text/event-stream`). +All major browsers transparently decompress `Content-Encoding: gzip` / +`br` streams before the EventSource parser sees them. + +### Encoder interface + +A tiny internal Protocol with two implementations: + +```python +class _StreamEncoder(Protocol): + def compress(self, data: bytes) -> bytes: ... + def flush(self) -> bytes: ... # mid-stream, sync flush + def finish(self) -> bytes: ... # final flush + +class _GzipStreamEncoder: + # zlib.compressobj(level=6, wbits=31) → gzip-format output + # flush(Z_SYNC_FLUSH) for mid-stream, flush(Z_FINISH) for end. + +class _BrotliStreamEncoder: + # brotli.Compressor(quality=4) + # process / flush / finish. +``` + +### Tests (additional) + +- SSE-shaped round-trip: `Accept-Encoding: gzip` → + `Content-Encoding: gzip`, body decompresses to all events + concatenated. Same for brotli (skip if dep missing). +- Per-chunk flushing: read raw socket with timeout between sends; + verify the first event's compressed bytes arrive before the second + send. +- Pass-through when Content-Length is set on `start_response`. +- Pass-through when content type is not compressible. +- Pass-through when `Cache-Control: no-transform`. +- Empty `send(b"")` doesn't emit sync-marker bytes. + ## Followups (separate PRs) -- **Streaming compression** — wrap `start_response` / `send` / - `finish_response`. For chunked responses, switch to - `Transfer-Encoding: chunked` (httptools backend already auto-frames - chunked when `Content-Length` is absent). For SSE, per-chunk flush - is required so events aren't buffered indefinitely. - **zstd** — add when `compression.zstd` (Python 3.14 stdlib) is the supported floor, or as a third-party-backed extra (`zstandard`) sooner. Same negotiation logic; one more entry in the diff --git a/tests/http/compress.py b/tests/http/compress.py index 4e144d6..0933b8d 100644 --- a/tests/http/compress.py +++ b/tests/http/compress.py @@ -4,6 +4,7 @@ import gzip import importlib.util +import zlib from pathlib import Path import httpx @@ -19,9 +20,11 @@ from localpost.http.compress import ( DEFAULT_COMPRESSIBLE_TYPES, _is_compressible_response, + _is_streaming_eligible, _merge_vary, _negotiate, _rewrite_headers, + _streaming_rewrite_headers, ) _HAS_BROTLI = importlib.util.find_spec("brotli") is not None @@ -427,3 +430,274 @@ def test_actual_gzip_bytes_on_wire(self, serve_backend_in_thread): head, _, raw_body = buf.partition(b"\r\n\r\n") assert b"content-encoding: gzip" in head.lower() assert gzip.decompress(raw_body) == body + + +# --- Streaming eligibility (unit) ---------------------------------------- + + +class TestStreamingEligible: + def test_basic_yes(self): + r = _r(headers=[(b"content-type", b"text/event-stream")]) + assert _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_head_no(self): + r = _r(headers=[(b"content-type", b"text/event-stream")]) + assert not _is_streaming_eligible(r, method=b"HEAD", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_known_length_no(self): + # Handler declared exact size — must not compress mid-stream. + r = _r(headers=[(b"content-type", b"text/event-stream"), (b"content-length", b"100")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_no_transform_no(self): + r = _r(headers=[(b"content-type", b"text/event-stream"), (b"cache-control", b"no-transform")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_existing_encoding_no(self): + r = _r(headers=[(b"content-type", b"text/event-stream"), (b"content-encoding", b"gzip")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + def test_non_compressible_type_no(self): + r = _r(headers=[(b"content-type", b"image/png")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + @pytest.mark.parametrize("status", [100, 199, 204, 304, 206]) + def test_no_body_status(self, status): + r = _r(status, headers=[(b"content-type", b"text/event-stream")]) + assert not _is_streaming_eligible(r, method=b"GET", compressible_types=DEFAULT_COMPRESSIBLE_TYPES) + + +# --- Streaming header rewrite (unit) ------------------------------------- + + +class TestStreamingRewriteHeaders: + def test_drops_content_length(self): + out = _streaming_rewrite_headers( + [(b"content-type", b"text/event-stream"), (b"content-length", b"100")], + encoding="gzip", + ) + names = [n for n, _ in out] + assert b"content-length" not in names + + def test_does_not_add_transfer_encoding(self): + # Backends auto-frame chunked on HTTP/1.1 — adding TE here breaks + # httptools' _chunked detection. See _streaming_rewrite_headers' docstring. + out = _streaming_rewrite_headers([(b"content-type", b"text/event-stream")], encoding="gzip") + names = [n.lower() for n, _ in out] + assert b"transfer-encoding" not in names + + def test_adds_content_encoding(self): + out = _streaming_rewrite_headers([(b"content-type", b"text/event-stream")], encoding="br") + assert (b"content-encoding", b"br") in out + + def test_merges_vary(self): + out = _streaming_rewrite_headers( + [(b"content-type", b"text/event-stream"), (b"vary", b"Cookie")], + encoding="gzip", + ) + d = dict(out) + assert d[b"vary"] == b"Cookie, Accept-Encoding" + + +# --- Streaming round-trip ------------------------------------------------ + + +def _sse_handler(events: list[bytes]): + """Handler that emits each item in ``events`` as a separate ``send`` call.""" + + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + # No Content-Length → streaming path. + ctx.start_response( + Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]) + ) + for ev in events: + ctx.send(b"data: " + ev + b"\n\n") + ctx.finish_response() + return None + + return handler + + +class TestStreamingRoundTripGzip: + def test_sse_compressed(self, serve_backend_in_thread): + events = [b"hello", b"world", b"x" * 4096, b"final"] + h = compress_handler(_sse_handler(events), algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "gzip" + assert "transfer-encoding" in r.headers + # httpx auto-decodes; verify all events present in order. + decoded = r.content + for ev in events: + assert b"data: " + ev + b"\n\n" in decoded + # Total length ordering preserved. + positions = [decoded.find(b"data: " + ev) for ev in events] + assert positions == sorted(positions) + + def test_streaming_passthrough_when_content_length_set(self, serve_backend_in_thread): + # If the handler set Content-Length, we must not compress (would + # corrupt the contract). Pass through verbatim. + body_chunk = b"x" * 4096 + + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.start_response( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"content-length", str(len(body_chunk)).encode("ascii")), + ], + ) + ) + ctx.send(body_chunk) + ctx.finish_response() + return None + + h = compress_handler(handler, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert r.status_code == 200 + assert "content-encoding" not in r.headers + assert r.content == body_chunk + + def test_streaming_passthrough_when_no_transform(self, serve_backend_in_thread): + events = [b"a", b"b", b"c"] + + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.start_response( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-transform"), + ], + ) + ) + for ev in events: + ctx.send(b"data: " + ev + b"\n\n") + ctx.finish_response() + return None + + h = compress_handler(handler, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "gzip"}, + timeout=5, + ) + assert "content-encoding" not in r.headers + assert b"data: a" in r.content + + +@pytest.mark.skipif(not _HAS_BROTLI, reason="brotli optional extra not installed") +class TestStreamingRoundTripBrotli: + def test_sse_compressed(self, serve_backend_in_thread): + events = [b"hello", b"world", b"x" * 4096] + h = compress_handler(_sse_handler(events), algorithms=("br",)) + with serve_backend_in_thread(h) as port: + r = httpx.get( + f"http://127.0.0.1:{port}/", + headers={"accept-encoding": "br"}, + timeout=5, + ) + assert r.status_code == 200 + assert r.headers["content-encoding"] == "br" + for ev in events: + assert b"data: " + ev in r.content + + +# --- Per-event flushing (raw socket) ------------------------------------- + + +class TestStreamingFlush: + """Verify SYNC_FLUSH semantics: the first event must be decodable + *before* the handler emits the second event. If we relied on the + full-stream end-flush, the test would deadlock — the handler is + holding a gate that only releases once the test reads the first event. + """ + + def test_first_event_decodes_before_stream_ends(self, serve_backend_in_thread): + import socket # noqa: PLC0415 + import threading # noqa: PLC0415 + + gate = threading.Event() + + def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + ctx.start_response( + Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]) + ) + ctx.send(b"data: first\n\n") + # Block until the test has read+decoded "data: first". If + # SYNC_FLUSH didn't happen, this deadlocks at the 5s timeout. + gate.wait(timeout=5) + ctx.send(b"data: second\n\n") + ctx.finish_response() + return None + + h = compress_handler(handler, algorithms=("gzip",)) + with serve_backend_in_thread(h) as port, socket.create_connection( + ("127.0.0.1", port), timeout=5 + ) as s: + s.sendall( + b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n" + ) + buf = bytearray() + s.settimeout(5) + while b"\r\n\r\n" not in buf: + buf.extend(s.recv(8192)) + head, _, after = bytes(buf).partition(b"\r\n\r\n") + assert b"content-encoding: gzip" in head.lower() + assert b"transfer-encoding: chunked" in head.lower() + + decompressor = zlib.decompressobj(wbits=31) + decoded = bytearray() + stream = bytearray(after) + + while b"data: first" not in decoded: + consumed = _consume_one_chunk(stream) + if consumed is None: + stream.extend(s.recv(8192)) + continue + decoded.extend(decompressor.decompress(consumed)) + + assert b"data: first" in decoded + gate.set() + # Drain remaining bytes (best-effort) so server-side loop + # logging stays clean for other tests. + try: + while s.recv(8192): + pass + except OSError: + pass + + +def _consume_one_chunk(stream: bytearray) -> bytes | None: + """Pop one HTTP/1.1 chunked-transfer chunk from ``stream``. Returns + the chunk's payload bytes, or ``None`` if a full chunk isn't present + yet (caller should ``recv`` more). + """ + idx = stream.find(b"\r\n") + if idx <= 0: + return None + try: + size = int(stream[:idx], 16) + except ValueError: + return None + if size == 0: + return None + start = idx + 2 + end = start + size + if len(stream) < end + 2: + return None + payload = bytes(stream[start:end]) + del stream[: end + 2] + return payload From e8515dea8c8d676d55c695a2b3bc81a2d51c93c6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 19:43:16 +0400 Subject: [PATCH 178/286] fix(http): honor user-set Transfer-Encoding: chunked on httptools backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a handler or middleware passes Transfer-Encoding: chunked on the response headers explicitly, httptools' start_response saw has_framing =True and skipped the auto-frame branch — but self._chunked is set only inside that branch, so subsequent ctx.send(...) calls wrote raw bytes while the wire still advertised chunked encoding. h11 already handled this correctly via its own writer dispatch; httptools silently produced a malformed response. Fix: extend _scan_response_headers to also report has_chunked, and flip self._chunked = True when either we auto-add chunked or the caller already supplied it. Adds a parametrised regression test exercising both backends — two send() calls with explicit TE: chunked, asserts the framed "\r\n\r\n" wire format plus the zero-length terminator. Drops the now-unnecessary workaround note in compress.py:_streaming_rewrite_headers. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/compress.py | 9 ++--- localpost/http/server_httptools.py | 56 +++++++++++++++++++++--------- tests/http/backend_parity.py | 45 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/localpost/http/compress.py b/localpost/http/compress.py index 86d6e61..e22ac76 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -344,13 +344,8 @@ def _streaming_rewrite_headers( ) -> list[tuple[bytes, bytes]]: """Add ``Content-Encoding``, merge ``Vary``. ``Content-Length`` is dropped (eligibility already rejected the case where it was set). - - We deliberately **don't** add ``Transfer-Encoding: chunked`` — both - backends auto-add it for HTTP/1.1 peers when no framing is present - (h11: ``_clean_up_response_headers_for_sending``; httptools: the - ``not has_framing`` branch in ``start_response``). Adding it - ourselves prevents httptools from setting its internal ``_chunked`` - flag, which then writes raw bytes instead of chunk-framed ones. + ``Transfer-Encoding`` is left to the backend — both auto-add + ``chunked`` for HTTP/1.1 peers when no framing is present. """ enc_value = encoding.encode("ascii") out: list[tuple[bytes, bytes]] = [] diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 0d39afc..ac96ab1 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -89,20 +89,34 @@ def _serialize_response(r: Response | InformationalResponse) -> bytes: return bytes(out) -def _scan_response_headers(headers: Sequence[tuple[bytes, bytes]]) -> tuple[bool, bool]: - """One-pass scan: ``(has_connection_close, has_content_length_or_te)``. - - Combined to avoid two walks per response. +def _scan_response_headers(headers: Sequence[tuple[bytes, bytes]]) -> tuple[bool, bool, bool]: + """One-pass scan: ``(has_connection_close, has_framing, has_chunked)``. + + - ``has_framing`` — either ``Content-Length`` or ``Transfer-Encoding`` + is set, i.e. the auto-frame branch in :meth:`start_response` should + be skipped. + - ``has_chunked`` — ``Transfer-Encoding`` carries a ``chunked`` token + anywhere in its value (per RFC 7230 §3.3.1, ``chunked`` must be the + final encoding). When true, ``_chunked`` must be set so subsequent + ``send`` calls actually wrap chunks with ``\\r\\n\\r\\n`` + framing — otherwise the wire is malformed. + + Combined to avoid multiple walks per response. """ has_close = False has_framing = False + has_chunked = False for name, value in headers: n = name.lower() if n == b"connection" and b"close" in value.lower(): has_close = True - elif n in {b"content-length", b"transfer-encoding"}: + elif n == b"content-length": + has_framing = True + elif n == b"transfer-encoding": has_framing = True - return has_close, has_framing + if b"chunked" in value.lower(): + has_chunked = True + return has_close, has_framing, has_chunked def _response_allows_body(request_method: bytes, status_code: int) -> bool: @@ -652,19 +666,27 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: self.response_status = response.status_code self.conn._response_started = True self._body_allowed = _response_allows_body(self.request.method, response.status_code) - has_close, has_framing = _scan_response_headers(response.headers) + has_close, has_framing, has_chunked = _scan_response_headers(response.headers) if has_close or not self._keep_alive: self.conn._close_after_response = True - if not has_framing and self._body_allowed: - # Auto-frame: no Content-Length / Transfer-Encoding → chunked. - # Without framing, an HTTP/1.1 client would wait for the - # connection to close before considering the response done. - response = Response( - status_code=response.status_code, - headers=[*response.headers, (b"transfer-encoding", b"chunked")], - reason=response.reason, - ) - self._chunked = True + if self._body_allowed: + if not has_framing: + # Auto-frame: no Content-Length / Transfer-Encoding → chunked. + # Without framing, an HTTP/1.1 client would wait for the + # connection to close before considering the response done. + response = Response( + status_code=response.status_code, + headers=[*response.headers, (b"transfer-encoding", b"chunked")], + reason=response.reason, + ) + self._chunked = True + elif has_chunked: + # User / middleware supplied ``Transfer-Encoding: chunked`` + # explicitly. Skip auto-add but still flip ``_chunked`` so + # ``send`` frames each chunk — otherwise the header would + # advertise chunked while the body went out raw, corrupting + # the wire. + self._chunked = True self._pending_header_bytes = _serialize_response(response) else: # Informational responses (e.g., 100 Continue) flush immediately. diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index d85be93..e28302b 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -238,3 +238,48 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"transfer-encoding:" not in headers.lower() assert body == b"" assert extra == b"" + + +def test_user_supplied_chunked_te_frames_chunks(serve_backend_in_thread): + """Regression: a handler that explicitly sets ``Transfer-Encoding: chunked`` + must still get its ``send(...)`` chunks framed on the wire. Earlier the + httptools backend only set its internal ``_chunked`` flag inside the + auto-frame branch, so user-supplied TE bypassed framing and the wire + was malformed. + """ + import socket # noqa: PLC0415 + + def handler(ctx: HTTPReqCtx) -> None: + ctx.start_response( + Response( + status_code=200, + headers=[ + (b"content-type", b"text/plain"), + (b"transfer-encoding", b"chunked"), + ], + ) + ) + ctx.send(b"chunk1") + ctx.send(b"chunk2") + ctx.finish_response() + + with serve_backend_in_thread(handler) as port, socket.create_connection( + ("127.0.0.1", port), timeout=5 + ) as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + s.settimeout(5) + buf = b"" + while True: + chunk = s.recv(8192) + if not chunk: + break + buf += chunk + + head, _, body = buf.partition(b"\r\n\r\n") + assert b"transfer-encoding: chunked" in head.lower() + # Each ``send`` produces one chunk on the wire, framed as + # ``\r\n\r\n``. Verify both are present, then the + # zero-length terminator. + assert b"6\r\nchunk1\r\n" in body + assert b"6\r\nchunk2\r\n" in body + assert body.endswith(b"0\r\n\r\n") From 289d5114135581da6440202f26ae3ea885218277 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 4 May 2026 22:58:05 +0400 Subject: [PATCH 179/286] feat(openapi): pluggable TypeAdapter registry for schema libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hard-coded msgspec/pydantic branches with an AdapterRegistry that dispatches JSON Schema generation, body decoding, and response encoding to per-library TypeAdapters. msgspec stays the catch-all; pydantic is a lazy-imported adapter; new libraries (attrs, protobuf, ...) plug in via HttpApp(adapters=...) without touching framework internals. Drops the public is_pydantic_model helper and the localpost.openapi.pydantic module — adapter dispatch is the new extension point. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/__init__.py | 13 +- localpost/openapi/adapters/__init__.py | 150 ++++++++++++++++++++++++ localpost/openapi/adapters/_msgspec.py | 63 ++++++++++ localpost/openapi/adapters/_pydantic.py | 50 ++++++++ localpost/openapi/app.py | 12 +- localpost/openapi/operation.py | 62 ++++++---- localpost/openapi/pydantic.py | 49 -------- localpost/openapi/resolvers.py | 83 ++++++------- localpost/openapi/schemas.py | 101 +++++----------- localpost/openapi/sse.py | 30 +++-- tests/openapi/schemas.py | 78 ++++++++++-- 11 files changed, 481 insertions(+), 210 deletions(-) create mode 100644 localpost/openapi/adapters/__init__.py create mode 100644 localpost/openapi/adapters/_msgspec.py create mode 100644 localpost/openapi/adapters/_pydantic.py delete mode 100644 localpost/openapi/pydantic.py diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index ac09359..dab60a5 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -31,11 +31,13 @@ def get_book(book_id: str) -> Book | NotFound[str]: sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) -Pydantic models are recognised automatically — install pydantic -yourself; it isn't a runtime dependency of localpost. +Pydantic models are recognised automatically when pydantic is installed. +To plug in another schema library, supply a custom +:class:`localpost.openapi.adapters.AdapterRegistry` via ``HttpApp(adapters=...)``. """ from localpost.openapi import spec +from localpost.openapi.adapters import AdapterRegistry, TypeAdapter, default_registry from localpost.openapi.app import HttpApp from localpost.openapi.auth import HttpBasicAuth, HttpBearerAuth from localpost.openapi.filter import OpFilter, op_filter @@ -63,7 +65,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: Unauthorized, UnprocessableEntity, ) -from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry, is_pydantic_model +from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry from localpost.openapi.sse import Event, EventStream __all__ = [ @@ -101,7 +103,10 @@ def get_book(book_id: str) -> Book | NotFound[str]: # schema utilities "SchemaRegistry", "REF_TEMPLATE", - "is_pydantic_model", + # type adapters (pluggable schema libraries) + "TypeAdapter", + "AdapterRegistry", + "default_registry", # SSE "Event", "EventStream", diff --git a/localpost/openapi/adapters/__init__.py b/localpost/openapi/adapters/__init__.py new file mode 100644 index 0000000..d76b786 --- /dev/null +++ b/localpost/openapi/adapters/__init__.py @@ -0,0 +1,150 @@ +"""Pluggable type adapters for JSON Schema, request decode, and response encode. + +A :class:`TypeAdapter` owns a family of types — msgspec for the catch-all, +pydantic for :class:`pydantic.BaseModel` subclasses, and so on. The +:class:`AdapterRegistry` dispatches by type, with the catch-all (msgspec) +always last. + +The default registry is auto-built from what's installed: msgspec is a +hard runtime dependency; pydantic is detected at import time. To plug in +a custom adapter (e.g. attrs, protobuf), pass an explicit registry to +:class:`localpost.openapi.HttpApp`:: + + from localpost.openapi import HttpApp + from localpost.openapi.adapters import AdapterRegistry, default_registry + + registry = AdapterRegistry([MyAttrsAdapter(), *default_registry().adapters]) + app = HttpApp(adapters=registry) +""" + +from __future__ import annotations + +import functools +from collections.abc import Sequence +from typing import Any, Protocol + +__all__ = [ + "AdapterRegistry", + "TypeAdapter", + "default_registry", +] + + +class TypeAdapter(Protocol): + """Library-specific bridge for JSON Schema, decode, and encode. + + Implementations are expected to be stateless and cheap to instantiate. + The catch-all adapter (msgspec by default) is the last one in the + registry; its :meth:`claims` always returns ``True``. + """ + + name: str + validation_errors: tuple[type[Exception], ...] + + def claims(self, t: Any, /) -> bool: + """True if this adapter handles ``t``. + + The first adapter in the registry that returns ``True`` wins. + """ + ... + + def is_body_type(self, t: Any, /) -> bool: + """True if ``t`` should be parsed from the request body. + + Falsey types (primitives, ``Annotated`` scalars, …) are left as + query / header parameters. Implementations should return ``False`` + for types that look scalar even if they technically claim them. + """ + ... + + def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + """Return the JSON Schema fragment for ``t``. + + For named types, return ``{"$ref": ...}`` and let + :meth:`components` produce the body. + """ + ... + + def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + """Return the ``components.schemas`` entries for every type + previously passed to :meth:`schema`. + """ + ... + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + """Parse ``body`` (raw request bytes) into an instance of ``t``. + + ``content_type`` is the request's declared content type — adapters + carrying multiple wire formats (e.g. protobuf binary vs JSON) use + it to dispatch; pure-JSON adapters can ignore it. + """ + ... + + def encode(self, value: object, /) -> tuple[bytes, str]: + """Serialise ``value`` to ``(body_bytes, content_type)``.""" + ... + + +class AdapterRegistry: + """Ordered list of :class:`TypeAdapter` s with type-keyed dispatch. + + The last adapter in the list is the catch-all; its :meth:`claims` is + expected to return ``True`` for every type, so :meth:`for_type` always + yields a usable adapter. + """ + + __slots__ = ("_adapters", "_validation_errors") + + def __init__(self, adapters: Sequence[TypeAdapter]) -> None: + if not adapters: + raise ValueError("AdapterRegistry requires at least one adapter") + self._adapters: tuple[TypeAdapter, ...] = tuple(adapters) + # Flatten + dedupe per-adapter validation errors so call sites can + # use a single ``except`` clause. + seen: set[type[Exception]] = set() + flat: list[type[Exception]] = [] + for a in self._adapters: + for exc in a.validation_errors: + if exc in seen: + continue + seen.add(exc) + flat.append(exc) + self._validation_errors: tuple[type[Exception], ...] = tuple(flat) + + @property + def adapters(self) -> tuple[TypeAdapter, ...]: + return self._adapters + + @property + def validation_errors(self) -> tuple[type[Exception], ...]: + return self._validation_errors + + def for_type(self, t: Any) -> TypeAdapter: + """Return the first adapter that claims ``t`` (catch-all if none).""" + for adapter in self._adapters: + if adapter.claims(t): + return adapter + return self._adapters[-1] + + def for_value(self, value: object) -> TypeAdapter: + return self.for_type(type(value)) + + +@functools.cache +def default_registry() -> AdapterRegistry: + """Return a process-wide :class:`AdapterRegistry` autoconfigured from + installed libraries. + + Order: pydantic (if importable), then msgspec as the catch-all. Cached. + """ + from localpost.openapi.adapters._msgspec import MsgspecAdapter # noqa: PLC0415 + + adapters: list[TypeAdapter] = [] + try: + from localpost.openapi.adapters._pydantic import PydanticAdapter # noqa: PLC0415 + + adapters.append(PydanticAdapter()) + except ImportError: + pass + adapters.append(MsgspecAdapter()) + return AdapterRegistry(adapters) diff --git a/localpost/openapi/adapters/_msgspec.py b/localpost/openapi/adapters/_msgspec.py new file mode 100644 index 0000000..5691862 --- /dev/null +++ b/localpost/openapi/adapters/_msgspec.py @@ -0,0 +1,63 @@ +"""msgspec-based :class:`TypeAdapter` — the catch-all in the registry. + +Understands :class:`msgspec.Struct`, dataclasses, ``TypedDict``, +``NamedTuple``, primitives, collections, ``Annotated[T, msgspec.Meta(...)]``, +``Literal``, ``Enum``, ``Union``, ``UUID``, ``datetime`` — i.e. everything +:func:`msgspec.json.schema_components` and :func:`msgspec.json.decode` +already handle. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import msgspec + +__all__ = ["MsgspecAdapter"] + + +_JSON_CONTENT_TYPE = "application/json" + + +class MsgspecAdapter: + name = "msgspec" + validation_errors: tuple[type[Exception], ...] = (msgspec.ValidationError,) + + def claims(self, t: Any, /) -> bool: + # Catch-all — placed last in the registry so more specific adapters + # win first. Returning True unconditionally lets us also handle + # primitives, generics, unions, and the long tail (UUID, datetime). + return True + + def is_body_type(self, t: Any, /) -> bool: + if not isinstance(t, type): + return False + if issubclass(t, msgspec.Struct): + return True + if hasattr(t, "__dataclass_fields__"): + return True + # NamedTuple: subclass of tuple with class-level field metadata. + return hasattr(t, "__annotations__") and hasattr(t, "_fields") + + def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + try: + (schema,), _ = msgspec.json.schema_components([t], ref_template=ref_template) + except (TypeError, RuntimeError): + # Fall back to a permissive schema for types msgspec can't introspect. + return {} + return schema + + def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + if not types: + return {} + _, components = msgspec.json.schema_components(list(types), ref_template=ref_template) + return dict(components) + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + if not body: + raise ValueError("empty request body") + return msgspec.json.decode(body, type=t) + + def encode(self, value: object, /) -> tuple[bytes, str]: + return msgspec.json.encode(value), _JSON_CONTENT_TYPE diff --git a/localpost/openapi/adapters/_pydantic.py b/localpost/openapi/adapters/_pydantic.py new file mode 100644 index 0000000..844c843 --- /dev/null +++ b/localpost/openapi/adapters/_pydantic.py @@ -0,0 +1,50 @@ +"""pydantic :class:`TypeAdapter`. Imported lazily by ``default_registry``; +import succeeds only when pydantic is installed. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from pydantic import BaseModel, ValidationError + +__all__ = ["PydanticAdapter"] + + +_JSON_CONTENT_TYPE = "application/json" + + +class PydanticAdapter: + name = "pydantic" + validation_errors: tuple[type[Exception], ...] = (ValidationError,) + + def claims(self, t: Any, /) -> bool: + return isinstance(t, type) and issubclass(t, BaseModel) + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + # Body emitted in components(); inline ref keeps the schema fragment + # consistent with msgspec's $ref-for-named-types behaviour. + return {"$ref": ref_template.format(name=t.__name__)} + + def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + # Pydantic's placeholder is ``{model}``, not ``{name}``. + pydantic_template = ref_template.replace("{name}", "{model}") + out: dict[str, dict[str, Any]] = {} + for t in types: + raw: dict[str, Any] = t.model_json_schema(ref_template=pydantic_template) + raw.pop("$defs", None) + out[t.__name__] = raw + return out + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + if not body: + raise ValueError("empty request body") + return t.model_validate_json(body) + + def encode(self, value: object, /) -> tuple[bytes, str]: + assert isinstance(value, BaseModel) + return value.model_dump_json().encode("utf-8"), _JSON_CONTENT_TYPE diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index cdd7ce9..e6b27e3 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -39,6 +39,7 @@ def hello(name: str) -> str: from localpost.http.router import Routes from localpost.openapi import spec as openapi_spec from localpost.openapi._docs import redoc_html, scalar_html, swagger_html +from localpost.openapi.adapters import AdapterRegistry, default_registry from localpost.openapi.filter import OpFilter from localpost.openapi.operation import Operation from localpost.openapi.schemas import SchemaRegistry @@ -70,6 +71,11 @@ class HttpApp: under it: ``{docs_path}`` (Swagger), ``{docs_path}/redoc``, ``{docs_path}/scalar``. ``None`` to disable all UIs. docs_ui: Which doc UIs to mount. Default ``"all"``. + adapters: Type adapters used for JSON Schema generation, request + body decoding, and response encoding. Defaults to + :func:`localpost.openapi.adapters.default_registry` (msgspec + as catch-all, plus pydantic if installed). Pass a custom + :class:`AdapterRegistry` to plug in attrs / protobuf / etc. """ def __init__( @@ -82,6 +88,7 @@ def __init__( openapi_path: str | None = "/openapi.json", docs_path: str | None = "/docs", docs_ui: DocsUI = "all", + adapters: AdapterRegistry | None = None, ) -> None: if max_concurrency < 1: raise ValueError("max_concurrency must be >= 1") @@ -94,6 +101,7 @@ def __init__( self._openapi_path = openapi_path self._docs_path = docs_path self._docs_ui = docs_ui + self._adapters = adapters or default_registry() self._operations: list[Operation] = [] self._lock = threading.Lock() # Cached spec; invalidated whenever an operation is added. @@ -122,7 +130,7 @@ def _decorator(self, method: HTTPMethod, path: str, op_filters: Sequence[OpFilte combined = (*self._filters, *op_filters) def deco(fn: Callable[..., Any]) -> Callable[..., Any]: - op = Operation.create(method, path, fn, filters=combined) + op = Operation.create(method, path, fn, filters=combined, adapters=self._adapters) with self._lock: self._operations.append(op) self._cached_spec = None @@ -144,7 +152,7 @@ def openapi_doc(self) -> openapi_spec.OpenAPI: cached = self._cached_spec if cached is not None: return cached - registry = SchemaRegistry() + registry = SchemaRegistry(self._adapters) doc = openapi_spec.OpenAPI(info=self._info) # Every filter (app-level and per-op) gets its contribute_root # called exactly once. We dedupe by identity so the same filter diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index 35d6746..f70ae28 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -22,13 +22,12 @@ from types import UnionType from typing import Annotated, Any, Self, Union, get_args, get_origin, get_type_hints -import msgspec - from localpost.http import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._types import Response as _Response from localpost.http.router import URITemplate from localpost.openapi import spec as openapi_spec +from localpost.openapi.adapters import AdapterRegistry, default_registry from localpost.openapi.filter import OpFilter from localpost.openapi.resolvers import ( ArgResolver, @@ -39,7 +38,7 @@ is_body_type, ) from localpost.openapi.results import NoContent, NotFound, Ok, OpResult -from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model +from localpost.openapi.schemas import SchemaRegistry from localpost.openapi.sse import EventStream, iter_events __all__ = ["Operation", "ResponseShape", "build_arg_resolvers", "extract_response_shapes"] @@ -85,6 +84,10 @@ class Operation: operation_id: str description: str + adapters: AdapterRegistry + """Type adapters used at request time for body decode and response + encode. Threaded down from :class:`HttpApp`.""" + @classmethod def create( cls, @@ -94,11 +97,15 @@ def create( /, *, filters: tuple[OpFilter, ...] = (), + adapters: AdapterRegistry | None = None, ) -> Self: template = URITemplate.parse(path) path_var_names = set(template.variable_names) + registry = adapters or default_registry() - sig, arg_resolvers, arg_factories = build_arg_resolvers(fn, path_var_names=path_var_names) + sig, arg_resolvers, arg_factories = build_arg_resolvers( + fn, path_var_names=path_var_names, adapters=registry + ) # Validate path bindings: every {var} in the template must be # claimed by a parameter (whether implicitly via name match or @@ -142,6 +149,7 @@ def create( summary=summary, operation_id=operation_id, description=description, + adapters=registry, ) # ----- runtime ----- @@ -163,17 +171,18 @@ def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: return pre_body def _run(self, ctx: HTTPReqCtx) -> None: + registry = self.adapters for f in self.filters: short_circuit = f(ctx) if short_circuit is not None: - response, body = _build_http_response(short_circuit) + response, body = _build_http_response(short_circuit, registry) ctx.complete(response, body) return kwargs: dict[str, object] = {} for name, resolver in self.arg_resolvers: value = resolver(ctx) if isinstance(value, OpResult): - response, body = _build_http_response(value) + response, body = _build_http_response(value, registry) ctx.complete(response, body) return kwargs[name] = value @@ -182,7 +191,7 @@ def _run(self, ctx: HTTPReqCtx) -> None: ctx.complete(result, b"") return if _is_sse_payload(result): - _stream_sse(ctx, result) + _stream_sse(ctx, result, registry) return if isinstance(result, OpResult): op_result: OpResult = result @@ -191,7 +200,7 @@ def _run(self, ctx: HTTPReqCtx) -> None: op_result = NotFound(None) else: op_result = Ok(result) - response, body = _build_http_response(op_result) + response, body = _build_http_response(op_result, registry) ctx.complete(response, body) # ----- spec build ----- @@ -224,6 +233,7 @@ def build_arg_resolvers( fn: Callable[..., Any], *, path_var_names: set[str] | None = None, + adapters: AdapterRegistry | None = None, ) -> tuple[ inspect.Signature, list[tuple[str, ArgResolver]], @@ -235,8 +245,14 @@ def build_arg_resolvers( Path-template binding is *not* validated here — pass ``path_var_names`` so :class:`FromPath` is auto-picked for matching parameter names; the caller is responsible for any "unbound var" error. + + ``adapters`` flows into auto-picked / un-bound :class:`FromBody` + factories so body decoding hits the per-app type adapter registry. If + ``None``, falls back to :func:`default_registry` (used by + :func:`op_filter`, which is built outside any app). """ path_vars = path_var_names or set() + registry = adapters or default_registry() # Resolve PEP 563 string annotations (``from __future__ import # annotations`` is in effect for most callers) into the real types @@ -259,12 +275,17 @@ def build_arg_resolvers( for name, param in sig.parameters.items(): if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): raise ValueError(f"handler {_qualname(fn)!r}: *args / **kwargs not supported") - factory = _pick_factory(name, param, path_vars) + factory = _pick_factory(name, param, path_vars, registry) if factory is None: # HTTPReqCtx pass-through. runtime.append((name, _resolve_ctx)) factories.append((name, param, None)) else: + # Inject the registry into FromBody so its closure binds the + # right adapter at build time. User-supplied factories are left + # alone — they're trusted to handle their own decoding. + if isinstance(factory, FromBody) and factory.adapters is None: + factory = replace(factory, adapters=registry) runtime.append((name, factory(param))) factories.append((name, param, factory)) return sig, runtime, factories @@ -328,6 +349,7 @@ def _pick_factory( name: str, param: inspect.Parameter, path_var_names: set[str], + adapters: AdapterRegistry, ) -> ArgResolverFactory | None: """Return the resolver factory for one parameter, or ``None`` for the ``HTTPReqCtx`` pass-through.""" @@ -347,7 +369,7 @@ def _pick_factory( return FromPath(name=name) if target is HTTPReqCtx: return None - if is_body_type(target): + if is_body_type(target, adapters): return FromBody() return FromQuery(name=name) @@ -469,16 +491,15 @@ def _build_responses(shapes: tuple[ResponseShape, ...], registry: SchemaRegistry # --- Response building --------------------------------------------------- -_JSON_CONTENT_TYPE = b"application/json" _TEXT_CONTENT_TYPE = b"text/plain; charset=utf-8" _OCTET_CONTENT_TYPE = b"application/octet-stream" -def _encode_body(value: object) -> tuple[bytes, bytes]: +def _encode_body(value: object, adapters: AdapterRegistry) -> tuple[bytes, bytes]: """Return ``(body_bytes, content_type)`` for ``value``. - Pass-through for ``bytes`` / ``str``; pydantic models go through - ``model_dump_json``; everything else through :func:`msgspec.json.encode`. + Pass-through for ``bytes`` / ``str``; structured values go through the + :class:`TypeAdapter` that claims ``type(value)``. """ if value is None: return b"", b"" @@ -488,13 +509,12 @@ def _encode_body(value: object) -> tuple[bytes, bytes]: return bytes(value), _OCTET_CONTENT_TYPE if isinstance(value, str): return value.encode("utf-8"), _TEXT_CONTENT_TYPE - if is_pydantic_model(type(value)): - return getattr(value, "model_dump_json")().encode("utf-8"), _JSON_CONTENT_TYPE # noqa: B009 - return msgspec.json.encode(value), _JSON_CONTENT_TYPE + body, content_type = adapters.for_value(value).encode(value) + return body, content_type.encode("ascii") -def _build_http_response(result: OpResult) -> tuple[_Response, bytes]: - body_bytes, default_ct = _encode_body(result.body) +def _build_http_response(result: OpResult, adapters: AdapterRegistry) -> tuple[_Response, bytes]: + body_bytes, default_ct = _encode_body(result.body, adapters) headers: list[tuple[bytes, bytes]] = [] headers_seen: set[bytes] = set() for name, value in _iter_headers(result.headers): @@ -532,7 +552,7 @@ def _is_sse_payload(value: object) -> bool: return isinstance(value, (EventStream, Iterator)) -def _stream_sse(ctx: HTTPReqCtx, source: object) -> None: +def _stream_sse(ctx: HTTPReqCtx, source: object, adapters: AdapterRegistry) -> None: """Run ``source`` as an SSE stream over ``ctx``. Sends the SSE response headers (chunked transfer encoding is implicit @@ -554,7 +574,7 @@ def _stream_sse(ctx: HTTPReqCtx, source: object) -> None: ctx.start_response(_Response(status_code=200, headers=list(_SSE_RESPONSE_HEADERS))) try: - for chunk in iter_events(source): + for chunk in iter_events(source, adapters): if cancel_supported: check_cancelled() ctx.send(chunk) diff --git a/localpost/openapi/pydantic.py b/localpost/openapi/pydantic.py deleted file mode 100644 index 2c061c4..0000000 --- a/localpost/openapi/pydantic.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Pydantic interop for :mod:`localpost.openapi`. - -Pydantic is **not** a runtime dependency of localpost. To use this module, -install pydantic yourself:: - - pip install pydantic - -Once available, :class:`localpost.openapi.HttpApp` recognises pydantic -models *automatically*: a parameter annotated as ``MyModel`` is parsed -from the request body via :meth:`BaseModel.model_validate_json`, and a -return value of type ``MyModel`` is serialised via -:meth:`BaseModel.model_dump_json`. The OpenAPI schema for the model is -sourced from :meth:`BaseModel.model_json_schema`. - -This module re-exports the parsing / serialising helpers for users who -want to call them explicitly (or build custom resolvers / response -converters on top). -""" - -from __future__ import annotations - -from typing import Any - -from localpost.openapi.schemas import _PydanticBaseModel, is_pydantic_model - -__all__ = ["is_pydantic_model", "decode_body", "encode_body"] - - -def decode_body(model: type[Any], body: bytes) -> Any: - """Validate ``body`` (raw JSON bytes) into an instance of ``model``. - - ``model`` must be a :class:`pydantic.BaseModel` subclass. - - Raises :exc:`pydantic.ValidationError` on invalid input. - """ - if _PydanticBaseModel is None: - raise RuntimeError("pydantic is not installed") - if not is_pydantic_model(model): - raise TypeError(f"Expected a pydantic BaseModel subclass, got {model!r}") - return getattr(model, "model_validate_json")(body) # noqa: B009 - - -def encode_body(value: Any) -> bytes: - """Encode a pydantic model instance to JSON bytes via ``model_dump_json``.""" - if _PydanticBaseModel is None: - raise RuntimeError("pydantic is not installed") - if not is_pydantic_model(type(value)): - raise TypeError(f"Expected a pydantic BaseModel instance, got {type(value)!r}") - return getattr(value, "model_dump_json")().encode("utf-8") # noqa: B009 diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py index 949ee99..ce189ba 100644 --- a/localpost/openapi/resolvers.py +++ b/localpost/openapi/resolvers.py @@ -23,8 +23,9 @@ from localpost.http.router import RouteMatch from localpost.openapi import spec as openapi_spec +from localpost.openapi.adapters import AdapterRegistry, default_registry from localpost.openapi.results import BadRequest, OpResult -from localpost.openapi.schemas import SchemaRegistry, is_pydantic_model +from localpost.openapi.schemas import SchemaRegistry if TYPE_CHECKING: from localpost.http import HTTPReqCtx @@ -32,10 +33,10 @@ __all__ = [ "ArgResolver", "ArgResolverFactory", + "FromBody", + "FromHeader", "FromPath", "FromQuery", - "FromHeader", - "FromBody", "is_body_type", ] @@ -113,7 +114,9 @@ def _cast_str(value: str, target: Any) -> Any: """Coerce a single string value into ``target``. Falls back to :func:`msgspec.convert` with ``strict=False`` for the - long tail (UUID, datetime, Decimal, Enum, …). + long tail (UUID, datetime, Decimal, Enum, …). URL-borne parameters + are always JSON-shaped strings, so msgspec is the right caster + regardless of which adapter would otherwise own ``target``. """ if target is str or target is Any or target is inspect.Parameter.empty: return value @@ -137,21 +140,18 @@ def _list_element_type(t: Any) -> Any | None: return None -def is_body_type(t: Any) -> bool: +def is_body_type(t: Any, adapters: AdapterRegistry | None = None) -> bool: """True if ``t`` looks like something we should parse from the request body. - Recognises :class:`msgspec.Struct` subclasses, dataclasses, ``TypedDict``, - ``NamedTuple``, and pydantic models. Primitives stay as query params. + Delegates to the matching :class:`TypeAdapter`'s ``is_body_type``; + msgspec recognises :class:`msgspec.Struct`, dataclasses, ``TypedDict``, + ``NamedTuple``; pydantic recognises :class:`pydantic.BaseModel`. + Primitives stay as query params. """ if not isinstance(t, type): return False - if issubclass(t, msgspec.Struct): - return True - if hasattr(t, "__dataclass_fields__"): - return True - if hasattr(t, "__annotations__") and hasattr(t, "_fields"): # NamedTuple - return True - return is_pydantic_model(t) + registry = adapters or default_registry() + return registry.for_type(t).is_body_type(t) def _route_match(ctx: HTTPReqCtx) -> RouteMatch: @@ -332,23 +332,46 @@ def update_doc( class FromBody: """Resolve a parameter from the request body. - Defaults to JSON parsing via :func:`msgspec.json.decode`. Raw ``bytes`` - / ``str`` annotations get the body verbatim. For pydantic models, uses - :meth:`BaseModel.model_validate_json`. + Decoding is delegated to the :class:`TypeAdapter` that claims the + parameter's type (msgspec for the catch-all, pydantic for + :class:`pydantic.BaseModel` subclasses, …). Raw ``bytes`` / ``str`` + annotations get the body verbatim. + + A custom ``converter`` (``(bytes, type) -> object``) bypasses the + adapter dispatch entirely. """ content_type: str = "application/json" description: str = "" converter: Callable[[bytes, Any], object] | None = None + adapters: AdapterRegistry | None = None def __call__(self, param: inspect.Parameter, /) -> ArgResolver: target = _unwrap_annotated(param.annotation) - converter = self.converter or _default_body_converter(target) + registry = self.adapters or default_registry() + validation_errors = registry.validation_errors + content_type = self.content_type + converter: Callable[[bytes, Any], object] + if self.converter is not None: + converter = self.converter + elif target is bytes: + converter = _bytes_passthrough + elif target is str: + converter = _str_decode + else: + adapter = registry.for_type(target) + + def _adapter_decode(body: bytes, _t: Any) -> object: + if not body: + raise ValueError("empty request body") + return adapter.decode(body, target, content_type=content_type) + + converter = _adapter_decode def resolve(ctx: HTTPReqCtx) -> object | OpResult: try: return converter(ctx.body, target) - except msgspec.ValidationError as exc: + except validation_errors as exc: return BadRequest(f"Invalid request body: {exc}") except (ValueError, TypeError) as exc: return BadRequest(f"Invalid request body: {exc}") @@ -372,31 +395,9 @@ def update_doc( return replace(op, request_body=request_body) -def _default_body_converter(target: Any) -> Callable[[bytes, Any], object]: - if target is bytes: - return _bytes_passthrough - if target is str: - return _str_decode - if is_pydantic_model(target): - return _pydantic_decode - return _msgspec_decode - - def _bytes_passthrough(body: bytes, _target: Any) -> object: return body def _str_decode(body: bytes, _target: Any) -> object: return body.decode("utf-8") - - -def _msgspec_decode(body: bytes, target: Any) -> object: - if not body: - raise ValueError("empty request body") - return msgspec.json.decode(body, type=target) - - -def _pydantic_decode(body: bytes, target: Any) -> object: - if not body: - raise ValueError("empty request body") - return getattr(target, "model_validate_json")(body) # noqa: B009 diff --git a/localpost/openapi/schemas.py b/localpost/openapi/schemas.py index ff249dd..1d717e6 100644 --- a/localpost/openapi/schemas.py +++ b/localpost/openapi/schemas.py @@ -1,14 +1,8 @@ -"""JSON Schema generation for OpenAPI 3.2 components. +"""JSON Schema accumulator for OpenAPI 3.2 components. -Backed by :func:`msgspec.json.schema_components`, which understands -:class:`msgspec.Struct`, dataclasses, ``TypedDict``, ``NamedTuple``, -primitives, collections, ``Annotated[T, msgspec.Meta(...)]``, ``Literal``, -``Enum``, ``Union``, ``UUID``, ``datetime`` and so on. The output is JSON -Schema 2020-12, which is the dialect OpenAPI 3.1 / 3.2 use natively. - -Pydantic models are recognised lazily and routed through -:meth:`pydantic.BaseModel.model_json_schema` — pydantic itself is *not* an -import dependency of this module. +Schema work is delegated to :class:`localpost.openapi.adapters.TypeAdapter` +implementations — see :mod:`localpost.openapi.adapters` for the protocol +and the built-in msgspec / pydantic bridges. """ from __future__ import annotations @@ -16,29 +10,14 @@ import threading from typing import Any -import msgspec - -try: - from pydantic import BaseModel # type: ignore[import-not-found] +from localpost.openapi.adapters import AdapterRegistry, TypeAdapter, default_registry - _PydanticBaseModel: type | None = BaseModel -except ImportError: - _PydanticBaseModel = None - -__all__ = ["SchemaRegistry", "REF_TEMPLATE", "is_pydantic_model"] +__all__ = ["REF_TEMPLATE", "SchemaRegistry"] REF_TEMPLATE = "#/components/schemas/{name}" -def is_pydantic_model(t: Any) -> bool: - """True if ``t`` is a subclass of :class:`pydantic.BaseModel`. - - Returns ``False`` if pydantic isn't installed. - """ - return _PydanticBaseModel is not None and isinstance(t, type) and issubclass(t, _PydanticBaseModel) - - class SchemaRegistry: """Accumulator for types referenced across operations. @@ -48,21 +27,28 @@ class SchemaRegistry: :meth:`components` to resolve the accumulated named types into ``components.schemas`` entries. - The registry is thread-safe: registration is guarded so concurrent doc - builds don't race on the cached components dict. + Schema/components production is delegated per type to the matching + :class:`TypeAdapter` from the supplied :class:`AdapterRegistry`. + + Thread-safe: registration is guarded so concurrent doc builds don't + race on the cached components dict. """ - __slots__ = ("_components", "_lock", "_msgspec_types", "_pydantic_types") + __slots__ = ("_adapters", "_components", "_lock", "_types_by_adapter") - def __init__(self) -> None: + def __init__(self, adapters: AdapterRegistry | None = None) -> None: self._lock = threading.Lock() - # Types we'll feed to msgspec.json.schema_components. We store a list - # (not a set) to preserve order — msgspec keys components by their - # name, but order matters for stable doc output. - self._msgspec_types: list[Any] = [] - self._pydantic_types: list[type] = [] + self._adapters = adapters or default_registry() + # We bucket types by the adapter that claims them. Insertion order + # is preserved (CPython dict semantics), so generated component + # output is stable across runs. + self._types_by_adapter: dict[TypeAdapter, list[Any]] = {} self._components: dict[str, dict[str, Any]] | None = None + @property + def adapters(self) -> AdapterRegistry: + return self._adapters + def schema_for(self, t: Any) -> dict[str, Any]: """Return a JSON Schema fragment describing ``t``. @@ -73,23 +59,13 @@ def schema_for(self, t: Any) -> dict[str, Any]: """ if t is None or t is type(None): return {"type": "null"} + adapter = self._adapters.for_type(t) with self._lock: self._components = None # invalidate - if is_pydantic_model(t): - if t not in self._pydantic_types: - self._pydantic_types.append(t) - return {"$ref": REF_TEMPLATE.format(name=t.__name__)} - self._msgspec_types.append(t) - # msgspec returns ``(schemas, components)`` — for a single type the - # ``schemas`` list has one element, which is either the inline schema - # or a ``$ref``. We discard the components dict here and re-derive - # everything in :meth:`components` so we have a single source of truth. - try: - (schema,), _ = msgspec.json.schema_components([t], ref_template=REF_TEMPLATE) - except (TypeError, RuntimeError): - # Fall back to a permissive schema for types msgspec can't introspect. - return {} - return schema + bucket = self._types_by_adapter.setdefault(adapter, []) + if t not in bucket: + bucket.append(t) + return adapter.schema(t, ref_template=REF_TEMPLATE) def components(self) -> dict[str, dict[str, Any]]: """Return the resolved ``components.schemas`` dict for every type @@ -101,26 +77,7 @@ def components(self) -> dict[str, dict[str, Any]]: if self._components is not None: return self._components schemas: dict[str, dict[str, Any]] = {} - if self._msgspec_types: - _, components = msgspec.json.schema_components(self._msgspec_types, ref_template=REF_TEMPLATE) - schemas.update(components) - for t in self._pydantic_types: - schemas[t.__name__] = _pydantic_json_schema(t) + for adapter, types in self._types_by_adapter.items(): + schemas.update(adapter.components(types, ref_template=REF_TEMPLATE)) self._components = schemas return schemas - - -def _pydantic_json_schema(model: type) -> dict[str, Any]: - """Extract a pydantic model's JSON Schema, stripped of pydantic-specific - framing (``$defs``, top-level ``title`` we'd duplicate). - - Pydantic's nested ``$defs`` are inlined under ``components.schemas`` — - callers should merge them in. v1 keeps the simpler path of treating each - pydantic model as a standalone component; deep cross-model refs may need - work later. - """ - # Pydantic uses ``{model}`` as the placeholder, not ``{name}`` like msgspec. - pydantic_ref_template = REF_TEMPLATE.replace("{name}", "{model}") - raw: dict[str, Any] = getattr(model, "model_json_schema")(ref_template=pydantic_ref_template) # noqa: B009 - raw.pop("$defs", None) - return raw diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py index 190eb04..80b26a3 100644 --- a/localpost/openapi/sse.py +++ b/localpost/openapi/sse.py @@ -13,17 +13,21 @@ from collections.abc import Iterable, Iterator from dataclasses import dataclass +from typing import TYPE_CHECKING import msgspec -__all__ = ["Event", "EventStream", "encode_event", "format_data_field"] +if TYPE_CHECKING: + from localpost.openapi.adapters import AdapterRegistry + +__all__ = ["Event", "EventStream", "encode_event", "format_data_field", "iter_events"] class Event[T](msgspec.Struct, eq=False, omit_defaults=True): """One SSE event. Args: - data: Payload. Encoded as JSON (msgspec) unless it's already a ``str``. + data: Payload. Encoded as JSON unless it's already a ``str``. event: Optional event name (``event:`` field). id: Optional last event id (``id:`` field). retry: Optional reconnection delay in milliseconds (``retry:`` field). @@ -60,24 +64,28 @@ def feed() -> EventStream[Update]: # --- Wire encoding ------------------------------------------------------- -def format_data_field(payload: object) -> str: +def format_data_field(payload: object, adapters: "AdapterRegistry | None" = None) -> str: """Encode an event's ``data`` value into the ``data:`` lines per the WHATWG SSE spec. A multi-line payload becomes one ``data:`` line per source line; a - string is sent as-is; everything else is JSON-encoded with - :func:`msgspec.json.encode`. + string is sent as-is; everything else is encoded via the matching + :class:`TypeAdapter` from ``adapters`` (default registry if ``None``). """ if isinstance(payload, str): text = payload elif isinstance(payload, (bytes, bytearray, memoryview)): text = bytes(payload).decode("utf-8") else: - text = msgspec.json.encode(payload).decode("utf-8") + from localpost.openapi.adapters import default_registry # noqa: PLC0415 + + registry = adapters or default_registry() + body, _ct = registry.for_value(payload).encode(payload) + text = body.decode("utf-8") return "\n".join(f"data: {line}" for line in text.split("\n")) -def encode_event(event: Event[object] | object) -> bytes: +def encode_event(event: Event[object] | object, adapters: "AdapterRegistry | None" = None) -> bytes: """Encode a single event (or bare payload) into SSE wire bytes. The output ends with the mandatory blank-line terminator (``\\n\\n``). @@ -92,12 +100,12 @@ def encode_event(event: Event[object] | object) -> bytes: lines.append(f"id: {event.id}") if event.retry is not None: lines.append(f"retry: {event.retry}") - lines.append(format_data_field(event.data)) + lines.append(format_data_field(event.data, adapters)) return ("\n".join(lines) + "\n\n").encode("utf-8") - return (format_data_field(event) + "\n\n").encode("utf-8") + return (format_data_field(event, adapters) + "\n\n").encode("utf-8") -def iter_events(source: object) -> Iterator[bytes]: +def iter_events(source: object, adapters: "AdapterRegistry | None" = None) -> Iterator[bytes]: """Drive ``source`` (a generator, iterator, or :class:`EventStream`) into a stream of SSE-encoded event bytes.""" if isinstance(source, EventStream): @@ -110,4 +118,4 @@ def iter_events(source: object) -> Iterator[bytes]: else: raise TypeError(f"SSE source must be iterable, got {type(source).__name__}") for item in iterator: - yield encode_event(item) + yield encode_event(item, adapters) diff --git a/tests/openapi/schemas.py b/tests/openapi/schemas.py index 1d7ac6a..eb40b3c 100644 --- a/tests/openapi/schemas.py +++ b/tests/openapi/schemas.py @@ -1,15 +1,18 @@ -"""Tests for the msgspec-backed JSON Schema registry.""" +"""Tests for the adapter-driven JSON Schema registry.""" from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from enum import Enum -from typing import Literal +from typing import Any, Literal import msgspec import pytest -from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry, is_pydantic_model +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.adapters._msgspec import MsgspecAdapter +from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry @dataclass @@ -99,20 +102,23 @@ def test_components_recomputed_after_new_registration(self): assert "Author" in components -class TestPydanticDetection: - def test_dataclass_is_not_pydantic(self): - assert is_pydantic_model(Book) is False +class TestAdapterDispatch: + def test_dataclass_routes_to_msgspec(self): + registry = default_registry() + assert registry.for_type(Book).name == "msgspec" - def test_msgspec_struct_is_not_pydantic(self): - assert is_pydantic_model(Author) is False + def test_msgspec_struct_routes_to_msgspec(self): + registry = default_registry() + assert registry.for_type(Author).name == "msgspec" - def test_pydantic_model_detected(self): + def test_pydantic_model_routes_to_pydantic(self): BaseModel = pytest.importorskip("pydantic").BaseModel class Pet(BaseModel): name: str - assert is_pydantic_model(Pet) is True + registry = default_registry() + assert registry.for_type(Pet).name == "pydantic" def test_pydantic_schema_registered_in_components(self): BaseModel = pytest.importorskip("pydantic").BaseModel @@ -128,3 +134,55 @@ class Pet(BaseModel): components = registry.components() assert "Pet" in components assert "name" in components["Pet"]["properties"] + + +class TestCustomAdapter: + """Plug a fake adapter ahead of msgspec to confirm dispatch is extensible.""" + + class _Marker: + """Sentinel type the fake adapter claims.""" + + class _FakeAdapter: + name = "fake" + validation_errors: tuple[type[Exception], ...] = () + + def __init__(self) -> None: + self.schema_calls: list[Any] = [] + self.components_calls: list[Sequence[Any]] = [] + + def claims(self, t: Any, /) -> bool: + return t is TestCustomAdapter._Marker + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + self.schema_calls.append(t) + return {"$ref": ref_template.format(name="Marker")} + + def components( + self, types: Sequence[Any], /, *, ref_template: str + ) -> dict[str, dict[str, Any]]: + self.components_calls.append(types) + return {"Marker": {"type": "object", "x-fake": True}} + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + raise NotImplementedError + + def encode(self, value: object, /) -> tuple[bytes, str]: + return b'"fake"', "application/json" + + def test_custom_adapter_wins_over_catch_all(self): + fake = self._FakeAdapter() + registry = AdapterRegistry([fake, MsgspecAdapter()]) + + schema_registry = SchemaRegistry(registry) + schema = schema_registry.schema_for(self._Marker) + assert schema == {"$ref": REF_TEMPLATE.format(name="Marker")} + assert fake.schema_calls == [self._Marker] + + components = schema_registry.components() + assert components["Marker"] == {"type": "object", "x-fake": True} + # And the catch-all is still active for non-claimed types. + schema_registry.schema_for(Book) + assert "Book" in schema_registry.components() From 4c8afebba6754b9eac147fda7260f612ef680ed8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 5 May 2026 08:41:31 +0400 Subject: [PATCH 180/286] feat(openapi)!: replace OpFilter with OpMiddleware (call_next semantics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters are gone. The new OpMiddleware protocol receives the request context plus a call_next callable that runs the rest of the chain, so middlewares can short-circuit, forward, or post-process the OpResult. ApiOperation = Callable[[HTTPReqCtx], OpResult] is now the honest operation type — no None sentinel, no "already written" branch. SSE returns are wrapped into a new EventStreamResult (an OpResult subclass) so the streaming path goes through the same middleware chain as everything else. The bare localpost.http.Response escape hatch is removed. HttpBearerAuth/HttpBasicAuth migrate to the new protocol; HttpApp / @app.get(...) gain `middlewares=` (replacing `filters=`). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/openapi/app.py | 34 ++- localpost/openapi/README.md | 86 ++++-- localpost/openapi/__init__.py | 11 +- localpost/openapi/app.py | 69 ++--- localpost/openapi/auth.py | 51 ++-- localpost/openapi/filter.py | 187 ------------ localpost/openapi/middleware.py | 245 +++++++++++++++ localpost/openapi/operation.py | 119 +++++--- localpost/openapi/results.py | 39 ++- tests/openapi/auth.py | 43 +-- tests/openapi/filter.py | 221 -------------- tests/openapi/middleware.py | 499 +++++++++++++++++++++++++++++++ tests/openapi/op_filter_tests.py | 232 -------------- 13 files changed, 1024 insertions(+), 812 deletions(-) delete mode 100644 localpost/openapi/filter.py create mode 100644 localpost/openapi/middleware.py delete mode 100644 tests/openapi/filter.py create mode 100644 tests/openapi/middleware.py delete mode 100644 tests/openapi/op_filter_tests.py diff --git a/examples/openapi/app.py b/examples/openapi/app.py index 06c3da6..98651f5 100644 --- a/examples/openapi/app.py +++ b/examples/openapi/app.py @@ -26,16 +26,18 @@ from typing import Annotated from localpost import hosting -from localpost.http import ServerConfig +from localpost.http import HTTPReqCtx, ServerConfig from localpost.openapi import ( + ApiOperation, BadRequest, Created, Event, FromHeader, HttpApp, + OpResult, TooManyRequests, Unauthorized, - op_filter, + op_middleware, spec, ) @@ -62,28 +64,33 @@ class BookPage: app = HttpApp(info=spec.Info(title="Library API", version="1.0.0")) -# A filter as a tiny operation: the framework reads the input +# A middleware as a tiny operation: the framework reads the input # (``X-API-Key`` header) and the failure response (``Unauthorized``) from # this signature and threads them into every operation that uses it. -@op_filter +@op_middleware def require_api_key( + ctx: HTTPReqCtx, + call_next: ApiOperation, x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", -) -> None | Unauthorized[str]: +) -> Unauthorized[str] | OpResult: if x_api_key != "secret": return Unauthorized("Invalid or missing X-API-Key") - return None + return call_next(ctx) -# Another tiny filter — every-N-requests rate limiter, just for the demo. +# Another tiny middleware — every-N-requests rate limiter, just for the demo. _call_counter = {"n": 0} -@op_filter -def rate_limit() -> None | TooManyRequests[str]: +@op_middleware +def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, +) -> TooManyRequests[str] | OpResult: _call_counter["n"] += 1 if _call_counter["n"] % 10 == 0: return TooManyRequests("Slow down") - return None + return call_next(ctx) @app.get("/hello/{name}") @@ -102,13 +109,14 @@ def get_book(book_id: str) -> Book | None: return book -@app.post("/books", filters=[require_api_key, rate_limit]) +@app.post("/books", middlewares=[require_api_key, rate_limit]) def create_book(book: Book) -> Created[Book]: """Add a new book to the library. Requires ``X-API-Key: secret``; subject to a (silly) every-10th-request - rate limit. Both filters' input headers and failure responses appear in - the OpenAPI doc automatically — no spec annotations needed here. + rate limit. Both middlewares' input headers and failure responses + appear in the OpenAPI doc automatically — no spec annotations needed + here. """ _LIBRARY[book.id] = book return Created(book, headers={"Location": f"/books/{book.id}"}) diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index aa091ce..5f4b675 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -10,7 +10,7 @@ Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of ``` Both branches end up in the OpenAPI doc with proper schemas; the runtime short-circuits to the right status code. -- **OpenAPI-aware middleware (filters).** Filters and auth contribute to the +- **OpenAPI-aware middleware.** Middlewares and auth contribute to the spec themselves (security schemes, extra parameters, extra response codes) via an `update_doc` hook. There's no second place to declare auth. - **msgspec-first.** [msgspec](https://jcristharif.com/msgspec/) does request @@ -113,6 +113,7 @@ failure → `BadRequest`). | `Created[T]` | 201 | | | `Accepted[T]` | 202 | | | `NoContent` | 204 | No body. | +| `EventStreamResult[T]` | 200 | SSE stream — see below. | | `BadRequest[T]` | 400 | | | `Unauthorized[T]` | 401 | | | `Forbidden[T]` | 403 | | @@ -123,71 +124,96 @@ failure → `BadRequest`). | `InternalServerError[T]` | 500 | | Use the *class* in return annotations (`Book | NotFound[str]`) so the OpenAPI -doc picks up the body type per status code. Returning a bare -`localpost.http.Response` is the escape hatch (no schema contribution). +doc picks up the body type per status code. -### `OpFilter` +### `OpMiddleware` -A filter is middleware that knows how to describe itself in the OpenAPI doc: +A middleware wraps the operation core. It receives the request context and +a `call_next` callable that runs the rest of the chain — and knows how to +describe itself in the OpenAPI doc: ```python -class OpFilter(Protocol): - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: ... +ApiOperation = Callable[[HTTPReqCtx], OpResult] + + +class OpMiddleware(Protocol): + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: ... def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: ... def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: ... ``` -- `__call__` runs before the resolvers; return an `OpResult` to short-circuit. +- `__call__` runs around the operation. Return `call_next(ctx)` to forward + (optionally post-processing the result), or short-circuit by returning + any other `OpResult`. - `contribute_root` is called once at spec-build time (typically to register a `SecurityScheme`). -- `contribute_operation` is called for each operation that the filter is +- `contribute_operation` is called for each operation that the middleware is attached to (typically to add a 401 response and a `security` requirement). Apply app-wide: ```python -app = HttpApp(filters=[my_filter]) +app = HttpApp(middlewares=[my_middleware]) ``` or per-operation: ```python -@app.get("/admin", filters=[my_filter]) +@app.get("/admin", middlewares=[my_middleware]) def admin() -> str: ... ``` -### `@op_filter` — filters as tiny operations +### `@op_middleware` — middlewares as tiny operations -For the common case, write a filter the same way you'd write a handler: -declare your inputs, declare your possible failure responses in the return -type, and let the framework do the OpenAPI bookkeeping: +For the common case, write a middleware the same way you'd write a handler: +declare your inputs (plus the `call_next` parameter), declare your possible +failure responses in the return type, and let the framework do the OpenAPI +bookkeeping: ```python from typing import Annotated -from localpost.openapi import FromHeader, TooManyRequests, op_filter +from localpost.http import HTTPReqCtx +from localpost.openapi import ( + ApiOperation, + FromHeader, + OpResult, + TooManyRequests, + op_middleware, +) -@op_filter +@op_middleware def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, x_client: Annotated[str, FromHeader("X-Client-Id")], -) -> None | TooManyRequests[str]: +) -> TooManyRequests[str] | OpResult: if quota_exhausted(x_client): return TooManyRequests("Slow down") - return None + return call_next(ctx) -@app.get("/expensive", filters=[rate_limit]) +@app.get("/expensive", middlewares=[rate_limit]) def expensive() -> str: ... ``` -Without writing any spec code, every operation that uses this filter gets: +Without writing any spec code, every operation that uses this middleware +gets: - the `X-Client-Id` header in its `parameters`, - `429 Too Many Requests` in its `responses` (with the body schema for `str`). -The filter's return type union *is* the OpenAPI contract. Filters must -return `None` or an `OpResult`; anything else raises `TypeError` at -request time. +The return-type union *is* the OpenAPI contract. Bare `OpResult` in the +union is the *passthrough* sentinel and contributes nothing; subclasses +contribute their response code. Middlewares must return an `OpResult` +(typically by calling `call_next(ctx)`); anything else raises `TypeError` +at request time. + +> **SSE caveat.** When the wrapped operation streams an SSE response, +> `call_next` returns an `EventStreamResult` whose body is an iterator. A +> middleware can wrap that iterator (transforming/dropping events) but +> can't post-process headers after streaming has started — they're sent +> before the first event. ### Server-Sent Events (SSE) @@ -230,18 +256,18 @@ without leaking workers — provided the app runs under For an iterator you've already constructed, wrap it in `EventStream(...)` to be explicit; otherwise just return the generator directly. -### Built-in auth filters +### Built-in auth middlewares ```python from localpost.openapi import HttpApp, HttpBearerAuth, HttpBasicAuth def validate_token(token: str) -> dict | None: # Return any truthy principal on success, None on failure. - # The principal is stashed on ctx.attrs[] for handlers to read. + # The principal is stashed on ctx.attrs[] for handlers to read. return decode_jwt(token) -app = HttpApp(filters=[HttpBearerAuth(validator=validate_token)]) +app = HttpApp(middlewares=[HttpBearerAuth(validator=validate_token)]) @app.get("/me") @@ -278,9 +304,9 @@ focused on user-facing concepts. | `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs | | `operation.py` | `Operation`: signature parsing, return-type inference, runtime closure | | `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories | -| `results.py` | `OpResult` hierarchy | -| `filter.py` | `OpFilter` protocol | -| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth filters | +| `results.py` | `OpResult` hierarchy (incl. `EventStreamResult`) | +| `middleware.py` | `OpMiddleware` protocol, `@op_middleware`, `ApiOperation` | +| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth middlewares | | `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events | | `spec.py` | OpenAPI 3.2 dataclasses | | `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index dab60a5..16f77f0 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -40,7 +40,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: from localpost.openapi.adapters import AdapterRegistry, TypeAdapter, default_registry from localpost.openapi.app import HttpApp from localpost.openapi.auth import HttpBasicAuth, HttpBearerAuth -from localpost.openapi.filter import OpFilter, op_filter +from localpost.openapi.middleware import ApiOperation, OpMiddleware, op_middleware from localpost.openapi.operation import Operation from localpost.openapi.resolvers import ( ArgResolver, @@ -55,6 +55,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: BadRequest, Conflict, Created, + EventStreamResult, Forbidden, InternalServerError, NoContent, @@ -74,9 +75,10 @@ def get_book(book_id: str) -> Book | NotFound[str]: "Operation", # spec sub-module (advanced; not all symbols flattened) "spec", - # filters - "OpFilter", - "op_filter", + # middleware + "ApiOperation", + "OpMiddleware", + "op_middleware", "HttpBearerAuth", "HttpBasicAuth", # arg resolvers @@ -92,6 +94,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: "Created", "Accepted", "NoContent", + "EventStreamResult", "BadRequest", "Unauthorized", "Forbidden", diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index e6b27e3..c7b01b0 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -40,7 +40,7 @@ def hello(name: str) -> str: from localpost.openapi import spec as openapi_spec from localpost.openapi._docs import redoc_html, scalar_html, swagger_html from localpost.openapi.adapters import AdapterRegistry, default_registry -from localpost.openapi.filter import OpFilter +from localpost.openapi.middleware import OpMiddleware from localpost.openapi.operation import Operation from localpost.openapi.schemas import SchemaRegistry @@ -56,10 +56,10 @@ class HttpApp: Args: info: Top-level OpenAPI :class:`Info` block. - filters: App-level :class:`OpFilter` s. Run before the per-operation - resolvers on every request, in order. Their - :meth:`OpFilter.contribute_root` is called once at spec build; - their :meth:`OpFilter.contribute_operation` is called for every + middlewares: App-level :class:`OpMiddleware` s. Wrap every operation, + outermost first. Their :meth:`OpMiddleware.contribute_root` is + called once at spec build; their + :meth:`OpMiddleware.contribute_operation` is called for every operation. max_concurrency: Worker-pool size used by :func:`thread_pool_handler`. Default 32. @@ -82,7 +82,7 @@ def __init__( self, *, info: openapi_spec.Info | None = None, - filters: Sequence[OpFilter] = (), + middlewares: Sequence[OpMiddleware] = (), max_concurrency: int = 32, backlog: int = 0, openapi_path: str | None = "/openapi.json", @@ -95,7 +95,7 @@ def __init__( if backlog < 0: raise ValueError("backlog must be >= 0") self._info = info or openapi_spec.Info() - self._filters = tuple(filters) + self._middlewares = tuple(middlewares) self._max_concurrency = max_concurrency self._backlog = backlog self._openapi_path = openapi_path @@ -110,27 +110,27 @@ def __init__( # ----- Decorators ----- - def get(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: - return self._decorator(HTTPMethod.GET, path, filters) + def get(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.GET, path, middlewares) - def post(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: - return self._decorator(HTTPMethod.POST, path, filters) + def post(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.POST, path, middlewares) - def put(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: - return self._decorator(HTTPMethod.PUT, path, filters) + def put(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PUT, path, middlewares) - def delete(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: - return self._decorator(HTTPMethod.DELETE, path, filters) + def delete(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.DELETE, path, middlewares) - def patch(self, path: str, *, filters: Sequence[OpFilter] = ()) -> _FluentDecorator: - return self._decorator(HTTPMethod.PATCH, path, filters) + def patch(self, path: str, *, middlewares: Sequence[OpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PATCH, path, middlewares) - def _decorator(self, method: HTTPMethod, path: str, op_filters: Sequence[OpFilter]) -> _FluentDecorator: - # App-level filters run first; per-op filters extend them. - combined = (*self._filters, *op_filters) + def _decorator(self, method: HTTPMethod, path: str, op_middlewares: Sequence[OpMiddleware]) -> _FluentDecorator: + # App-level middlewares wrap outermost; per-op middlewares are inside. + combined = (*self._middlewares, *op_middlewares) def deco(fn: Callable[..., Any]) -> Callable[..., Any]: - op = Operation.create(method, path, fn, filters=combined, adapters=self._adapters) + op = Operation.create(method, path, fn, middlewares=combined, adapters=self._adapters) with self._lock: self._operations.append(op) self._cached_spec = None @@ -154,34 +154,35 @@ def openapi_doc(self) -> openapi_spec.OpenAPI: return cached registry = SchemaRegistry(self._adapters) doc = openapi_spec.OpenAPI(info=self._info) - # Every filter (app-level and per-op) gets its contribute_root - # called exactly once. We dedupe by identity so the same filter - # attached to several ops registers its securityScheme just once. + # Every middleware (app-level and per-op) gets its + # contribute_root called exactly once. We dedupe by identity so + # the same middleware attached to several ops registers its + # securityScheme just once. seen: set[int] = set() - for f in self._all_filters(): - key = id(f) + for mw in self._all_middlewares(): + key = id(mw) if key in seen: continue seen.add(key) - doc = f.contribute_root(doc, registry) + doc = mw.contribute_root(doc, registry) for op in self._operations: spec_op = op.build_spec(registry) doc = doc.add_operation(op.path, op.method.value, spec_op) - # Merge in the schemas the registry collected. Filters may have - # already populated other Components fields above; preserve them - # by replacing only ``schemas``. + # Merge in the schemas the registry collected. Middlewares may + # have already populated other Components fields above; + # preserve them by replacing only ``schemas``. doc = doc.with_components( replace(doc.components, schemas={**doc.components.schemas, **registry.components()}) ) self._cached_spec = doc return doc - def _all_filters(self): - """Yield every filter that participates in the doc — app-level + def _all_middlewares(self): + """Yield every middleware that participates in the doc — app-level first, then per-op (in registration order).""" - yield from self._filters + yield from self._middlewares for op in self._operations: - yield from op.filters + yield from op.middlewares def _openapi_bytes(self) -> bytes: with self._lock: diff --git a/localpost/openapi/auth.py b/localpost/openapi/auth.py index 4f37bcc..8d0b8fc 100644 --- a/localpost/openapi/auth.py +++ b/localpost/openapi/auth.py @@ -1,23 +1,24 @@ -"""Concrete :class:`OpFilter` implementations for HTTP authentication. +"""Concrete :class:`OpMiddleware` implementations for HTTP authentication. -Both filters are thin wrappers around a :func:`@op_filter`-decorated -inner function that reads the ``Authorization`` header and validates it. -The OpenAPI parameter (``Authorization``) and the ``401`` response are -contributed *automatically* by ``@op_filter`` — only the +Both middlewares are thin wrappers around an +:func:`@op_middleware`-decorated inner function that reads the +``Authorization`` header and validates it. The OpenAPI parameter +(``Authorization``) and the ``401`` response are contributed +*automatically* by ``@op_middleware`` — only the :class:`SecurityScheme` registration at the root is custom code here. -On success the validated principal is stashed on ``ctx.attrs[]`` +On success the validated principal is stashed on ``ctx.attrs[]`` so user code can pull it via a tiny custom resolver — see the README for the pattern. The validator is a sync callable returning either the principal (any object) on success or ``None`` on rejection. Use either app-wide: - app = HttpApp(filters=[HttpBearerAuth(validate_token)]) + app = HttpApp(middlewares=[HttpBearerAuth(validate_token)]) or per-operation: - @app.get("/me", filters=[HttpBearerAuth(validate_token)]) + @app.get("/me", middlewares=[HttpBearerAuth(validate_token)]) def me(ctx: HTTPReqCtx) -> dict: ... """ @@ -31,7 +32,7 @@ def me(ctx: HTTPReqCtx) -> dict: ... from localpost.http import HTTPReqCtx from localpost.openapi import spec -from localpost.openapi.filter import _FunctionFilter, op_filter +from localpost.openapi.middleware import ApiOperation, _FunctionMiddleware, op_middleware from localpost.openapi.resolvers import FromHeader from localpost.openapi.results import OpResult, Unauthorized from localpost.openapi.schemas import SchemaRegistry @@ -54,7 +55,7 @@ def _add_security_requirement(op: spec.Operation, scheme_name: str, scopes: tupl @dataclass(slots=True, eq=False) class HttpBearerAuth: - """``Authorization: Bearer `` filter. + """``Authorization: Bearer `` middleware. Args: validator: ``token_str -> principal | None``. Called for every @@ -73,17 +74,18 @@ class HttpBearerAuth: bearer_format: str = "JWT" description: str = "" - _wrapped: _FunctionFilter = field(init=False, repr=False) + _wrapped: _FunctionMiddleware = field(init=False, repr=False) def __post_init__(self) -> None: validator = self.validator principal_key = self # stable identity across requests - @op_filter + @op_middleware def _bearer( ctx: HTTPReqCtx, + call_next: ApiOperation, authorization: Annotated[str, FromHeader("Authorization")] = "", - ) -> None | Unauthorized[str]: + ) -> Unauthorized[str] | OpResult: # Default ``""`` makes the header optional at the resolver level so # absence is reported as 401 (with the spec-aware Unauthorized) rather # than a generic 400 from FromHeader's "missing required" branch. @@ -93,12 +95,12 @@ def _bearer( if principal is None: return Unauthorized("Invalid token") ctx.attrs[principal_key] = principal - return None + return call_next(ctx) self._wrapped = _bearer - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - return self._wrapped(ctx) + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + return self._wrapped(ctx, call_next) def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: scheme = spec.SecurityScheme( @@ -110,7 +112,7 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return _add_security_scheme(doc, self.scheme_name, scheme) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - # ``@op_filter`` already adds the Authorization header parameter + # ``@op_middleware`` already adds the Authorization header parameter # and the 401 response from the wrapped function's signature. # We only add the security requirement on top. op = self._wrapped.contribute_operation(op, registry) @@ -119,7 +121,7 @@ def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) @dataclass(slots=True, eq=False) class HttpBasicAuth: - """``Authorization: Basic `` filter. + """``Authorization: Basic `` middleware. Args: validator: ``(username, password) -> principal | None``. Called @@ -138,18 +140,19 @@ class HttpBasicAuth: realm: str = "localpost" description: str = "" - _wrapped: _FunctionFilter = field(init=False, repr=False) + _wrapped: _FunctionMiddleware = field(init=False, repr=False) def __post_init__(self) -> None: validator = self.validator challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} principal_key = self - @op_filter + @op_middleware def _basic( ctx: HTTPReqCtx, + call_next: ApiOperation, authorization: Annotated[str, FromHeader("Authorization")] = "", - ) -> None | Unauthorized[str]: + ) -> Unauthorized[str] | OpResult: if not authorization.startswith("Basic "): return Unauthorized("Missing or malformed Authorization header", headers=challenge) try: @@ -163,12 +166,12 @@ def _basic( if principal is None: return Unauthorized("Invalid credentials", headers=challenge) ctx.attrs[principal_key] = principal - return None + return call_next(ctx) self._wrapped = _basic - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - return self._wrapped(ctx) + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + return self._wrapped(ctx, call_next) def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: scheme = spec.SecurityScheme(type="http", scheme="basic", description=self.description) diff --git a/localpost/openapi/filter.py b/localpost/openapi/filter.py deleted file mode 100644 index cc3c9dc..0000000 --- a/localpost/openapi/filter.py +++ /dev/null @@ -1,187 +0,0 @@ -"""``OpFilter`` — middleware that also describes itself in the OpenAPI doc. - -A filter is a callable that runs before the operation handler. It can -short-circuit the request by returning an :class:`OpResult` (e.g. a -``Unauthorized`` from auth) or return ``None`` to let the operation -proceed. - -The OpenAPI improvement vs. FastAPI: the *same* object that runs at -request time also describes its contribution to the spec — security -schemes at the root, additional response codes / security requirements -on each operation. There's no second place to declare auth. - -Two contribution hooks: - -- :meth:`OpFilter.contribute_root` — called once at spec build time. The - typical use is to register a :class:`SecurityScheme` under - ``components.securitySchemes``. -- :meth:`OpFilter.contribute_operation` — called per operation that has - this filter attached (app-level filters are attached to *every* op). - The typical use is to add a 401 / 403 response and a ``security`` - requirement. - -Filters typically override one or both of these; both have a default -implementation that returns the input unchanged so a filter that only -needs runtime behaviour can ignore the doc side. - -For the common case — a filter built like a tiny operation, with -arg resolvers and a typed return — use :func:`op_filter`. It does the -signature inspection for you and the OpenAPI contribution comes from -the same annotations that run the resolvers:: - - from localpost.openapi import FromHeader, Unauthorized, op_filter - - - @op_filter - def require_token( - authorization: Annotated[str, FromHeader("Authorization")], - ) -> None | Unauthorized[str]: - if not authorization.startswith("Bearer "): - return Unauthorized("Missing or malformed Authorization header") - if not validate(authorization[7:]): - return Unauthorized("Invalid token") - return None - - - @app.get("/me", filters=[require_token]) - def me() -> User: ... -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable - -if TYPE_CHECKING: - from localpost.http import HTTPReqCtx - from localpost.openapi import spec - from localpost.openapi.results import OpResult - from localpost.openapi.schemas import SchemaRegistry - -__all__ = ["OpFilter", "op_filter"] - - -@runtime_checkable -class OpFilter(Protocol): - """Pre-handler middleware that knows how to describe itself in OpenAPI.""" - - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - """Run pre-handler. Return :class:`OpResult` to short-circuit, or - ``None`` to let the operation proceed.""" - ... - - def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: - """App-level OpenAPI contribution. - - Called once when the spec is built. Typical use: register a - ``SecurityScheme`` under ``components.securitySchemes``. Default: - return ``doc`` unchanged. - """ - ... - - def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - """Per-operation OpenAPI contribution. - - Called once per operation that this filter is attached to (every - operation for app-level filters). Typical use: add a ``401`` / - ``403`` response and a ``security`` requirement. Default: return - ``op`` unchanged. - """ - ... - - -@dataclass(eq=False, slots=True) -class _FunctionFilter: - """Concrete :class:`OpFilter` produced by :func:`op_filter`. - - Inspects the wrapped function's signature once: arg resolvers come - from the parameters (just like an :class:`Operation`), and the - declared response codes come from the return-type union. The OpenAPI - contribution mirrors what an operation with the same signature would - emit. - """ - - target: Callable[..., Any] - arg_resolvers: tuple[tuple[str, Any], ...] - arg_factories: tuple[tuple[str, Any, Any | None], ...] - return_shapes: tuple[Any, ...] - - # Optional root-level contribution (set by ``with_root``); auth filters - # use this to register their securityScheme. Defaults to a no-op. - root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) - - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - from localpost.openapi.results import OpResult as _OpResult # noqa: PLC0415 - - kwargs: dict[str, object] = {} - for name, resolver in self.arg_resolvers: - value = resolver(ctx) - if isinstance(value, _OpResult): - return value - kwargs[name] = value - result = self.target(**kwargs) - if result is None or isinstance(result, _OpResult): - return result - name = getattr(self.target, "__qualname__", None) or repr(self.target) - raise TypeError( - f"@op_filter {name!r} returned {type(result).__name__}; filters must return None or an OpResult" - ) - - def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: - if self.root_contribution is None: - return doc - return self.root_contribution(doc, registry) - - def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - from localpost.openapi.operation import _build_responses # noqa: PLC0415 - - # Parameters / requestBody come from the resolver factories. - for _name, param, factory in self.arg_factories: - if factory is None: - continue - op = factory.update_doc(param, op, registry) - # Response codes come from the return-type union. Don't overwrite - # codes the operation already declared (the op's own response is - # more specific than the filter's generic one). - for code, response in _build_responses(self.return_shapes, registry).items(): - if code in op.responses: - continue - op = replace(op, responses={**op.responses, code: response}) - return op - - def with_root(self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI]) -> _FunctionFilter: - """Return a copy that also contributes ``contribution`` at the root. - - Used by auth filters to register their ``SecurityScheme`` while - still inheriting the parameter / response contribution from the - wrapped function's signature. - """ - return replace(self, root_contribution=contribution) - - -def op_filter(fn: Callable[..., Any]) -> _FunctionFilter: - """Wrap ``fn`` as an :class:`OpFilter`. - - The wrapped function looks like an operation handler: its parameters - are resolved from the request (via :class:`FromHeader`, - :class:`FromQuery`, :class:`FromBody`, …) and its return must be - ``None`` (let the operation proceed) or an :class:`OpResult` (short- - circuit with that response). - - The OpenAPI contribution is inferred from the same annotations: the - declared parameters land on every operation that uses this filter, - and the response codes from the return-type union are added to those - operations' ``responses`` (without overwriting codes the operation - already declared). - """ - from localpost.openapi.operation import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 - - sig, runtime, factories = build_arg_resolvers(fn) - shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) - return _FunctionFilter( - target=fn, - arg_resolvers=tuple(runtime), - arg_factories=tuple(factories), - return_shapes=tuple(shapes_list), - ) diff --git a/localpost/openapi/middleware.py b/localpost/openapi/middleware.py new file mode 100644 index 0000000..d447bfc --- /dev/null +++ b/localpost/openapi/middleware.py @@ -0,0 +1,245 @@ +"""``OpMiddleware`` — middleware that also describes itself in the OpenAPI doc. + +A middleware wraps an :class:`~localpost.openapi.operation.Operation`'s +core. It receives the request context plus a ``call_next`` callable that +runs the rest of the chain (next middleware, then the operation itself). +A middleware can: + +- Short-circuit by returning an :class:`OpResult` without invoking + ``call_next`` — the typical auth pattern. +- Forward to the chain (``return call_next(ctx)``) and return its result + unchanged — pure observers / metrics. +- Forward, then post-process the :class:`OpResult` — add headers, swap + the body, wrap an SSE stream, etc. + +The OpenAPI improvement vs. plain wrap-and-forget middleware: the *same* +object that runs at request time also describes its contribution to the +spec — security schemes at the root, additional response codes / security +requirements / parameters on each operation. There's no second place to +declare auth. + +Two contribution hooks: + +- :meth:`OpMiddleware.contribute_root` — called once at spec build time. + Typical use: register a :class:`SecurityScheme` under + ``components.securitySchemes``. +- :meth:`OpMiddleware.contribute_operation` — called per operation that + has this middleware attached (app-level middlewares are attached to + *every* op). Typical use: add a 401 / 403 response, a ``security`` + requirement, or an extra header parameter. + +For the common case — a middleware built like a tiny operation, with arg +resolvers and a typed return — use :func:`op_middleware`. It does the +signature inspection for you and the OpenAPI contribution comes from the +same annotations that run the resolvers:: + + from localpost.openapi import FromHeader, Unauthorized, op_middleware + + + @op_middleware + def require_token( + ctx: HTTPReqCtx, + call_next: ApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + if not validate(authorization[7:]): + return Unauthorized("Invalid token") + return call_next(ctx) + + + @app.get("/me", middlewares=[require_token]) + def me() -> User: ... + +Bare ``OpResult`` in the return-type union is the *passthrough* sentinel: +it represents the wrapped operation's own result and contributes nothing +to the spec. Subclasses (``Unauthorized[str]`` etc.) add their response +codes to every operation that uses the middleware. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from localpost.http import HTTPReqCtx + from localpost.openapi import spec + from localpost.openapi.results import OpResult + from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["ApiOperation", "OpMiddleware", "op_middleware"] + + +type ApiOperation = Callable[["HTTPReqCtx"], "OpResult"] +"""The shape of an operation as seen by middleware. + +A middleware's ``call_next`` is an :class:`ApiOperation` — given the +request context, it returns an :class:`OpResult` (which may be an +:class:`~localpost.openapi.results.EventStreamResult` for SSE handlers). +""" + + +@runtime_checkable +class OpMiddleware(Protocol): + """Operation middleware that knows how to describe itself in OpenAPI.""" + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + """Run around the operation. Return an :class:`OpResult` — + typically by calling ``call_next(ctx)`` and forwarding (or + post-processing) its result, or by short-circuiting with a + middleware-specific result (e.g. ``Unauthorized``).""" + ... + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + """App-level OpenAPI contribution. + + Called once when the spec is built. Typical use: register a + ``SecurityScheme`` under ``components.securitySchemes``. Default: + return ``doc`` unchanged. + """ + ... + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + """Per-operation OpenAPI contribution. + + Called once per operation that this middleware is attached to + (every operation for app-level middlewares). Typical use: add a + ``401`` / ``403`` response and a ``security`` requirement. + Default: return ``op`` unchanged. + """ + ... + + +@dataclass(eq=False, slots=True) +class _FunctionMiddleware: + """Concrete :class:`OpMiddleware` produced by :func:`op_middleware`. + + Inspects the wrapped function's signature once: arg resolvers come + from the parameters (just like an :class:`Operation`), the + ``call_next`` parameter is identified and threaded through at request + time, and the declared response codes come from the return-type + union. The OpenAPI contribution mirrors what an operation with the + same signature would emit. + """ + + target: Callable[..., Any] + call_next_param: str + arg_resolvers: tuple[tuple[str, Any], ...] + arg_factories: tuple[tuple[str, Any, Any | None], ...] + return_shapes: tuple[Any, ...] + + # Optional root-level contribution (set by ``with_root``); auth + # middlewares use this to register their securityScheme. Defaults to + # a no-op. + root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + from localpost.openapi.results import OpResult as _OpResult # noqa: PLC0415 + + kwargs: dict[str, object] = {self.call_next_param: call_next} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, _OpResult): + return value + kwargs[name] = value + result = self.target(**kwargs) + if isinstance(result, _OpResult): + return result + name = getattr(self.target, "__qualname__", None) or repr(self.target) + raise TypeError( + f"@op_middleware {name!r} returned {type(result).__name__}; " + f"middlewares must return an OpResult (typically by calling call_next)" + ) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + if self.root_contribution is None: + return doc + return self.root_contribution(doc, registry) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + from localpost.openapi.operation import _build_responses # noqa: PLC0415 + + # Parameters / requestBody come from the resolver factories. + for _name, param, factory in self.arg_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + # Response codes come from the return-type union (sans the bare + # ``OpResult`` passthrough sentinel). Don't overwrite codes the + # operation already declared (the op's own response is more + # specific than the middleware's generic one). + for code, response in _build_responses(self.return_shapes, registry).items(): + if code in op.responses: + continue + op = replace(op, responses={**op.responses, code: response}) + return op + + def with_root( + self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] + ) -> _FunctionMiddleware: + """Return a copy that also contributes ``contribution`` at the root. + + Used by auth middlewares to register their ``SecurityScheme`` + while still inheriting the parameter / response contribution from + the wrapped function's signature. + """ + return replace(self, root_contribution=contribution) + + +def op_middleware(fn: Callable[..., Any]) -> _FunctionMiddleware: + """Wrap ``fn`` as an :class:`OpMiddleware`. + + The wrapped function looks like an operation handler with one extra + parameter — the ``call_next`` callable — which the framework passes + in to let the middleware invoke the rest of the chain. Other + parameters are resolved from the request (via :class:`FromHeader`, + :class:`FromQuery`, :class:`FromBody`, …); the function must return + an :class:`OpResult` — typically by ``return call_next(ctx)`` for + the passthrough path, or a short-circuit result like + ``Unauthorized(...)``. + + The ``call_next`` parameter is identified by name (``call_next``) or + by annotation as :class:`ApiOperation` / + ``Callable[[HTTPReqCtx], OpResult]``. + + The OpenAPI contribution is inferred from the same annotations: the + declared parameters land on every operation that uses this + middleware, and the response codes from the return-type union are + added to those operations' ``responses`` (without overwriting codes + the operation already declared). A bare ``OpResult`` member of the + return union is the passthrough sentinel and contributes nothing. + """ + from localpost.openapi.operation import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + + call_next_param = _identify_call_next_param(fn) + sig, runtime, factories = build_arg_resolvers(fn, exclude={call_next_param}) + shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) + return _FunctionMiddleware( + target=fn, + call_next_param=call_next_param, + arg_resolvers=tuple(runtime), + arg_factories=tuple(factories), + return_shapes=tuple(shapes_list), + ) + + +def _identify_call_next_param(fn: Callable[..., Any]) -> str: + """Pick the ``call_next`` parameter on ``fn``. + + Identification is by name (``call_next``); the parameter must exist + and may be annotated as :class:`ApiOperation` / + ``Callable[[HTTPReqCtx], OpResult]``. The annotation is informational + — we don't require it. + """ + sig = inspect.signature(fn) + if "call_next" in sig.parameters: + return "call_next" + name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + raise ValueError( + f"@op_middleware {name!r}: missing required 'call_next' parameter " + f"(of type ApiOperation = Callable[[HTTPReqCtx], OpResult])" + ) diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index f70ae28..9f328dc 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -1,5 +1,5 @@ """Per-operation wiring: signature parsing, return-type inference, the -``(ctx) -> Response`` closure that runs at request time. +``(ctx) -> OpResult`` closure that runs at request time. An :class:`Operation` is built once at decoration time and used in two places: its :meth:`as_handler` populates the :class:`localpost.http.Routes` @@ -28,7 +28,7 @@ from localpost.http.router import URITemplate from localpost.openapi import spec as openapi_spec from localpost.openapi.adapters import AdapterRegistry, default_registry -from localpost.openapi.filter import OpFilter +from localpost.openapi.middleware import ApiOperation, OpMiddleware from localpost.openapi.resolvers import ( ArgResolver, ArgResolverFactory, @@ -37,7 +37,7 @@ FromQuery, is_body_type, ) -from localpost.openapi.results import NoContent, NotFound, Ok, OpResult +from localpost.openapi.results import EventStreamResult, NoContent, NotFound, Ok, OpResult from localpost.openapi.schemas import SchemaRegistry from localpost.openapi.sse import EventStream, iter_events @@ -71,9 +71,11 @@ class Operation: ``None`` for the ``HTTPReqCtx`` pass-through, which contributes nothing to the OpenAPI doc.""" - filters: tuple[OpFilter, ...] - """Filters to run before the resolvers. Includes both app-level filters - (injected by :class:`HttpApp`) and per-operation filters.""" + middlewares: tuple[OpMiddleware, ...] + """Middlewares wrapping the operation core. Includes both app-level + middlewares (injected by :class:`HttpApp`) and per-operation middlewares. + Outermost first; the chain is built in :meth:`_run` so each middleware's + ``call_next`` invokes the next one.""" return_shapes: tuple[ResponseShape, ...] null_is_not_found: bool @@ -96,7 +98,7 @@ def create( fn: Callable[..., Any], /, *, - filters: tuple[OpFilter, ...] = (), + middlewares: tuple[OpMiddleware, ...] = (), adapters: AdapterRegistry | None = None, ) -> Self: template = URITemplate.parse(path) @@ -143,7 +145,7 @@ def create( target=fn, arg_resolvers=tuple(arg_resolvers), arg_resolver_factories=tuple(arg_factories), - filters=filters, + middlewares=middlewares, return_shapes=return_shapes, null_is_not_found=null_is_not_found, summary=summary, @@ -160,8 +162,8 @@ def as_handler(self) -> RequestHandler: The pre-body phase returns a ``BodyHandler`` so that :func:`localpost.http.thread_pool_handler` offloads execution to a worker after the request body is buffered. The body handler runs the - full operation pipeline: arg resolvers → user fn → response build → - ``ctx.complete``. + full operation pipeline: middleware chain → arg resolvers → user fn → + response build → ``ctx.complete``. """ run = self._run @@ -171,36 +173,38 @@ def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: return pre_body def _run(self, ctx: HTTPReqCtx) -> None: - registry = self.adapters - for f in self.filters: - short_circuit = f(ctx) - if short_circuit is not None: - response, body = _build_http_response(short_circuit, registry) - ctx.complete(response, body) - return + chain: ApiOperation = self._run_core + for mw in reversed(self.middlewares): + chain = _wrap_middleware(mw, chain) + result = chain(ctx) + self._write_response(ctx, result) + + def _run_core(self, ctx: HTTPReqCtx) -> OpResult: + """Resolve args, invoke the target, normalise the return value to + an :class:`OpResult`. SSE-shaped returns (generator, iterator, + :class:`EventStream`) are wrapped in :class:`EventStreamResult` + so middleware can post-process / wrap the stream uniformly.""" kwargs: dict[str, object] = {} for name, resolver in self.arg_resolvers: value = resolver(ctx) if isinstance(value, OpResult): - response, body = _build_http_response(value, registry) - ctx.complete(response, body) - return + return value kwargs[name] = value result = self.target(**kwargs) - if isinstance(result, _Response): - ctx.complete(result, b"") - return - if _is_sse_payload(result): - _stream_sse(ctx, result, registry) - return if isinstance(result, OpResult): - op_result: OpResult = result - elif result is None and self.null_is_not_found: + return result + if _is_sse_payload(result): + return EventStreamResult(result) + if result is None and self.null_is_not_found: # ``T | None`` returning None → implicit 404. - op_result = NotFound(None) - else: - op_result = Ok(result) - response, body = _build_http_response(op_result, registry) + return NotFound(None) + return Ok(result) + + def _write_response(self, ctx: HTTPReqCtx, result: OpResult) -> None: + if isinstance(result, EventStreamResult): + _stream_sse(ctx, result, self.adapters) + return + response, body = _build_http_response(result, self.adapters) ctx.complete(response, body) # ----- spec build ----- @@ -217,8 +221,8 @@ def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: op = factory.update_doc(param, op, registry) responses = _build_responses(self.return_shapes, registry) op = replace(op, responses=responses) - for f in self.filters: - op = f.contribute_operation(op, registry) + for mw in self.middlewares: + op = mw.contribute_operation(op, registry) return op @@ -234,13 +238,14 @@ def build_arg_resolvers( *, path_var_names: set[str] | None = None, adapters: AdapterRegistry | None = None, + exclude: set[str] | None = None, ) -> tuple[ inspect.Signature, list[tuple[str, ArgResolver]], list[tuple[str, inspect.Parameter, ArgResolverFactory | None]], ]: """Inspect ``fn`` and return ``(resolved_signature, runtime_resolvers, - spec_factories)`` for use by :class:`Operation` or :func:`op_filter`. + spec_factories)`` for use by :class:`Operation` or :func:`op_middleware`. Path-template binding is *not* validated here — pass ``path_var_names`` so :class:`FromPath` is auto-picked for matching parameter names; the @@ -249,10 +254,14 @@ def build_arg_resolvers( ``adapters`` flows into auto-picked / un-bound :class:`FromBody` factories so body decoding hits the per-app type adapter registry. If ``None``, falls back to :func:`default_registry` (used by - :func:`op_filter`, which is built outside any app). + :func:`op_middleware`, which is built outside any app). + + ``exclude`` skips parameters by name — used by :func:`op_middleware` + to drop the ``call_next`` parameter from resolver inspection. """ path_vars = path_var_names or set() registry = adapters or default_registry() + skip = exclude or set() # Resolve PEP 563 string annotations (``from __future__ import # annotations`` is in effect for most callers) into the real types @@ -273,6 +282,8 @@ def build_arg_resolvers( runtime: list[tuple[str, ArgResolver]] = [] factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] for name, param in sig.parameters.items(): + if name in skip: + continue if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): raise ValueError(f"handler {_qualname(fn)!r}: *args / **kwargs not supported") factory = _pick_factory(name, param, path_vars, registry) @@ -411,6 +422,13 @@ def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape] body_type = sse_payload content_type = "text/event-stream" has_success = True + elif isinstance(cls, type) and issubclass(cls, EventStreamResult): + code = cls._status_code + description = cls._description + generic_args = get_args(member) if member_origin is not None else () + body_type = generic_args[0] if generic_args else None + content_type = "text/event-stream" + has_success = True elif isinstance(cls, type) and issubclass(cls, OpResult): code = cls._status_code description = cls._description @@ -421,10 +439,6 @@ def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape] body_type = generic_args[0] if generic_args else None if code < 400: has_success = True - elif isinstance(cls, type) and issubclass(cls, _Response): - # Bare ``Response`` escape hatch — emit nothing for this branch. - has_success = True - continue else: code = 200 description = "Successful response" @@ -552,13 +566,23 @@ def _is_sse_payload(value: object) -> bool: return isinstance(value, (EventStream, Iterator)) -def _stream_sse(ctx: HTTPReqCtx, source: object, adapters: AdapterRegistry) -> None: - """Run ``source`` as an SSE stream over ``ctx``. +def _wrap_middleware(mw: OpMiddleware, call_next: ApiOperation) -> ApiOperation: + def wrapped(ctx: HTTPReqCtx) -> OpResult: + return mw(ctx, call_next) + + return wrapped + + +def _stream_sse(ctx: HTTPReqCtx, result: EventStreamResult[Any], adapters: AdapterRegistry) -> None: + """Run ``result``'s body as an SSE stream over ``ctx``. Sends the SSE response headers (chunked transfer encoding is implicit — we don't set ``content-length`` so h11 picks chunked), then writes one event per yielded item until the iterator exhausts or the client disconnects (signalled via :func:`localpost.http.check_cancelled`). + Any user-set headers on ``result`` are merged in (without overriding + the framework-set ``content-type`` / ``cache-control`` / + ``x-accel-buffering``). """ # ``check_cancelled`` only works when a request context is active — # i.e. when the handler runs under the worker pool. When the SSE op @@ -572,9 +596,16 @@ def _stream_sse(ctx: HTTPReqCtx, source: object, adapters: AdapterRegistry) -> N except RequestCancelled: return - ctx.start_response(_Response(status_code=200, headers=list(_SSE_RESPONSE_HEADERS))) + headers = list(_SSE_RESPONSE_HEADERS) + seen = {name for name, _ in headers} + for name, value in _iter_headers(result.headers): + if name.lower() in seen: + continue + headers.append((name, value)) + + ctx.start_response(_Response(status_code=result.status_code, headers=headers)) try: - for chunk in iter_events(source, adapters): + for chunk in iter_events(result.body, adapters): if cancel_supported: check_cancelled() ctx.send(chunk) diff --git a/localpost/openapi/results.py b/localpost/openapi/results.py index 3316bc1..684a1d2 100644 --- a/localpost/openapi/results.py +++ b/localpost/openapi/results.py @@ -15,10 +15,13 @@ def get_book(book_id: str) -> Book | NotFound[str]: ... from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterable, Iterator, Mapping from dataclasses import dataclass, field from types import MappingProxyType -from typing import ClassVar +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from localpost.openapi.sse import Event, EventStream __all__ = [ "OpResult", @@ -26,6 +29,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: ... "Created", "Accepted", "NoContent", + "EventStreamResult", "BadRequest", "Unauthorized", "Forbidden", @@ -123,6 +127,37 @@ def __init__(self, *, headers: Mapping[str, str] | None = None) -> None: object.__setattr__(self, "headers", _normalize_headers(headers)) +@dataclass(frozen=True, eq=False, slots=True, init=False) +class EventStreamResult[T](OpResult): + """Server-Sent Events response. + + Body is the stream source — an :class:`~localpost.openapi.sse.EventStream`, + a generator/iterator of payloads, or an iterable of + :class:`~localpost.openapi.sse.Event` instances. The response is sent + with ``Content-Type: text/event-stream`` and chunked transfer encoding; + one wire event per yielded item. + + Returning a generator/iterator/``EventStream`` directly from an operation + is auto-promoted to ``EventStreamResult`` — you only construct one + explicitly to attach extra response headers. + """ + + body: EventStream[T] | Iterator[T] | Iterator[Event[T]] | Iterable[T] | Iterable[Event[T]] + + _status_code: ClassVar[int] = 200 + _description: ClassVar[str] = "Successful response" + + def __init__( + self, + body: Any, + /, + *, + headers: Mapping[str, str] | None = None, + ) -> None: + object.__setattr__(self, "body", body) + object.__setattr__(self, "headers", _normalize_headers(headers)) + + # --- 4xx ----------------------------------------------------------------- diff --git a/tests/openapi/auth.py b/tests/openapi/auth.py index bdac142..9c94693 100644 --- a/tests/openapi/auth.py +++ b/tests/openapi/auth.py @@ -18,7 +18,7 @@ def _basic_header(user: str, password: str) -> bytes: class TestHttpBearerAuthRuntime: def test_valid_token_passes(self): bearer = HttpBearerAuth(validator=lambda t: {"sub": t} if t == "good" else None) - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -33,7 +33,7 @@ def me() -> str: def test_invalid_token_returns_401(self): bearer = HttpBearerAuth(validator=lambda _t: None) - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -47,7 +47,7 @@ def me() -> str: def test_missing_header_returns_401(self): bearer = HttpBearerAuth(validator=lambda _t: True) - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -60,7 +60,7 @@ def me() -> str: def test_non_bearer_scheme_returns_401(self): bearer = HttpBearerAuth(validator=lambda _t: True) - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -75,7 +75,7 @@ def me() -> str: class TestHttpBearerAuthSpec: def test_security_scheme_in_components(self): bearer = HttpBearerAuth(validator=lambda _t: True, bearer_format="opaque") - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -90,7 +90,7 @@ def me() -> str: def test_operation_gets_security_and_401(self): bearer = HttpBearerAuth(validator=lambda _t: True) - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -98,8 +98,9 @@ def me() -> str: op = app.openapi_doc.to_dict()["paths"]["/me"]["get"] assert op["security"] == [{"bearerAuth": []}] - # 401 response is contributed by the @op_filter wrapper from the - # filter's `Unauthorized[str]` return annotation — body schema included. + # 401 response is contributed by the @op_middleware wrapper from + # the middleware's `Unauthorized[str]` return annotation — body + # schema included. assert op["responses"]["401"]["description"] == "Unauthorized" assert op["responses"]["401"]["content"]["application/json"]["schema"] == {"type": "string"} @@ -107,7 +108,7 @@ def test_per_op_does_not_protect_other_ops(self): bearer = HttpBearerAuth(validator=lambda _t: True) app = HttpApp() - @app.get("/protected", filters=[bearer]) + @app.get("/protected", middlewares=[bearer]) def protected() -> str: return "x" @@ -123,7 +124,7 @@ def public() -> str: def test_custom_scheme_name(self): bearer = HttpBearerAuth(validator=lambda _t: True, scheme_name="apiToken") - app = HttpApp(filters=[bearer]) + app = HttpApp(middlewares=[bearer]) @app.get("/me") def me() -> str: @@ -140,7 +141,7 @@ def me() -> str: class TestHttpBasicAuthRuntime: def test_valid_credentials_pass(self): basic = HttpBasicAuth(validator=lambda u, p: u if (u, p) == ("alice", "wonder") else None) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -158,7 +159,7 @@ def me() -> str: def test_invalid_credentials_returns_401_with_challenge(self): basic = HttpBasicAuth(validator=lambda _u, _p: None) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -175,7 +176,7 @@ def me() -> str: def test_missing_header_returns_401_with_challenge(self): basic = HttpBasicAuth(validator=lambda _u, _p: True) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -188,7 +189,7 @@ def me() -> str: def test_malformed_base64_returns_401(self): basic = HttpBasicAuth(validator=lambda _u, _p: True) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -202,7 +203,7 @@ def me() -> str: def test_credentials_without_colon_returns_401(self): basic = HttpBasicAuth(validator=lambda _u, _p: True) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -222,7 +223,7 @@ def me() -> str: class TestHttpBasicAuthSpec: def test_security_scheme_in_components(self): basic = HttpBasicAuth(validator=lambda _u, _p: True) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -236,7 +237,7 @@ def me() -> str: def test_operation_gets_security_and_401(self): basic = HttpBasicAuth(validator=lambda _u, _p: True) - app = HttpApp(filters=[basic]) + app = HttpApp(middlewares=[basic]) @app.get("/me") def me() -> str: @@ -248,20 +249,20 @@ def me() -> str: assert op["responses"]["401"]["content"]["application/json"]["schema"] == {"type": "string"} -# --- Integration: mixing two filters in one app -------------------------- +# --- Integration: mixing two middlewares in one app --------------------- class TestMixedAuth: - def test_two_filters_register_both_schemes(self): + def test_two_middlewares_register_both_schemes(self): bearer = HttpBearerAuth(validator=lambda _t: True) basic = HttpBasicAuth(validator=lambda _u, _p: True) app = HttpApp() - @app.get("/jwt", filters=[bearer]) + @app.get("/jwt", middlewares=[bearer]) def jwt() -> str: return "j" - @app.get("/basic", filters=[basic]) + @app.get("/basic", middlewares=[basic]) def b() -> str: return "b" diff --git a/tests/openapi/filter.py b/tests/openapi/filter.py deleted file mode 100644 index 1d95ef3..0000000 --- a/tests/openapi/filter.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Tests for ``OpFilter`` wiring through ``HttpApp`` and ``Operation``.""" - -from __future__ import annotations - -from dataclasses import replace -from typing import TYPE_CHECKING - -from localpost.openapi import HttpApp, OpFilter, Unauthorized, spec -from tests.openapi.app import make_ctx, run_op - -if TYPE_CHECKING: - from localpost.http import HTTPReqCtx - from localpost.openapi.results import OpResult - from localpost.openapi.schemas import SchemaRegistry - - -# --- Test filter helpers ------------------------------------------------- - - -class _RecordingFilter: - """Filter that just records calls; never short-circuits.""" - - def __init__(self) -> None: - self.calls: list[str] = [] - - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - self.calls.append("run") - return None - - def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: - self.calls.append("contribute_root") - return doc - - def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - self.calls.append("contribute_operation") - return op - - -class _DenyFilter: - """Filter that always short-circuits with 401.""" - - def __init__(self, message: str = "denied") -> None: - self.message = message - - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - return Unauthorized(self.message) - - def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: - new_components = replace( - doc.components, - security_schemes={ - **doc.components.security_schemes, - "denyAuth": spec.SecurityScheme(type="http", scheme="bearer"), - }, - ) - return replace(doc, components=new_components) - - def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - return replace( - op, - security=(*op.security, {"denyAuth": ()}), - responses={**op.responses, "401": spec.Response(description="Unauthorized")}, - ) - - -class TestFilterIsRuntimeProtocol: - def test_recording_satisfies_protocol(self): - assert isinstance(_RecordingFilter(), OpFilter) - - def test_deny_satisfies_protocol(self): - assert isinstance(_DenyFilter(), OpFilter) - - -class TestAppLevelFilterRuntime: - def test_filter_runs_before_handler(self): - rec = _RecordingFilter() - app = HttpApp(filters=[rec]) - - @app.get("/foo") - def foo() -> str: - return "ok" - - op = app.operations[0] - ctx = make_ctx(path="/foo") - status, body, _ = run_op(op, ctx) - assert status == 200 - assert body == b"ok" - # The filter ran for the request, plus contribute_operation at registration. - # contribute_root only fires when openapi_doc is built. - assert rec.calls == ["run"] - - def test_filter_short_circuits(self): - app = HttpApp(filters=[_DenyFilter("nope")]) - - @app.get("/foo") - def foo() -> str: - return "ok" - - op = app.operations[0] - ctx = make_ctx(path="/foo") - status, body, _ = run_op(op, ctx) - assert status == 401 - assert body == b"nope" - - -class TestPerOpFilter: - def test_per_op_filter_only_affects_that_op(self): - deny = _DenyFilter("op-only") - app = HttpApp() - - @app.get("/protected", filters=[deny]) - def protected() -> str: - return "secret" - - @app.get("/public") - def public() -> str: - return "hi" - - protected_op = app.operations[0] - public_op = app.operations[1] - - status, _, _ = run_op(protected_op, make_ctx(path="/protected")) - assert status == 401 - - status, body, _ = run_op(public_op, make_ctx(path="/public")) - assert status == 200 - assert body == b"hi" - - def test_app_and_per_op_filters_compose(self): - order: list[str] = [] - - class _Tagging: - def __init__(self, tag: str) -> None: - self.tag = tag - - def __call__(self, ctx: HTTPReqCtx, /) -> None | OpResult: - order.append(self.tag) - return None - - def contribute_root(self, doc, registry): # noqa: ANN001 - return doc - - def contribute_operation(self, op, registry): # noqa: ANN001 - return op - - app = HttpApp(filters=[_Tagging("app")]) - - @app.get("/foo", filters=[_Tagging("op")]) - def foo() -> str: - return "ok" - - run_op(app.operations[0], make_ctx(path="/foo")) - # App-level runs before per-op. - assert order == ["app", "op"] - - -class TestSpecContribution: - def test_contribute_root_called_once_per_doc_build(self): - rec = _RecordingFilter() - app = HttpApp(filters=[rec]) - - @app.get("/a") - def a() -> str: - return "a" - - @app.get("/b") - def b() -> str: - return "b" - - rec.calls.clear() - _ = app.openapi_doc - # contribute_root once, contribute_operation per operation. - assert rec.calls.count("contribute_root") == 1 - assert rec.calls.count("contribute_operation") == 2 - - def test_contribute_root_appears_in_doc(self): - app = HttpApp(filters=[_DenyFilter()]) - - @app.get("/foo") - def foo() -> str: - return "ok" - - doc = app.openapi_doc.to_dict() - assert "denyAuth" in doc["components"]["securitySchemes"] - assert doc["components"]["securitySchemes"]["denyAuth"] == { - "type": "http", - "scheme": "bearer", - } - - def test_contribute_operation_appears_in_doc(self): - app = HttpApp(filters=[_DenyFilter()]) - - @app.get("/foo") - def foo() -> str: - return "ok" - - doc = app.openapi_doc.to_dict() - op = doc["paths"]["/foo"]["get"] - assert op["security"] == [{"denyAuth": []}] - assert "401" in op["responses"] - assert op["responses"]["401"] == {"description": "Unauthorized"} - - def test_per_op_filter_doc_contribution_is_op_scoped(self): - app = HttpApp() - - @app.get("/protected", filters=[_DenyFilter()]) - def protected() -> str: - return "secret" - - @app.get("/public") - def public() -> str: - return "hi" - - doc = app.openapi_doc.to_dict() - # Per-op filter contributes to BOTH root (because contribute_root - # is always called once per filter that sees the doc) and only to - # its own operation. - assert "denyAuth" in doc["components"]["securitySchemes"] - assert "security" in doc["paths"]["/protected"]["get"] - assert "security" not in doc["paths"]["/public"]["get"] - assert "401" not in doc["paths"]["/public"]["get"]["responses"] diff --git a/tests/openapi/middleware.py b/tests/openapi/middleware.py new file mode 100644 index 0000000..326fa96 --- /dev/null +++ b/tests/openapi/middleware.py @@ -0,0 +1,499 @@ +"""Tests for ``OpMiddleware`` wiring through ``HttpApp`` and ``Operation``, +plus the ``@op_middleware`` decorator.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, Annotated + +import pytest + +from localpost.http import HTTPReqCtx +from localpost.openapi import ( + ApiOperation, + BadRequest, + FromHeader, + FromQuery, + HttpApp, + Ok, + OpMiddleware, + OpResult, + TooManyRequests, + Unauthorized, + op_middleware, + spec, +) +from tests.openapi.app import make_ctx, run_op + +if TYPE_CHECKING: + from localpost.openapi.schemas import SchemaRegistry + + +# --- Test middleware helpers --------------------------------------------- + + +class _RecordingMiddleware: + """Middleware that just records calls; always forwards via ``call_next``.""" + + def __init__(self) -> None: + self.calls: list[str] = [] + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + self.calls.append("run") + return call_next(ctx) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + self.calls.append("contribute_root") + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + self.calls.append("contribute_operation") + return op + + +class _DenyMiddleware: + """Middleware that always short-circuits with 401.""" + + def __init__(self, message: str = "denied") -> None: + self.message = message + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + return Unauthorized(self.message) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + new_components = replace( + doc.components, + security_schemes={ + **doc.components.security_schemes, + "denyAuth": spec.SecurityScheme(type="http", scheme="bearer"), + }, + ) + return replace(doc, components=new_components) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return replace( + op, + security=(*op.security, {"denyAuth": ()}), + responses={**op.responses, "401": spec.Response(description="Unauthorized")}, + ) + + +class TestMiddlewareIsRuntimeProtocol: + def test_recording_satisfies_protocol(self): + assert isinstance(_RecordingMiddleware(), OpMiddleware) + + def test_deny_satisfies_protocol(self): + assert isinstance(_DenyMiddleware(), OpMiddleware) + + +class TestAppLevelMiddlewareRuntime: + def test_middleware_runs_around_handler(self): + rec = _RecordingMiddleware() + app = HttpApp(middlewares=[rec]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/foo") + status, body, _ = run_op(op, ctx) + assert status == 200 + assert body == b"ok" + # The middleware ran for the request. + assert rec.calls == ["run"] + + def test_middleware_short_circuits(self): + app = HttpApp(middlewares=[_DenyMiddleware("nope")]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/foo") + status, body, _ = run_op(op, ctx) + assert status == 401 + assert body == b"nope" + + def test_middleware_can_post_process_op_result(self): + class _AddHeader: + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + result = call_next(ctx) + # Re-wrap with extra headers. (replace() doesn't work on + # OpResult subclasses because their __init__ is custom.) + return Ok(result.body, headers={**result.headers, "X-Trace": "abc"}) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return op + + app = HttpApp(middlewares=[_AddHeader()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + op = app.operations[0] + status, _, headers = run_op(op, make_ctx(path="/foo")) + assert status == 200 + assert headers["X-Trace"] == "abc" + + +class TestPerOpMiddleware: + def test_per_op_middleware_only_affects_that_op(self): + deny = _DenyMiddleware("op-only") + app = HttpApp() + + @app.get("/protected", middlewares=[deny]) + def protected() -> str: + return "secret" + + @app.get("/public") + def public() -> str: + return "hi" + + protected_op = app.operations[0] + public_op = app.operations[1] + + status, _, _ = run_op(protected_op, make_ctx(path="/protected")) + assert status == 401 + + status, body, _ = run_op(public_op, make_ctx(path="/public")) + assert status == 200 + assert body == b"hi" + + def test_app_and_per_op_middlewares_compose(self): + order: list[str] = [] + + class _Tagging: + def __init__(self, tag: str) -> None: + self.tag = tag + + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: + order.append(self.tag) + return call_next(ctx) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + return doc + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + return op + + app = HttpApp(middlewares=[_Tagging("app")]) + + @app.get("/foo", middlewares=[_Tagging("op")]) + def foo() -> str: + return "ok" + + run_op(app.operations[0], make_ctx(path="/foo")) + # App-level wraps outermost; per-op runs inside it. + assert order == ["app", "op"] + + +class TestSpecContribution: + def test_contribute_root_called_once_per_doc_build(self): + rec = _RecordingMiddleware() + app = HttpApp(middlewares=[rec]) + + @app.get("/a") + def a() -> str: + return "a" + + @app.get("/b") + def b() -> str: + return "b" + + rec.calls.clear() + _ = app.openapi_doc + # contribute_root once, contribute_operation per operation. + assert rec.calls.count("contribute_root") == 1 + assert rec.calls.count("contribute_operation") == 2 + + def test_contribute_root_appears_in_doc(self): + app = HttpApp(middlewares=[_DenyMiddleware()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + assert "denyAuth" in doc["components"]["securitySchemes"] + assert doc["components"]["securitySchemes"]["denyAuth"] == { + "type": "http", + "scheme": "bearer", + } + + def test_contribute_operation_appears_in_doc(self): + app = HttpApp(middlewares=[_DenyMiddleware()]) + + @app.get("/foo") + def foo() -> str: + return "ok" + + doc = app.openapi_doc.to_dict() + op = doc["paths"]["/foo"]["get"] + assert op["security"] == [{"denyAuth": []}] + assert "401" in op["responses"] + assert op["responses"]["401"] == {"description": "Unauthorized"} + + def test_per_op_middleware_doc_contribution_is_op_scoped(self): + app = HttpApp() + + @app.get("/protected", middlewares=[_DenyMiddleware()]) + def protected() -> str: + return "secret" + + @app.get("/public") + def public() -> str: + return "hi" + + doc = app.openapi_doc.to_dict() + # Per-op middleware contributes to BOTH root (because contribute_root + # is always called once per middleware that sees the doc) and only + # to its own operation. + assert "denyAuth" in doc["components"]["securitySchemes"] + assert "security" in doc["paths"]["/protected"]["get"] + assert "security" not in doc["paths"]["/public"]["get"] + assert "401" not in doc["paths"]["/public"]["get"]["responses"] + + +# --- @op_middleware decorator ------------------------------------------ + + +class TestOpMiddlewareDecorator: + def test_decorator_produces_middleware(self): + @op_middleware + def f(ctx: HTTPReqCtx, call_next: ApiOperation) -> OpResult: + return call_next(ctx) + + assert isinstance(f, OpMiddleware) + + def test_missing_call_next_param_is_error(self): + with pytest.raises(ValueError, match="call_next"): + + @op_middleware + def bad() -> OpResult: # type: ignore[empty-body] + ... + + +class TestOpMiddlewareRuntime: + def test_resolved_args_are_available(self): + seen: list[str] = [] + + @op_middleware + def record( + ctx: HTTPReqCtx, + call_next: ApiOperation, + authorization: Annotated[str, FromHeader("Authorization")], + ) -> OpResult: + seen.append(authorization) + return call_next(ctx) + + app = HttpApp(middlewares=[record]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + ctx = make_ctx(path="/x", headers=[(b"authorization", b"Bearer abc")]) + status, _, _ = run_op(op, ctx) + assert status == 200 + assert seen == ["Bearer abc"] + + def test_short_circuit_with_op_result(self): + @op_middleware + def deny( + ctx: HTTPReqCtx, + call_next: ApiOperation, + code: Annotated[str, FromQuery("code")] = "", + ) -> Unauthorized[str] | OpResult: + if code != "good": + return Unauthorized("Invalid code") + return call_next(ctx) + + app = HttpApp(middlewares=[deny]) + + @app.get("/x") + def x() -> str: + return "secret" + + op = app.operations[0] + status, body, _ = run_op(op, make_ctx(path="/x", query=b"code=bad")) + assert status == 401 + assert body == b"Invalid code" + + def test_short_circuit_via_resolver(self): + # When a resolver itself returns an OpResult (e.g. missing required + # header → BadRequest), the middleware short-circuits with that + # result without calling the inner chain. + @op_middleware + def needs_header( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_id: Annotated[str, FromHeader("X-Id")], + ) -> OpResult: + return call_next(ctx) + + app = HttpApp(middlewares=[needs_header]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + # No X-Id header → resolver short-circuits with 400. + status, body, _ = run_op(op, make_ctx(path="/x")) + assert status == 400 + assert b"Missing required header" in body + + def test_non_op_result_return_is_error(self): + @op_middleware + def bad(ctx: HTTPReqCtx, call_next: ApiOperation) -> OpResult: + return "not allowed" # type: ignore[return-value] + + app = HttpApp(middlewares=[bad]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + with pytest.raises(TypeError, match="middlewares must return an OpResult"): + run_op(op, make_ctx(path="/x")) + + +class TestOpMiddlewareSpecContribution: + def test_header_param_appears_on_operation(self): + @op_middleware + def require_header( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")], + ) -> Unauthorized[str] | OpResult: + return call_next(ctx) if x_api_key else Unauthorized("missing") + + app = HttpApp(middlewares=[require_header]) + + @app.get("/x") + def x() -> str: + return "ok" + + params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] + names = [p["name"] for p in params] + assert "X-API-Key" in names + # Coming from the middleware, the param is required. + x_api_key_param = next(p for p in params if p["name"] == "X-API-Key") + assert x_api_key_param.get("required") is True + + def test_query_param_appears_on_operation(self): + @op_middleware + def require_token( + ctx: HTTPReqCtx, + call_next: ApiOperation, + token: Annotated[str, FromQuery("token")], + ) -> Unauthorized[str] | OpResult: + return call_next(ctx) if token else Unauthorized("missing") + + app = HttpApp(middlewares=[require_token]) + + @app.get("/x") + def x() -> str: + return "ok" + + params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] + names = [p["name"] for p in params] + assert "token" in names + + def test_response_codes_from_return_type_union(self): + @op_middleware + def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, + ) -> TooManyRequests[str] | OpResult: + return call_next(ctx) + + app = HttpApp(middlewares=[rate_limit]) + + @app.get("/x") + def x() -> str: + return "ok" + + responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] + assert "429" in responses + assert responses["429"]["description"] == "Too Many Requests" + + def test_middleware_response_does_not_overwrite_operation_response(self): + # If the operation already declares 400 for its own reason, the + # middleware's 400 (if any) shouldn't clobber it. + @op_middleware + def always_pass( + ctx: HTTPReqCtx, + call_next: ApiOperation, + ) -> BadRequest[int] | OpResult: # different body type + return call_next(ctx) + + app = HttpApp(middlewares=[always_pass]) + + @app.get("/x") + def x() -> str | BadRequest[str]: # the op's own 400 — wins + return "ok" + + responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] + assert "400" in responses + # The op's BadRequest[str] wins over the middleware's BadRequest[int]. + assert responses["400"]["content"]["application/json"]["schema"] == {"type": "string"} + + def test_per_op_middleware_only_contributes_to_its_op(self): + @op_middleware + def deny( + ctx: HTTPReqCtx, + call_next: ApiOperation, + code: Annotated[str, FromQuery("code")] = "", + ) -> Unauthorized[str] | OpResult: + return Unauthorized("nope") if code != "good" else call_next(ctx) + + app = HttpApp() + + @app.get("/protected", middlewares=[deny]) + def protected() -> str: + return "x" + + @app.get("/public") + def public() -> str: + return "y" + + doc = app.openapi_doc.to_dict() + # /protected has the middleware's parameter and 401 response + assert any(p["name"] == "code" for p in doc["paths"]["/protected"]["get"]["parameters"]) + assert "401" in doc["paths"]["/protected"]["get"]["responses"] + # /public doesn't + assert "parameters" not in doc["paths"]["/public"]["get"] + assert "401" not in doc["paths"]["/public"]["get"]["responses"] + + +class TestCtxParam: + def test_ctx_param_is_passthrough_and_contributes_nothing(self): + seen: list[str] = [] + + @op_middleware + def stash(ctx: HTTPReqCtx, call_next: ApiOperation) -> OpResult: + seen.append(ctx.request.path.decode()) + return call_next(ctx) + + app = HttpApp(middlewares=[stash]) + + @app.get("/x") + def x() -> str: + return "ok" + + op = app.operations[0] + run_op(op, make_ctx(path="/x")) + assert seen == ["/x"] + + # ctx parameter does NOT show up in the spec. + op_spec = app.openapi_doc.to_dict()["paths"]["/x"]["get"] + assert "parameters" not in op_spec diff --git a/tests/openapi/op_filter_tests.py b/tests/openapi/op_filter_tests.py deleted file mode 100644 index 6c4e2b5..0000000 --- a/tests/openapi/op_filter_tests.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Tests for the ``@op_filter`` decorator.""" - -from __future__ import annotations - -from typing import Annotated - -from localpost.http import HTTPReqCtx -from localpost.openapi import ( - BadRequest, - FromHeader, - FromQuery, - HttpApp, - OpFilter, - TooManyRequests, - Unauthorized, - op_filter, -) -from tests.openapi.app import make_ctx, run_op - - -# --- Wrapper smoke ------------------------------------------------------- - - -class TestProtocolConformance: - def test_decorator_produces_op_filter(self): - @op_filter - def f() -> None: - return None - - assert isinstance(f, OpFilter) - - -# --- Runtime semantics --------------------------------------------------- - - -class TestRuntime: - def test_filter_runs_resolved_args(self): - seen: list[str] = [] - - @op_filter - def record(authorization: Annotated[str, FromHeader("Authorization")]) -> None: - seen.append(authorization) - return None - - app = HttpApp(filters=[record]) - - @app.get("/x") - def x() -> str: - return "ok" - - op = app.operations[0] - ctx = make_ctx(path="/x", headers=[(b"authorization", b"Bearer abc")]) - status, _, _ = run_op(op, ctx) - assert status == 200 - assert seen == ["Bearer abc"] - - def test_filter_short_circuit_with_op_result(self): - @op_filter - def deny(token: Annotated[str, FromQuery("token")] = "") -> None | Unauthorized[str]: - if token != "good": - return Unauthorized("Invalid token") - return None - - app = HttpApp(filters=[deny]) - - @app.get("/x") - def x() -> str: - return "secret" - - op = app.operations[0] - status, body, _ = run_op(op, make_ctx(path="/x", query=b"token=bad")) - assert status == 401 - assert body == b"Invalid token" - - def test_filter_short_circuit_via_resolver(self): - # When a resolver itself returns an OpResult (e.g. missing required - # header → BadRequest), the filter short-circuits with that result. - @op_filter - def needs_header(x_id: Annotated[str, FromHeader("X-Id")]) -> None: - return None - - app = HttpApp(filters=[needs_header]) - - @app.get("/x") - def x() -> str: - return "ok" - - op = app.operations[0] - # No X-Id header → resolver short-circuits with 400. - status, body, _ = run_op(op, make_ctx(path="/x")) - assert status == 400 - assert b"Missing required header" in body - - def test_non_none_non_op_result_return_is_error(self): - import pytest - - @op_filter - def bad() -> str: - return "not allowed" # type: ignore[return-value] - - app = HttpApp(filters=[bad]) - - @app.get("/x") - def x() -> str: - return "ok" - - op = app.operations[0] - with pytest.raises(TypeError, match="filters must return None"): - run_op(op, make_ctx(path="/x")) - - -# --- Spec contribution --------------------------------------------------- - - -class TestSpecContribution: - def test_header_param_appears_on_operation(self): - @op_filter - def require_header( - x_api_key: Annotated[str, FromHeader("X-API-Key")], - ) -> None | Unauthorized[str]: - return None if x_api_key else Unauthorized("missing") - - app = HttpApp(filters=[require_header]) - - @app.get("/x") - def x() -> str: - return "ok" - - params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] - names = [p["name"] for p in params] - assert "X-API-Key" in names - # Coming from the filter, the param is required. - x_api_key_param = next(p for p in params if p["name"] == "X-API-Key") - assert x_api_key_param.get("required") is True - - def test_query_param_appears_on_operation(self): - @op_filter - def require_token(token: Annotated[str, FromQuery("token")]) -> None | Unauthorized[str]: - return None if token else Unauthorized("missing") - - app = HttpApp(filters=[require_token]) - - @app.get("/x") - def x() -> str: - return "ok" - - params = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["parameters"] - names = [p["name"] for p in params] - assert "token" in names - - def test_response_codes_from_return_type_union(self): - @op_filter - def rate_limit() -> None | TooManyRequests[str]: - return None - - app = HttpApp(filters=[rate_limit]) - - @app.get("/x") - def x() -> str: - return "ok" - - responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] - assert "429" in responses - assert responses["429"]["description"] == "Too Many Requests" - - def test_filter_response_does_not_overwrite_operation_response(self): - # If the operation already declares 400 for its own reason, the - # filter's 400 (if any) shouldn't clobber it. - @op_filter - def always_pass() -> None | BadRequest[int]: # different body type - return None - - app = HttpApp(filters=[always_pass]) - - @app.get("/x") - def x() -> str | BadRequest[str]: # the op's own 400 — wins - return "ok" - - responses = app.openapi_doc.to_dict()["paths"]["/x"]["get"]["responses"] - assert "400" in responses - # The op's BadRequest[str] wins over the filter's BadRequest[int]. - assert responses["400"]["content"]["application/json"]["schema"] == {"type": "string"} - - def test_per_op_filter_only_contributes_to_its_op(self): - @op_filter - def deny(token: Annotated[str, FromQuery("token")] = "") -> None | Unauthorized[str]: - return Unauthorized("nope") if token != "good" else None - - app = HttpApp() - - @app.get("/protected", filters=[deny]) - def protected() -> str: - return "x" - - @app.get("/public") - def public() -> str: - return "y" - - doc = app.openapi_doc.to_dict() - # /protected has the filter's parameter and 401 response - assert any(p["name"] == "token" for p in doc["paths"]["/protected"]["get"]["parameters"]) - assert "401" in doc["paths"]["/protected"]["get"]["responses"] - # /public doesn't - assert "parameters" not in doc["paths"]["/public"]["get"] - assert "401" not in doc["paths"]["/public"]["get"]["responses"] - - -# --- Compose with HTTPReqCtx -------------------------------------------- - - -class TestCtxParam: - def test_ctx_param_is_passthrough_and_contributes_nothing(self): - seen: list[str] = [] - - @op_filter - def stash(ctx: HTTPReqCtx) -> None: - seen.append(ctx.request.path.decode()) - return None - - app = HttpApp(filters=[stash]) - - @app.get("/x") - def x() -> str: - return "ok" - - op = app.operations[0] - run_op(op, make_ctx(path="/x")) - assert seen == ["/x"] - - # ctx parameter does NOT show up in the spec. - op_spec = app.openapi_doc.to_dict()["paths"]["/x"]["get"] - assert "parameters" not in op_spec From 826994a097322745c4134c022ad67812c05a8697 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 5 May 2026 14:24:07 +0400 Subject: [PATCH 181/286] feat(threadtools)!: ThreadTaskGroup + drop HTTP pool admission gate Add a Trio-style sync task group running tasks on a process-wide pool of daemon worker threads. Workers self-exit after 60s idle; start_soon returns concurrent.futures.Future; __exit__ drains and re-raises a BaseExceptionGroup of body+task errors. Rewrite HTTP _pool.py on top of it: drop the admission semaphore, backlog channel, 503-on-overflow, and the max_concurrency/backlog parameters from thread_pool_handler / streaming_pool_handler. HttpApp gains a single ``pooled: bool`` toggle in place of the cap-size knobs. The CLI flag --workers becomes --pool/--no-pool. Per-request cancel, 413/500 handling, and dispatch/streaming wrappers are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/compressed_api.py | 2 +- examples/http/flask_app_server.py | 2 +- examples/http/middleware_basic_auth.py | 2 +- examples/http/middleware_rate_limit.py | 2 +- examples/http/multithread_server.py | 4 +- examples/http/sentry_router_server.py | 2 +- examples/http/sse_compressed.py | 2 +- examples/http/static_files.py | 4 +- examples/http/wsgi_app_server.py | 2 +- localpost/http/README.md | 28 +- localpost/http/__main__.py | 10 +- localpost/http/_base.py | 3 - localpost/http/_pool.py | 251 ++++++---------- localpost/http/_service.py | 2 +- localpost/http/app.py | 38 +-- localpost/http/flask.py | 2 +- localpost/http/flask_sentry.py | 2 +- localpost/http/router_sentry.py | 2 +- localpost/http/static.py | 1 - localpost/openapi/app.py | 16 +- localpost/threadtools.py | 199 ++++++++++++- tests/http/_integration_app.py | 2 +- tests/http/app.py | 14 +- tests/http/service.py | 379 +------------------------ tests/threadtools/thread_task_group.py | 299 +++++++++++++++++++ 25 files changed, 651 insertions(+), 619 deletions(-) create mode 100644 tests/threadtools/thread_task_group.py diff --git a/examples/http/compressed_api.py b/examples/http/compressed_api.py index bbeb335..0145038 100644 --- a/examples/http/compressed_api.py +++ b/examples/http/compressed_api.py @@ -69,7 +69,7 @@ async def app(): h = compress_handler(build_router().as_handler(), algorithms=("gzip",)) config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(h, max_concurrency=8) as wrapped: + async with thread_pool_handler(h) as wrapped: async with http_server(config, wrapped): yield diff --git a/examples/http/flask_app_server.py b/examples/http/flask_app_server.py index 8259859..578558c 100644 --- a/examples/http/flask_app_server.py +++ b/examples/http/flask_app_server.py @@ -55,7 +55,7 @@ def _log_teardown(exc): @service async def app_svc(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(flask_handler(build_app()), max_concurrency=8) as wrapped: + async with thread_pool_handler(flask_handler(build_app())) as wrapped: async with http_server(config, wrapped): yield diff --git a/examples/http/middleware_basic_auth.py b/examples/http/middleware_basic_auth.py index 77093ae..3a76696 100644 --- a/examples/http/middleware_basic_auth.py +++ b/examples/http/middleware_basic_auth.py @@ -102,7 +102,7 @@ def build_router() -> RequestHandler: @service async def app(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(build_router(), max_concurrency=8) as h: + async with thread_pool_handler(build_router()) as h: async with http_server(config, h): yield diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py index 9ef3b42..f252eda 100644 --- a/examples/http/middleware_rate_limit.py +++ b/examples/http/middleware_rate_limit.py @@ -112,7 +112,7 @@ def build_handler() -> RequestHandler: @service async def app(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(build_handler(), max_concurrency=8) as h: + async with thread_pool_handler(build_handler()) as h: async with http_server(config, h): yield diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index 0a4e6ba..3f46cc9 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -9,7 +9,7 @@ curl http://localhost:8000/slow # sleeps; spam a few of these in parallel The whole router is wrapped with :func:`localpost.http.thread_pool_handler` so -every matched route runs on a worker thread (bounded by ``max_concurrency``). +every matched route runs on a worker thread from the shared pool. SIGINT / SIGTERM signals shutdown; in-flight handlers see ``RequestCancelled`` on the next ``check_cancelled`` call and the pool drains before the process exits. @@ -74,7 +74,7 @@ def build_router() -> Router: @service async def app(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(build_router().as_handler(), max_concurrency=16) as wrapped: + async with thread_pool_handler(build_router().as_handler()) as wrapped: async with http_server(config, wrapped): yield diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index 3e8a681..199976d 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -70,7 +70,7 @@ def build_router(): async def app(): handler = sentry_router_handler(build_router()) config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(handler, max_concurrency=8) as wrapped: + async with thread_pool_handler(handler) as wrapped: async with http_server(config, wrapped): yield diff --git a/examples/http/sse_compressed.py b/examples/http/sse_compressed.py index 640e5d4..3f9d5bd 100644 --- a/examples/http/sse_compressed.py +++ b/examples/http/sse_compressed.py @@ -66,7 +66,7 @@ def _events(ctx: HTTPReqCtx) -> None: async def app(): # Streaming SSE handlers run on a streaming pool — body is not # pre-buffered, the worker holds the borrowed conn for the duration. - async with streaming_pool_handler(_events, max_concurrency=8) as inner: + async with streaming_pool_handler(_events) as inner: # Same compress_handler call as for JSON APIs; the middleware # picks the streaming path automatically when the response has no # Content-Length and the content type is in the allowlist (which diff --git a/examples/http/static_files.py b/examples/http/static_files.py index 21236d5..1b1945f 100644 --- a/examples/http/static_files.py +++ b/examples/http/static_files.py @@ -68,8 +68,8 @@ async def app(): ) async with ( - thread_pool_handler(build_api(), max_concurrency=8) as api_h, - thread_pool_handler(static, max_concurrency=64, backlog=64) as static_h, + thread_pool_handler(build_api()) as api_h, + thread_pool_handler(static) as static_h, ): def root_handler(ctx: HTTPReqCtx) -> BodyHandler | None: return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py index fdaa44f..87775ed 100644 --- a/examples/http/wsgi_app_server.py +++ b/examples/http/wsgi_app_server.py @@ -47,7 +47,7 @@ async def wsgi_app_service(): config = ServerConfig(host="127.0.0.1", port=8000) # WSGI views block on response-body iteration, so wrap with a thread pool # to serve more than one request at a time. - async with thread_pool_handler(wrap_wsgi(build_app()), max_concurrency=8) as wrapped: + async with thread_pool_handler(wrap_wsgi(build_app())) as wrapped: async with http_server(config, wrapped): yield diff --git a/localpost/http/README.md b/localpost/http/README.md index 56f6195..8b76e33 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -270,11 +270,10 @@ from localpost.http import ( routes = Routes() # ... register API routes ... -api = thread_pool_handler(routes.build().as_handler(), max_concurrency=8) +api = thread_pool_handler(routes.build().as_handler()) static = thread_pool_handler( static_handler("/var/www", prefix=b"/static/", cache_control="public, max-age=31536000, immutable"), - max_concurrency=128, backlog=64, ) async with api as api_h, static as static_h: @@ -284,8 +283,8 @@ async with api as api_h, static as static_h: ... ``` -The API pool is small and CPU-shaped; the static pool is wide and I/O-shaped. -See [`examples/http/static_files.py`](../../examples/http/static_files.py). +Both wrappers share the process-wide worker pool — workers are spawned on +demand and reused. See [`examples/http/static_files.py`](../../examples/http/static_files.py). #### `HTTPReqCtx.sendfile` @@ -375,10 +374,8 @@ zero-copy: ```python api = thread_pool_handler( compress_handler(routes.build().as_handler(), algorithms=("br", "gzip")), - max_concurrency=8, ) -static = thread_pool_handler(static_handler("/var/www", prefix=b"/static/"), - max_concurrency=128, backlog=64) +static = thread_pool_handler(static_handler("/var/www", prefix=b"/static/")) ``` See [`examples/http/compressed_api.py`](../../examples/http/compressed_api.py). @@ -457,7 +454,7 @@ on the documented public Flask API. | `http_server(config, handler, *, selectors=1, acceptor=False)` | `localpost.http._service` | `@hosting.service` — runs the server loop with `handler`. See **Threading topologies** below. | | `wsgi_server(config, app, *, selectors=1, acceptor=False)` | `localpost.http._service` | Same, for a generic WSGI app. | | `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask`. | -| `thread_pool_handler(inner, *, max_concurrency, backlog=0)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a worker thread. Admission cap = `max_concurrency + backlog`; default `backlog=0` means exactly `max_concurrency` in flight. | +| `thread_pool_handler(inner)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a shared worker thread (process-wide pool, workers spawned on demand, no concurrency cap). | #### Threading topologies @@ -495,7 +492,7 @@ async def app(): def hello(ctx): ... # plain RequestCtx → Response handler config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(routes.build().as_handler(), max_concurrency=8) as h: + async with thread_pool_handler(routes.build().as_handler()) as h: async with http_server(config, h): yield @@ -513,14 +510,11 @@ What this gives you: the router directly to `http_server` to keep them all on the selector; more granular per-route control is the user's composition problem (today there is no per-route pool API). -- **No max\_concurrency on `http_server`.** The pool is the wrapper's - concern; `http_server` has one job and one job only. -- **Admission is the pool's concern, not the server's.** The pool admits up to - `max_concurrency + backlog` requests at once (a `threading.Semaphore` - acquired by the selector on dispatch, released by the worker on completion). - The default `backlog=0` is the strict-N case: every dispatch needs a free - worker, otherwise it 503s. Bump `backlog` to let bursts queue briefly - instead of bouncing. +- **No concurrency cap on `http_server` or the pool.** The pool dispatches + every request onto a process-wide `ThreadTaskGroup`; workers are spawned + on demand and reused across all `thread_pool_handler` instances in the + process. There is no admission gate and no 503-on-overflow — backpressure + is the deployment's concern (front-LB / OS thread limits). ## Design diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py index 95fd95e..17bc927 100644 --- a/localpost/http/__main__.py +++ b/localpost/http/__main__.py @@ -30,15 +30,15 @@ def _load_handler(app_str: str) -> RequestHandler: @click.argument("app") @click.option("--host", default="127.0.0.1", show_default=True, help="Bind host.") @click.option("--port", default=8000, show_default=True, help="Bind port.") -@click.option("--workers", default=0, show_default=True, help="Thread-pool size (0 = no pool).") +@click.option("--pool/--no-pool", default=False, show_default=True, help="Run handlers on a thread pool.") @click.option("--selectors", default=1, show_default=True, help="Selector threads.") @click.option("--acceptor", is_flag=True, default=False, help="Use acceptor topology.") -def main(app: str, host: str, port: int, workers: int, selectors: int, acceptor: bool) -> None: +def main(app: str, host: str, port: int, pool: bool, selectors: int, acceptor: bool) -> None: """Run a LocalPost HTTP/1.1 server. APP is a 'module:handler' reference — e.g. ``myapp:router_handler``. The attribute must be a :data:`localpost.http.RequestHandler` callable. - Pass ``--workers N`` to wrap it with :func:`localpost.http.thread_pool_handler`. + Pass ``--pool`` to wrap it with :func:`localpost.http.thread_pool_handler`. """ logging.basicConfig(level=logging.INFO) handler = _load_handler(app) @@ -46,8 +46,8 @@ def main(app: str, host: str, port: int, workers: int, selectors: int, acceptor: @hosting.service async def _serve(): - if workers > 0: - async with thread_pool_handler(handler, max_concurrency=workers) as h: + if pool: + async with thread_pool_handler(handler) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield else: diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 4b8eddb..5ceb267 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -159,9 +159,6 @@ def _build_canned(status_code: int, body: bytes) -> tuple[Response, bytes]: REQUEST_TIMEOUT_BODY = b"Request Timeout" REQUEST_TIMEOUT_RESPONSE, REQUEST_TIMEOUT_WIRE = _build_canned(408, REQUEST_TIMEOUT_BODY) -SERVICE_UNAVAILABLE_BODY = b"Service Unavailable" -SERVICE_UNAVAILABLE_RESPONSE, SERVICE_UNAVAILABLE_WIRE = _build_canned(503, SERVICE_UNAVAILABLE_BODY) - # -------------------------------------------------------------------------- # HTTPReqCtx Protocol + handler types diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index a538e4f..a2154b2 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -15,6 +15,11 @@ buffered up-front; the handler reads it via ``ctx.receive(...)`` chunk by chunk. Use for streaming uploads / large bodies where buffering is undesirable. + +Both wrappers dispatch onto a process-wide +:class:`localpost.threadtools.ThreadTaskGroup` — workers are spawned +on demand and reused across all pool wrappers / HTTP servers in the +process. There is no concurrency cap. """ from __future__ import annotations @@ -24,19 +29,12 @@ from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager, suppress -from anyio import ( - BrokenResourceError, - ClosedResourceError, - create_task_group, - to_thread, -) +from anyio import to_thread from localpost import threadtools from localpost.http._base import ( PAYLOAD_TOO_LARGE_BODY, PAYLOAD_TOO_LARGE_RESPONSE, - SERVICE_UNAVAILABLE_BODY, - SERVICE_UNAVAILABLE_RESPONSE, BodyHandler, HTTPReqCtx, RequestHandler, @@ -52,31 +50,22 @@ _WorkFn = Callable[[HTTPReqCtx], None] -_WorkItem = tuple[HTTPReqCtx, RequestCancel, _WorkFn] class _Pool: - """Shared channel + workers + admission semaphore, plus dispatch helpers. - - Created/torn-down by :func:`_pool_context`. Two ``dispatch_*`` methods - let callers wire either a post-body :data:`BodyHandler` or a - pre-body streaming handler onto the same worker pool. Admission is - non-blocking from the selector thread: the semaphore caps the number - of in-flight + buffered requests at ``max_concurrency + backlog``; if - the cap is hit, the request receives 503 and the connection is closed. + """Dispatcher that runs request handlers on a shared + :class:`ThreadTaskGroup`. + + Two ``dispatch_*`` methods let callers wire either a post-body + :data:`BodyHandler` or a pre-body streaming handler onto the same + task group. """ - __slots__ = ("_admission", "_shutdown_event", "_tx") + __slots__ = ("_shutdown_event", "_tg") - def __init__( - self, - tx: threadtools.SendChannel[_WorkItem], - shutdown_event: threading.Event, - admission: threading.Semaphore, - ) -> None: - self._tx = tx + def __init__(self, tg: threadtools.ThreadTaskGroup, shutdown_event: threading.Event) -> None: + self._tg = tg self._shutdown_event = shutdown_event - self._admission = admission def dispatch_buffered(self, body_handler: BodyHandler) -> BodyHandler: """Wrap a :data:`BodyHandler` so when it's invoked post-body it @@ -105,118 +94,86 @@ def pre_body(ctx: HTTPReqCtx) -> BodyHandler | None: return pre_body def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: - tx = self._tx - admission = self._admission + tg = self._tg shutdown_event = self._shutdown_event def dispatched(ctx: HTTPReqCtx) -> None: ctx.conn.selector.stop_tracking(ctx.conn) - if not admission.acquire(blocking=False): - _reject_overloaded(ctx) - return cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) - try: - tx.put_nowait((ctx, cancel, fn)) - except (ClosedResourceError, BrokenResourceError): - # Pool shutting down; semaphore release happens here since the - # worker won't run. - admission.release() - with suppress(Exception): - ctx.conn.close() + tg.start_soon(_run_request, ctx, cancel, fn) return dispatched -def _reject_overloaded(ctx: HTTPReqCtx) -> None: - """Fail fast when the worker queue is full. +def _run_request(ctx: HTTPReqCtx, cancel: RequestCancel, fn: _WorkFn) -> None: + """Run a single request on the worker thread. - This runs on the selector thread after the conn has been unregistered. - Keep the response tiny and close afterwards so unread request-body bytes - cannot poison keep-alive framing. + Mirrors the failure handling the previous worker loop performed: + body-too-large maps to 413; other exceptions are logged and turned + into a generic 500 (or close on the spot if the response already + started). On per-request cancellation the conn is closed because + its protocol state is uncertain. """ - with suppress(Exception): - ctx.complete(SERVICE_UNAVAILABLE_RESPONSE, SERVICE_UNAVAILABLE_BODY) - with suppress(Exception): - ctx.conn.close() + logger = logging.getLogger(LOGGER_NAME) + try: + with _enter_request(cancel): + try: + fn(ctx) + except RequestCancelled: + # Handler bailed cleanly. Connection state is uncertain — close. + with suppress(Exception): + ctx.conn.close() + except BodyTooLarge: + _emit_body_too_large(ctx) + except Exception: + # The future captures the exception, but nothing reads it on + # the HTTP path; log here so failures are visible immediately + # rather than only at pool shutdown. + logger.exception( + "Pool handler raised for %s %r", + ctx.request.method, + ctx.request.target, + ) + emit_handler_error(ctx) + finally: + # Conn-release policy: + # + # On the success path the handler's ``finish_response`` + # already re-tracked the conn via ``_maybe_give_back`` — + # we MUST NOT touch it here. We can't read + # ``ctx.conn.tracked`` either: that field is shared + # with the next request's dispatcher, which clears it + # via ``stop_tracking`` before this finally runs. + # + # The only case left to handle here is per-request + # cancellation: the handler caught the signal and + # returned, but the conn is in an uncertain state. + # ``cancel.fired`` is the cheap (no-syscall) check. + if cancel.fired: + with suppress(Exception): + ctx.conn.close() @asynccontextmanager -async def _pool_context(max_concurrency: int, backlog: int) -> AsyncGenerator[_Pool]: - """Open a worker pool for ``max_concurrency`` concurrent requests with an - optional ``backlog`` of additional buffered requests. - - Yields a :class:`_Pool` whose ``dispatch_*`` helpers can be used to - wire handlers onto the shared channel. On exit, signals in-flight - handlers via the cancel event and waits for workers to drain. - - Admission is gated by a :class:`threading.Semaphore` of size - ``max_concurrency + backlog``: the selector acquires a permit on - dispatch, the worker releases it when the handler finishes. The - channel itself is unbounded — it's only the conduit; the semaphore - is the budget. ``backlog=0`` makes the budget exactly - ``max_concurrency``: only an idle worker can pick up a new request. - """ - if max_concurrency < 1: - raise ValueError("max_concurrency must be >= 1") - if backlog < 0: - raise ValueError("backlog must be >= 0") +async def _pool_context() -> AsyncGenerator[_Pool]: + """Open a worker pool backed by a :class:`ThreadTaskGroup`. - logger = logging.getLogger(LOGGER_NAME) - tx, rx = threadtools.Channel[_WorkItem].create(capacity=None) + The task group is process-wide in spirit (workers are shared across + all pools), but each ``_pool_context`` owns its own group so its + teardown drains exactly the requests it dispatched. + + On exit, signals in-flight handlers via the cancel event and waits + for the group to drain. Drain is offloaded to a thread so the + surrounding event loop stays responsive. + """ shutdown_event = threading.Event() - admission = threading.Semaphore(max_concurrency + backlog) - - def worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: - with my_rx: - for ctx, cancel, fn in my_rx: - try: - with _enter_request(cancel): - try: - fn(ctx) - except RequestCancelled: - # Handler bailed cleanly. Connection state is uncertain — close. - with suppress(Exception): - ctx.conn.close() - except BodyTooLarge: - _emit_body_too_large(ctx) - except Exception: - logger.exception( - "Pool handler raised for %s %r", - ctx.request.method, - ctx.request.target, - ) - emit_handler_error(ctx) - finally: - # Conn-release policy: - # - # On the success path the handler's ``finish_response`` - # already re-tracked the conn via ``_maybe_give_back`` — - # we MUST NOT touch it here. We can't read - # ``ctx.conn.tracked`` either: that field is shared - # with the next request's dispatcher, which clears it - # via ``stop_tracking`` before this finally runs. - # - # The only case left to handle here is per-request - # cancellation: the handler caught the signal and - # returned, but the conn is in an uncertain state. - # ``cancel.fired`` is the cheap (no-syscall) check. - if cancel.fired: - with suppress(Exception): - ctx.conn.close() - admission.release() - - async def run_worker(my_rx: threadtools.ReceiveChannel[_WorkItem]) -> None: - await to_thread.run_sync(worker, my_rx) - - async with create_task_group() as tg: - for _ in range(max_concurrency): - tg.start_soon(run_worker, rx.clone()) - rx.close() # Keep only workers read ends - try: - yield _Pool(tx, shutdown_event, admission) - finally: - shutdown_event.set() - tx.close() + tg = threadtools.ThreadTaskGroup(name="http-pool") + tg.__enter__() + try: + yield _Pool(tg, shutdown_event) + finally: + shutdown_event.set() + await to_thread.run_sync(tg.__exit__, None, None, None) def _emit_body_too_large(ctx: HTTPReqCtx) -> None: @@ -235,13 +192,7 @@ def _emit_body_too_large(ctx: HTTPReqCtx) -> None: @asynccontextmanager -async def thread_pool_handler( - inner: RequestHandler, - /, - *, - max_concurrency: int, - backlog: int = 0, -) -> AsyncGenerator[RequestHandler]: +async def thread_pool_handler(inner: RequestHandler, /) -> AsyncGenerator[RequestHandler]: """Async context manager: yields a ``RequestHandler`` that offloads body-handler continuations to a worker thread. @@ -254,16 +205,12 @@ async def thread_pool_handler( - :data:`BodyHandler` — the wrapper returns a *new* continuation that, when invoked by the selector after the body has been buffered, ``stop_tracking`` s the conn and queues the work for a - worker. If neither a worker nor a backlog slot is available, the - request is rejected with 503 instead of blocking the selector. - ``max_concurrency`` workers pull from the channel and run the - continuation under a per-request cancel scope. + worker. Workers run the continuation under a per-request cancel + scope. - ``backlog`` controls the channel buffer between selector and workers. - The default ``0`` is rendezvous — a request is dispatched only when a - worker is currently idle on ``Channel.get()``. ``backlog=K`` lets up - to K extra requests sit in the channel before 503s start. Total - system capacity = ``max_concurrency + backlog``. + Workers come from a process-wide + :class:`localpost.threadtools.ThreadTaskGroup` and are reused across + all pool wrappers; there is no concurrency cap. Per-request cancellation surfaces through :func:`localpost.http.check_cancelled` (client disconnect via @@ -272,15 +219,11 @@ async def thread_pool_handler( Example:: - async with thread_pool_handler(router.as_handler(), max_concurrency=8) as h: + async with thread_pool_handler(router.as_handler()) as h: async with http_server(config, h): ... """ - if max_concurrency < 1: - raise ValueError("max_concurrency must be >= 1") - if backlog < 0: - raise ValueError("backlog must be >= 0") - async with _pool_context(max_concurrency, backlog) as pool: + async with _pool_context() as pool: def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: result = inner(ctx) @@ -292,13 +235,7 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: @asynccontextmanager -async def streaming_pool_handler( - inner: _WorkFn, - /, - *, - max_concurrency: int, - backlog: int = 0, -) -> AsyncGenerator[RequestHandler]: +async def streaming_pool_handler(inner: _WorkFn, /) -> AsyncGenerator[RequestHandler]: """Async context manager: yields a ``RequestHandler`` that runs ``inner`` on a worker thread with a *borrowed* conn — body **not** pre-buffered. @@ -310,10 +247,6 @@ async def streaming_pool_handler( ``inner`` shape: ``(ctx) -> None``. Must complete the response or close the conn. - ``backlog`` has the same meaning as in :func:`thread_pool_handler`: - default ``0`` is rendezvous; ``backlog=K`` allows K extra queued - requests before 503s start. - Per-request cancellation surfaces through :func:`localpost.http.check_cancelled` — same triggers as :func:`thread_pool_handler`. @@ -327,13 +260,9 @@ def upload(ctx: HTTPReqCtx) -> None: ctx.complete(Response(204, [(b"content-length", b"0")]), b"") - async with streaming_pool_handler(upload, max_concurrency=4) as h: + async with streaming_pool_handler(upload) as h: async with http_server(config, h): ... """ - if max_concurrency < 1: - raise ValueError("max_concurrency must be >= 1") - if backlog < 0: - raise ValueError("backlog must be >= 0") - async with _pool_context(max_concurrency, backlog) as pool: + async with _pool_context() as pool: yield pool.dispatch_streaming(inner) diff --git a/localpost/http/_service.py b/localpost/http/_service.py index a0bca58..bab7cc5 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -285,7 +285,7 @@ def wsgi_server( synchronous). Wrap with :func:`thread_pool_handler` if you want to serve more than one request at a time:: - async with thread_pool_handler(wrap_wsgi(my_app), max_concurrency=8) as h: + async with thread_pool_handler(wrap_wsgi(my_app)) as h: async with http_server(config, h): ... """ diff --git a/localpost/http/app.py b/localpost/http/app.py index 38c3106..ad551da 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -181,18 +181,13 @@ class HttpApp: """Decorator-driven HTTP app on top of :class:`Router`. Args: - max_concurrency: Worker-pool size. Default 32. Set to ``0`` to - disable the pool — buffered handlers then run inline on the - selector thread (only viable when every handler is short - and non-blocking). Streaming routes (``buffer_body=False``) - require a pool — registering one with ``max_concurrency=0`` - raises at service-startup time. - backlog: Additional channel buffer between selector and workers. - Default ``0`` = rendezvous: a request is dispatched only when - a worker is currently waiting; otherwise the selector replies - 503 immediately. ``backlog=K`` allows up to K extra requests - to sit in the channel before 503s start. Total system capacity - is ``max_concurrency + backlog``. + pooled: When ``True`` (default), buffered routes run on a shared + worker pool (:func:`thread_pool_handler`). Set to ``False`` to + run buffered handlers inline on the selector thread — only + viable when every handler is short and non-blocking. + Streaming routes (``buffer_body=False``) always require the + pool; ``pooled=False`` with a streaming route raises at + service-startup time. middleware: App-level middlewares wrapping the entire dispatcher (after Router). Outermost-first. """ @@ -200,16 +195,10 @@ class HttpApp: def __init__( self, *, - max_concurrency: int = 32, - backlog: int = 0, + pooled: bool = True, middleware: Sequence[Middleware] = (), ) -> None: - if max_concurrency < 0: - raise ValueError("max_concurrency must be >= 0") - if backlog < 0: - raise ValueError("backlog must be >= 0") - self.max_concurrency = max_concurrency - self.backlog = backlog + self.pooled = pooled self._middleware = tuple(middleware) self._routes: list[_Route] = [] @@ -340,7 +329,7 @@ def _build_router_handler(self, pool: _Pool | None) -> RequestHandler: if pool is None: raise RuntimeError( f"streaming route {route.method.value} {route.path!r} requires " - f"a worker pool (HttpApp(max_concurrency > 0))" + f"a worker pool (HttpApp(pooled=True))" ) handler = self._build_streaming_handler(route, pool) routes.add(route.method, route.path, handler) @@ -367,17 +356,16 @@ def service( ``selectors`` and ``acceptor`` forward to :func:`http_server` — see its docstring for the full topology rules. """ - max_concurrency = self.max_concurrency - backlog = self.backlog + pooled = self.pooled @hosting.service async def _app_service(): - if max_concurrency == 0: + if not pooled: inner = self._build_router_handler(None) async with http_server(config, inner, selectors=selectors, acceptor=acceptor): yield return - async with _pool_context(max_concurrency, backlog) as pool: + async with _pool_context() as pool: inner = self._build_router_handler(pool) async with http_server(config, inner, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/http/flask.py b/localpost/http/flask.py index 57525e1..8fee58d 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -85,7 +85,7 @@ def flask_server(config: ServerConfig, app: Flask, /): request at a time wrap the handler with :func:`localpost.http.thread_pool_handler`:: - async with thread_pool_handler(flask_handler(app), max_concurrency=8) as h: + async with thread_pool_handler(flask_handler(app)) as h: async with http_server(config, h): ... """ diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index ce5018c..775761d 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -18,7 +18,7 @@ sentry_sdk.init(dsn=..., traces_sample_rate=1.0) handler = sentry_flask_handler(my_flask_app) - async with thread_pool_handler(handler, max_concurrency=8) as wrapped: + async with thread_pool_handler(handler) as wrapped: async with http_server(config, wrapped): ... """ diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 199aab7..19ed26a 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -23,7 +23,7 @@ def get_book(ctx): ... sentry_sdk.init(dsn=..., traces_sample_rate=1.0) handler = sentry_router_handler(router) - async with thread_pool_handler(handler, max_concurrency=16) as wrapped: + async with thread_pool_handler(handler) as wrapped: async with http_server(config, wrapped): ... diff --git a/localpost/http/static.py b/localpost/http/static.py index c310791..d088263 100644 --- a/localpost/http/static.py +++ b/localpost/http/static.py @@ -68,7 +68,6 @@ def static_handler( h = thread_pool_handler( static_handler("/var/www", prefix=b"/static/", cache_control="public, max-age=31536000, immutable"), - max_concurrency=128, backlog=64, ) """ root_path = Path(os.fspath(root)).resolve(strict=True) diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index c7b01b0..39a8b24 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -61,10 +61,6 @@ class HttpApp: called once at spec build; their :meth:`OpMiddleware.contribute_operation` is called for every operation. - max_concurrency: Worker-pool size used by :func:`thread_pool_handler`. - Default 32. - backlog: Extra channel buffer between the selector and the worker - pool. Default ``0`` (rendezvous). openapi_path: URL the generated spec is served on. ``None`` to disable. docs_path: Base URL for the built-in doc UIs. Each UI is served @@ -83,21 +79,13 @@ def __init__( *, info: openapi_spec.Info | None = None, middlewares: Sequence[OpMiddleware] = (), - max_concurrency: int = 32, - backlog: int = 0, openapi_path: str | None = "/openapi.json", docs_path: str | None = "/docs", docs_ui: DocsUI = "all", adapters: AdapterRegistry | None = None, ) -> None: - if max_concurrency < 1: - raise ValueError("max_concurrency must be >= 1") - if backlog < 0: - raise ValueError("backlog must be >= 0") self._info = info or openapi_spec.Info() self._middlewares = tuple(middlewares) - self._max_concurrency = max_concurrency - self._backlog = backlog self._openapi_path = openapi_path self._docs_path = docs_path self._docs_ui = docs_ui @@ -213,13 +201,11 @@ def service( ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ - max_concurrency = self._max_concurrency - backlog = self._backlog router = self._build_router_handler() @hosting.service async def _app_service(): - async with thread_pool_handler(router, max_concurrency=max_concurrency, backlog=backlog) as h: + async with thread_pool_handler(router) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/threadtools.py b/localpost/threadtools.py index d40de6c..84da4c8 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -1,11 +1,13 @@ from __future__ import annotations import dataclasses as dc +import queue import threading import time from collections import deque -from collections.abc import Iterator -from typing import Protocol, Self, final, override +from collections.abc import Callable, Iterator +from concurrent.futures import Future +from typing import Any, Literal, Protocol, Self, final, override from anyio import ( ClosedResourceError, @@ -16,8 +18,9 @@ __all__ = [ "Channel", - "SendChannel", "ReceiveChannel", + "SendChannel", + "ThreadTaskGroup", ] CHECK_TIMEOUT: float = 1.0 @@ -381,3 +384,193 @@ def close(self) -> None: self._closed = True state.open_receive_channels -= 1 state.not_empty.notify() # Wake up threads waiting in get() immediately + + +### +# ThreadTaskGroup +### + +_IDLE_TIMEOUT: float = 60.0 +"""Seconds an idle worker waits for new work before self-exiting.""" + + +@dc.dataclass(slots=True) +class _Task: + future: Future[Any] + fn: Callable[..., Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + group: ThreadTaskGroup + + def run(self) -> None: + try: + try: + result = self.fn(*self.args, **self.kwargs) + except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything + self.future.set_exception(exc) + self.group._record_error(exc) + else: + self.future.set_result(result) + finally: + self.group._task_done() + + +@final +class _Worker: + """Idle-tracked worker thread with a 1-slot inbox. + + State machine: ``busy`` (running a task or freshly spawned) → ``idle`` + (re-parked, in the global ``_idle`` deque) → ``busy`` again on claim, + or ``dead`` if the inbox stays empty for ``_IDLE_TIMEOUT`` seconds. + + Idle workers stay listed in ``_idle`` until a dispatcher pops them. + A self-died worker leaves a tombstone in the deque; ``claim()`` + returns ``False`` for tombstones so the dispatcher pops the next + one (or spawns a fresh worker). + """ + + __slots__ = ("_inbox", "_lock", "_state", "_thread") + + def __init__(self) -> None: + self._inbox: queue.Queue[_Task] = queue.Queue(maxsize=1) + self._lock = threading.Lock() + # Born busy: spawned only with a task imminent (skips claim()). + self._state: Literal["busy", "idle", "dead"] = "busy" + self._thread = threading.Thread(target=self._run, daemon=True, name="localpost-worker") + self._thread.start() + + def claim(self) -> bool: + """Atomically transition idle → busy. Returns ``False`` for tombstones.""" + with self._lock: + if self._state == "idle": + self._state = "busy" + return True + return False + + def submit(self, task: _Task) -> None: + """Hand off a task to a worker that's already been claimed.""" + # Inbox capacity is 1; a claimed worker has an empty inbox by construction + # (the previous task was already dequeued before re-parking). + self._inbox.put(task) + + def _run(self) -> None: + while True: + try: + task = self._inbox.get(timeout=_IDLE_TIMEOUT) + except queue.Empty: + with self._lock: + if self._state == "idle": + # Genuinely idle on timeout — die. Tombstone stays in + # the deque; the next dispatcher to pop it will retry. + self._state = "dead" + return + # Claimed during the timeout race — fall through and consume + # the put that's about to land in the inbox. + continue + task.run() + with self._lock: + self._state = "idle" + _idle.append(self) + + +_idle: deque[_Worker] = deque() +"""Global LIFO stack of idle workers, shared across all ``ThreadTaskGroup``s.""" + + +@final +class ThreadTaskGroup: + """Trio-style task group running sync callables on a shared thread pool. + + Tasks submitted via :meth:`start_soon` run on a process-wide pool of + worker threads. Workers are spawned on demand, reused across all + ``ThreadTaskGroup`` instances, and self-exit after 60 s of idleness. + There is no concurrency cap — ``start_soon`` always succeeds (modulo + OS thread limits). + + Lifetime: sync context manager. On exit, blocks until every task + started inside the ``with`` block has finished, then re-raises any + task exceptions wrapped in an :class:`ExceptionGroup` (Trio + ``strict_exception_groups=True`` semantics — a body exception and + task exceptions are merged into one group). + + Example:: + + with ThreadTaskGroup() as tg: + fut = tg.start_soon(do_work, arg) + # ... + # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. + + ``start_soon`` is callable from any thread, including from inside a + task running on the same group (recursive spawn). It returns a + :class:`concurrent.futures.Future` that captures the task's result + or exception. Reading the future is optional — a task exception is + surfaced via the ``ExceptionGroup`` raised at ``__exit__`` even if + the future is discarded. + """ + + __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending") + + def __init__(self, *, name: str | None = None) -> None: + self._name = name + self._lock = threading.Lock() + self._cv = threading.Condition(self._lock) + self._pending = 0 + self._errors: list[BaseException] = [] + self._closed = False + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("ThreadTaskGroup cannot be reused") + return self + + def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> None: + # Drain. Nested ``start_soon`` from in-flight tasks is allowed — the + # while loop re-checks after each notify. + with self._cv: + while self._pending > 0: + self._cv.wait() + self._closed = True + all_errors: list[BaseException] = [] + if exc is not None: + all_errors.append(exc) + all_errors.extend(self._errors) + if all_errors: + label = f"ThreadTaskGroup {self._name!r} failed" if self._name else "ThreadTaskGroup failed" + # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every + # member is an ``Exception`` subclass, ``BaseExceptionGroup`` otherwise + # — matches Trio semantics for ``KeyboardInterrupt`` / ``SystemExit``. + raise BaseExceptionGroup(label, all_errors) + + def start_soon[**P, R]( + self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs + ) -> Future[R]: + """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future.""" + with self._lock: + if self._closed: + raise RuntimeError("ThreadTaskGroup is closed") + self._pending += 1 + fut: Future[R] = Future() + task = _Task(fut, fn, args, kwargs, self) + while True: + try: + w = _idle.pop() + except IndexError: + # No idle worker — spawn one. It starts in ``busy`` state, so + # we skip ``claim()`` and submit directly. + w = _Worker() + break + if w.claim(): + break + # Tombstone (self-died on idle timeout); pop the next one. + w.submit(task) + return fut + + def _task_done(self) -> None: + with self._cv: + self._pending -= 1 + if self._pending == 0: + self._cv.notify_all() + + def _record_error(self, exc: BaseException) -> None: + with self._lock: + self._errors.append(exc) diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index fa59738..b0e1b8b 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -112,7 +112,7 @@ def _main() -> int: @service async def app(): - async with thread_pool_handler(handler, max_concurrency=8) as wrapped: + async with thread_pool_handler(handler) as wrapped: async with http_server(cfg, wrapped): yield diff --git a/tests/http/app.py b/tests/http/app.py index e776670..f9e1a09 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -410,7 +410,7 @@ def post_only(): class TestWorkerPool: async def test_handlers_run_on_worker_threads(self, free_port): - """Default ``max_concurrency=32`` — handlers run on worker threads, + """Default (``pooled=True``) — handlers run on worker threads, not the selector thread.""" seen: list[int] = [] lock = threading.Lock() @@ -438,12 +438,12 @@ def tid(): lt.shutdown() await lt.stopped - async def test_max_concurrency_zero_runs_inline(self, free_port): + async def test_pooled_false_runs_inline(self, free_port): """When the pool is disabled, handlers run on the selector thread.""" seen: set[int] = set() lock = threading.Lock() - app = HttpApp(max_concurrency=0) + app = HttpApp(pooled=False) @app.get("/tid") def tid(): @@ -464,10 +464,6 @@ def tid(): lt.shutdown() await lt.stopped - async def test_invalid_max_concurrency(self): - with pytest.raises(ValueError, match="max_concurrency"): - HttpApp(max_concurrency=-1) - # --- backend selection --------------------------------------------------- @@ -721,9 +717,9 @@ def hit() -> bytes: assert captured == ["upload:1024", "ping"] def test_streaming_route_with_pool_disabled_raises(self): - """Registering a streaming route on an HttpApp with ``max_concurrency=0`` + """Registering a streaming route on an HttpApp with ``pooled=False`` raises ``RuntimeError`` when the dispatcher is built.""" - app = HttpApp(max_concurrency=0) + app = HttpApp(pooled=False) @app.post("/upload", buffer_body=False) def upload(ctx: HTTPReqCtx): # pragma: no cover diff --git a/tests/http/service.py b/tests/http/service.py index 8ca55ba..1ed320f 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -34,8 +34,6 @@ streaming_pool_handler, thread_pool_handler, ) -from tests.http._helpers import read_http_response - pytestmark = pytest.mark.anyio @@ -44,8 +42,6 @@ async def _serve_pooled( cfg: ServerConfig, handler: RequestHandler, *, - max_concurrency: int, - backlog: int = 0, selectors: int = 1, acceptor: bool = False, ) -> AsyncGenerator[ServiceLifetimeView]: @@ -53,7 +49,7 @@ async def _serve_pooled( Tests own shutdown via the yielded lifetime; the pool drains on exit. """ - async with thread_pool_handler(handler, max_concurrency=max_concurrency, backlog=backlog) as wrapped: + async with thread_pool_handler(handler) as wrapped: async with serve(http_server(cfg, wrapped, selectors=selectors, acceptor=acceptor)) as lt: yield lt @@ -109,7 +105,7 @@ async def test_serves_single_request_immediate(self, free_port): async def test_serves_single_request_pooled(self, free_port): """Pool-wrapped handler: same observable behaviour, but on a worker thread.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, _handler_200(b"hi"), max_concurrency=1) as lt: + async with _serve_pooled(cfg, _handler_200(b"hi")) as lt: await lt.started await _wait_server_ready(free_port) resp = await _get(f"http://127.0.0.1:{free_port}/") @@ -120,7 +116,7 @@ async def test_serves_single_request_pooled(self, free_port): assert lt.exit_code == 0 async def test_each_request_becomes_a_task(self, free_port): - """max_concurrency>1 → several slow handlers run in parallel (different threads).""" + """Several slow handlers run in parallel on different worker threads.""" thread_ids: list[int] = [] lock = threading.Lock() entered = threading.Semaphore(0) # used as a barrier signal @@ -141,7 +137,7 @@ def handler(_ctx: HTTPReqCtx): return body_handler cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=4) as lt: + async with _serve_pooled(cfg, handler) as lt: await lt.started await _wait_server_ready(free_port) @@ -177,39 +173,6 @@ def wait_for_three(): lt.shutdown() await lt.stopped - async def test_max_concurrency_one_serializes(self, free_port): - """With a 1-slot pool plus a queue, all requests run, just one at a time.""" - in_flight = 0 - peak = 0 - lock = threading.Lock() - - def body_handler(ctx: HTTPReqCtx): - nonlocal in_flight, peak - with lock: - in_flight += 1 - peak = max(peak, in_flight) - time.sleep(0.1) - with lock: - in_flight -= 1 - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - def handler(_ctx: HTTPReqCtx): - return body_handler - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=1, backlog=2) as lt: - await lt.started - await _wait_server_ready(free_port) - - async with anyio.create_task_group() as tg: - for _ in range(3): - tg.start_soon(_get, f"http://127.0.0.1:{free_port}/") - - assert peak == 1 - - lt.shutdown() - await lt.stopped - async def test_shutdown_cancels_inflight(self, free_port): """Triggering shutdown while a handler is running cancels it via the HTTP cancellation token. @@ -235,7 +198,7 @@ def handler(_ctx: HTTPReqCtx): return body_handler cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: + async with _serve_pooled(cfg, handler) as lt: await lt.started await _wait_server_ready(free_port) @@ -277,7 +240,7 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: router = routes.build() cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, router.as_handler(), max_concurrency=4) as lt: + async with _serve_pooled(cfg, router.as_handler()) as lt: await lt.started await _wait_server_ready(free_port) @@ -291,192 +254,6 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: lt.shutdown() await lt.stopped - async def test_invalid_max_concurrency(self): - with pytest.raises(ValueError, match="max_concurrency"): - async with thread_pool_handler(_handler_200(), max_concurrency=0): - pass - - async def test_invalid_backlog(self): - with pytest.raises(ValueError, match="backlog"): - async with thread_pool_handler(_handler_200(), max_concurrency=1, backlog=-1): - pass - - -class TestBacklogAdmission: - """``backlog`` controls the channel buffer between selector and workers. - - Default ``backlog=0`` is rendezvous: the selector's ``put_nowait`` only - succeeds when a worker is currently waiting on ``Channel.get()``; - otherwise the request is rejected with 503 immediately. ``backlog=K`` - admits up to ``max_concurrency + K`` requests before 503s start. - """ - - async def test_rendezvous_default_rejects_when_workers_busy(self, free_port): - """backlog=0, max_concurrency=1: a 2nd concurrent request 503s - immediately because no worker is waiting on get().""" - entered = threading.Event() - release = threading.Event() - - def body_handler(ctx: HTTPReqCtx): - entered.set() - release.wait(timeout=5.0) - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - def handler(_ctx: HTTPReqCtx): - return body_handler - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: - await lt.started - await _wait_server_ready(free_port) - - try: - # Block the only worker. - async def slow(): - return await _get(f"http://127.0.0.1:{free_port}/", timeout=5.0) - - async with anyio.create_task_group() as tg: - - async def hold_first(): - await slow() - - tg.start_soon(hold_first) - - # Wait until the first request is in the worker. - await to_thread.run_sync(lambda: entered.wait(2.0)) - - # Second request: no worker waiting → 503 immediately. - r = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) - assert r.status_code == 503 - - release.set() - finally: - release.set() - - lt.shutdown() - await lt.stopped - - async def test_rendezvous_idle_worker_dispatches_normally(self, free_port): - """backlog=0 must not 503 when a worker IS waiting. Sequential - requests on max_concurrency=1 are the regression check — each - request finishes before the next arrives, so the worker is always - idle on get() at dispatch time.""" - - def handler(ctx: HTTPReqCtx): - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: - await lt.started - await _wait_server_ready(free_port) - - for _ in range(5): - r = await _get(f"http://127.0.0.1:{free_port}/", timeout=2.0) - assert r.status_code == 200 - - lt.shutdown() - await lt.stopped - - async def test_backlog_admits_extra_requests(self, free_port): - """backlog=2 with max_concurrency=2: 4 concurrent slow requests all - succeed (2 in flight + 2 buffered).""" - entered = threading.Semaphore(0) - release = threading.Event() - - def body_handler(ctx: HTTPReqCtx): - entered.release() - release.wait(timeout=5.0) - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - def handler(_ctx: HTTPReqCtx): - return body_handler - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=2, backlog=2) as lt: - await lt.started - await _wait_server_ready(free_port) - - results: list[httpx.Response | None] = [None] * 4 - - async def fire(i: int) -> None: - results[i] = await _get(f"http://127.0.0.1:{free_port}/", timeout=5.0) - - try: - async with anyio.create_task_group() as tg: - for i in range(4): - tg.start_soon(fire, i) - - # Two workers run concurrently; the other two sit in the - # backlog. Wait for the first wave to enter, then release. - def wait_for_two(): - for _ in range(2): - assert entered.acquire(timeout=5.0) - - await to_thread.run_sync(wait_for_two) - release.set() - finally: - release.set() - - for r in results: - assert r is not None - assert r.status_code == 200 - - lt.shutdown() - await lt.stopped - - async def test_backlog_overflow_rejects_with_503(self, free_port): - """backlog=1 with max_concurrency=1: 1 worker + 1 buffered = 2 - capacity. A 3rd concurrent request is rejected with 503.""" - entered = threading.Event() - release = threading.Event() - - def body_handler(ctx: HTTPReqCtx): - entered.set() - release.wait(timeout=5.0) - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - def handler(_ctx: HTTPReqCtx): - return body_handler - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=1, backlog=1) as lt: - await lt.started - await _wait_server_ready(free_port) - - sockets: list[socket.socket] = [] - - def open_slow() -> socket.socket: - sock = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) - sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") - return sock - - try: - # Saturate worker. - sockets.append(await to_thread.run_sync(open_slow)) - assert await to_thread.run_sync(lambda: entered.wait(2.0)) - # Saturate the single backlog slot. - sockets.append(await to_thread.run_sync(open_slow)) - await anyio.sleep(0.1) - - # 3rd request must 503. - def probe() -> bytes: - s = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) - sockets.append(s) - s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") - return read_http_response(s, deadline=2.0) - - response = await to_thread.run_sync(probe) - assert b"HTTP/1.1 503" in response, response - finally: - release.set() - for s in sockets: - with contextlib.suppress(OSError): - s.close() - - lt.shutdown() - await lt.stopped - - class TestMultiSelector: """``selectors=N > 1`` spawns N independent ``BaseServer`` threads bound to the same address via ``SO_REUSEPORT``. Tests assert correctness; the @@ -509,9 +286,7 @@ async def test_serves_requests_pooled_concurrent(self, free_port): """selectors=4 + thread pool. Multiple selectors push onto the single shared channel; the pool drains across all producers.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - # backlog=8: with 10 fast requests against max_concurrency=4, headroom keeps the - # rendezvous race from intermittently 503'ing late arrivals during the test. - async with thread_pool_handler(_handler_200(b"hi"), max_concurrency=4, backlog=8) as wrapped: + async with thread_pool_handler(_handler_200(b"hi")) as wrapped: async with serve(http_server(cfg, wrapped, selectors=4)) as lt: await lt.started await _wait_server_ready(free_port) @@ -659,13 +434,9 @@ async def test_serves_requests_pooled(self, free_port): actual owning selector. """ cfg = ServerConfig(host="127.0.0.1", port=free_port) - # backlog=8 keeps the rendezvous race from intermittently 503'ing late - # arrivals while the pool has spare capacity. async with _serve_pooled( cfg, _handler_200(b"hi"), - max_concurrency=4, - backlog=8, selectors=4, acceptor=True, ) as lt: @@ -784,7 +555,7 @@ def work(_ctx: HTTPReqCtx) -> None: ran.set() try: - async with streaming_pool_handler(work, max_concurrency=1) as wrapped: + async with streaming_pool_handler(work) as wrapped: assert wrapped(cast(HTTPReqCtx, ctx)) is None assert ctx.deferred is not None assert selector.stopped is False @@ -798,121 +569,6 @@ def work(_ctx: HTTPReqCtx) -> None: sock.close() peer.close() - async def test_max_concurrency_caps_parallelism(self, free_port): - """N+1 requests against max_concurrency=N: peak in-flight is exactly N.""" - in_flight = 0 - peak = 0 - lock = threading.Lock() - gate = threading.Event() - - def body_handler(ctx: HTTPReqCtx): - nonlocal in_flight, peak - with lock: - in_flight += 1 - peak = max(peak, in_flight) - gate.wait(timeout=5.0) - with lock: - in_flight -= 1 - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - def handler(_ctx: HTTPReqCtx): - return body_handler - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=3, backlog=2) as lt: - await lt.started - await _wait_server_ready(free_port) - - async with anyio.create_task_group() as tg: - for _ in range(5): - tg.start_soon(_get, f"http://127.0.0.1:{free_port}/") - - # Once 3 handlers are in flight, the cap is observable. Wait for it - # before releasing the gate so we don't race the "still ramping up" state. - async def wait_for_peak() -> None: - while True: - with lock: - if peak >= 3: - return - await anyio.sleep(0.02) - - with anyio.fail_after(5.0): - await wait_for_peak() - gate.set() - - assert peak == 3, f"expected peak 3, got {peak}" - - lt.shutdown() - await lt.stopped - - async def test_pool_overload_rejects_without_blocking_selector(self, free_port): - """A full worker pool + full queue returns 503 instead of blocking the selector.""" - entered = threading.Event() - release = threading.Event() - - def body_handler(ctx: HTTPReqCtx): - entered.set() - release.wait(timeout=5.0) - ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - - routes = Routes() - - @routes.get("/slow") - def slow(_ctx: HTTPReqCtx) -> BodyHandler: - return body_handler - - cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, routes.build().as_handler(), max_concurrency=1, backlog=1) as lt: - await lt.started - await _wait_server_ready(free_port) - - sockets: list[socket.socket] = [] - - def open_slow() -> socket.socket: - sock = socket.create_connection(("127.0.0.1", free_port), timeout=2.0) - sock.sendall(b"GET /slow HTTP/1.1\r\nHost: x\r\n\r\n") - return sock - - def open_slow_and_try_read() -> tuple[socket.socket, bytes]: - sock = open_slow() - try: - return sock, read_http_response(sock, deadline=0.5) - except TimeoutError: - return sock, b"" - - try: - sockets.append(await to_thread.run_sync(open_slow)) - assert await to_thread.run_sync(lambda: entered.wait(2.0)) - - # This request occupies the single pending queue slot while - # the first request keeps the only worker busy. - sockets.append(await to_thread.run_sync(open_slow)) - await anyio.sleep(0.1) - - overload_response = b"" - for _ in range(4): - sock, response = await to_thread.run_sync(open_slow_and_try_read) - sockets.append(sock) - if b"HTTP/1.1 503" in response: - overload_response = response - break - - assert b"HTTP/1.1 503" in overload_response, overload_response - assert b"Service Unavailable" in overload_response - - # The selector must still be responsive while the worker and - # pending queue are saturated. A miss is answered inline. - r = await _get(f"http://127.0.0.1:{free_port}/missing", timeout=1.0) - assert r.status_code == 404 - finally: - release.set() - for sock in sockets: - with contextlib.suppress(OSError): - sock.close() - - lt.shutdown() - await lt.stopped - async def test_handler_exception_returns_500_and_service_stays_up(self, free_port): """A handler exception is caught at the connection level and returned as 500. @@ -923,7 +579,7 @@ def boom(_: HTTPReqCtx) -> None: raise RuntimeError("handler crashed") cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, boom, max_concurrency=2) as lt: + async with _serve_pooled(cfg, boom) as lt: await lt.started await _wait_server_ready(free_port) @@ -937,18 +593,15 @@ def boom(_: HTTPReqCtx) -> None: assert lt.exit_code == 0 - async def test_slot_released_after_normal_request(self, free_port): - """Repeatedly hitting a 1-slot pool must keep working. - - Indirectly confirms the worker channel slot is released on the success - path — if it weren't, the second request would block forever. - """ + async def test_repeated_requests_keep_working(self, free_port): + """Repeatedly hitting the pool must keep working — confirms workers + re-park (back into the shared idle deque) after each task.""" def handler(ctx: HTTPReqCtx): ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=1) as lt: + async with _serve_pooled(cfg, handler) as lt: await lt.started await _wait_server_ready(free_port) @@ -974,9 +627,7 @@ def body_handler(ctx: HTTPReqCtx): def handler(_ctx: HTTPReqCtx): return body_handler - # backlog gives 10 fast requests headroom past the rendezvous default, - # so this test isolates the dispatch-distribution check from admission timing. - async with _serve_pooled(cfg, handler, max_concurrency=8, backlog=4) as lt: + async with _serve_pooled(cfg, handler) as lt: await lt.started await _wait_server_ready(free_port) @@ -1027,7 +678,7 @@ def handler(_ctx: HTTPReqCtx): return body_handler cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with _serve_pooled(cfg, handler, max_concurrency=2) as lt: + async with _serve_pooled(cfg, handler) as lt: await lt.started await _wait_server_ready(free_port) diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py new file mode 100644 index 0000000..e1a5d75 --- /dev/null +++ b/tests/threadtools/thread_task_group.py @@ -0,0 +1,299 @@ +"""Tests for ``localpost.threadtools.ThreadTaskGroup``.""" + +import threading +import time +from concurrent.futures import Future + +import pytest + +from localpost import threadtools +from localpost.threadtools import ThreadTaskGroup + + +@pytest.fixture +def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch): + """Make idle-timeout tests fast: 100 ms instead of 60 s.""" + monkeypatch.setattr(threadtools, "_IDLE_TIMEOUT", 0.1) + yield 0.1 + + +def _drain_idle_workers() -> None: + """Wait for the global idle deque to empty. + + Tests that monkeypatch ``_IDLE_TIMEOUT`` to a small value rely on this + so they don't leak workers (or interact with each other through the + shared ``_idle`` deque). + """ + deadline = time.monotonic() + 2.0 + while threadtools._idle and time.monotonic() < deadline: + time.sleep(0.05) + + +# --------------------------------------------------------------------------- +# Basic submit / result +# --------------------------------------------------------------------------- + + +def test_start_soon_returns_future_with_result(): + with ThreadTaskGroup() as tg: + fut = tg.start_soon(lambda x: x * 2, 21) + assert isinstance(fut, Future) + assert fut.result(timeout=5) == 42 + + +def test_start_soon_with_kwargs(): + with ThreadTaskGroup() as tg: + fut = tg.start_soon(lambda *, a, b: a + b, a=1, b=2) + assert fut.result(timeout=5) == 3 + + +def test_many_concurrent_tasks_all_complete(): + n = 50 + + def work(i: int) -> int: + time.sleep(0.01) + return i * i + + with ThreadTaskGroup() as tg: + futs = [tg.start_soon(work, i) for i in range(n)] + + assert sorted(f.result() for f in futs) == [i * i for i in range(n)] + + +# --------------------------------------------------------------------------- +# Exception capture +# --------------------------------------------------------------------------- + + +def test_task_exception_captured_in_future_and_raised_at_exit(): + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + with pytest.raises(ExceptionGroup) as ei: + with ThreadTaskGroup() as tg: + fut = tg.start_soon(bad) + # Future records the exception too + with pytest.raises(Boom): + fut.result(timeout=5) + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], Boom) + + +def test_body_exception_alone_is_wrapped(): + class BodyBoom(Exception): + pass + + with pytest.raises(ExceptionGroup) as ei: + with ThreadTaskGroup(): + raise BodyBoom("body") + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], BodyBoom) + + +def test_body_and_task_exceptions_are_merged(): + class Body(Exception): + pass + + class Task(Exception): + pass + + def bad(): + raise Task("task") + + with pytest.raises(ExceptionGroup) as ei: + with ThreadTaskGroup() as tg: + tg.start_soon(bad) + time.sleep(0.05) # let the task land + raise Body("body") + types = {type(e) for e in ei.value.exceptions} + assert types == {Body, Task} + + +def test_named_group_uses_name_in_exception_message(): + def bad(): + raise RuntimeError("x") + + with pytest.raises(ExceptionGroup, match="ThreadTaskGroup 'pool-x' failed"): + with ThreadTaskGroup(name="pool-x") as tg: + tg.start_soon(bad) + + +# --------------------------------------------------------------------------- +# Drain semantics +# --------------------------------------------------------------------------- + + +def test_exit_blocks_until_all_tasks_complete(): + started = threading.Event() + release = threading.Event() + finished = threading.Event() + + def slow(): + started.set() + release.wait(timeout=5) + finished.set() + + with ThreadTaskGroup() as tg: + tg.start_soon(slow) + assert started.wait(timeout=2) + assert not finished.is_set() + release.set() + # Past the with block: drain has completed. + assert finished.is_set() + + +def test_nested_start_soon_during_drain(): + """A task can spawn more tasks; drain waits for the whole transitive set.""" + counter = 0 + counter_lock = threading.Lock() + + def leaf(): + nonlocal counter + with counter_lock: + counter += 1 + + def branch(tg: ThreadTaskGroup, depth: int): + if depth == 0: + leaf() + return + # Each branch spawns 2 children of depth-1, waits for them. + f1 = tg.start_soon(branch, tg, depth - 1) + f2 = tg.start_soon(branch, tg, depth - 1) + f1.result(timeout=5) + f2.result(timeout=5) + leaf() + + with ThreadTaskGroup() as tg: + tg.start_soon(branch, tg, 3) + + # depth=3 → 1 + 2 + 4 + 8 = 15 leaves + assert counter == 15 + + +def test_start_soon_after_close_raises(): + tg = ThreadTaskGroup() + with tg: + tg.start_soon(lambda: None).result(timeout=5) + with pytest.raises(RuntimeError, match="closed"): + tg.start_soon(lambda: None) + + +def test_group_cannot_be_reused(): + tg = ThreadTaskGroup() + with tg: + pass + with pytest.raises(RuntimeError, match="cannot be reused"): + with tg: + pass + + +# --------------------------------------------------------------------------- +# Worker reuse / shared pool +# --------------------------------------------------------------------------- + + +def test_workers_are_shared_across_groups(fast_idle_timeout): + """A worker idle from one group should be reused by another.""" + seen: set[int] = set() + + def record(): + seen.add(threading.get_ident()) + + with ThreadTaskGroup() as tg: + for _ in range(4): + tg.start_soon(record).result(timeout=5) + first_run = set(seen) + + # Second group sees at least some of the same threads (workers parked + # in the global _idle deque). + seen.clear() + with ThreadTaskGroup() as tg: + for _ in range(4): + tg.start_soon(record).result(timeout=5) + second_run = set(seen) + + assert first_run & second_run, "no worker reuse across groups" + _drain_idle_workers() + + +def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): + # Drop any leftover workers from earlier tests — they were spawned before + # the monkeypatch and are stuck on their original 60 s ``inbox.get``. + # Orphaning them is safe (daemon threads, won't be reused). + threadtools._idle.clear() + + # Spawn a fresh worker and grab a reference to it. + with ThreadTaskGroup() as tg: + tg.start_soon(lambda: None).result(timeout=5) + assert len(threadtools._idle) >= 1 + fresh = threadtools._idle[-1] # LIFO: most recently parked + + # Wait past the idle timeout. + time.sleep(fast_idle_timeout * 10) + assert fresh._state == "dead" + + # Submitting again skips the dead tombstone and spawns fresh. + with ThreadTaskGroup() as tg: + tg.start_soon(lambda: None).result(timeout=5) + _drain_idle_workers() + + +# --------------------------------------------------------------------------- +# Race / stress +# --------------------------------------------------------------------------- + + +def test_claim_vs_idle_timeout_race(fast_idle_timeout): + """Stress the pop+claim vs self-die race. + + Idle workers may time out *exactly* as a dispatcher pops them. The + dispatcher must either (a) successfully claim a still-alive worker, + or (b) see a tombstone and try the next one / spawn fresh. No task + should be lost. + """ + n = 200 + counter = 0 + lock = threading.Lock() + + def tick(): + nonlocal counter + with lock: + counter += 1 + + for _ in range(5): + with ThreadTaskGroup() as tg: + futs = [tg.start_soon(tick) for _ in range(n)] + for f in futs: + f.result(timeout=5) + # Sleep to let some workers idle-time-out, leaving tombstones. + time.sleep(fast_idle_timeout * 1.5) + + assert counter == n * 5 + _drain_idle_workers() + + +def test_start_soon_from_arbitrary_thread(): + """``start_soon`` must be safe to call from any thread.""" + results: list[int] = [] + results_lock = threading.Lock() + + def producer(tg: ThreadTaskGroup): + for i in range(10): + tg.start_soon(_record, i, results, results_lock) + + with ThreadTaskGroup() as tg: + threads = [threading.Thread(target=producer, args=(tg,)) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sorted(results) == sorted(list(range(10)) * 5) + + +def _record(i: int, sink: list[int], lock: threading.Lock) -> None: + with lock: + sink.append(i) From 90d728f6ed0ca10e98698a3dd4ad2ab5cce2e14a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 5 May 2026 20:11:17 +0400 Subject: [PATCH 182/286] refactor(threadtools): simpler _Worker, lock-free _record_error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Worker drops the 3-state machine (idle/busy/dead + claim()) for a 2-state design (_alive: bool + merged submit() that returns False on tombstone). Lost-notify race fixed by re-checking the inbox under the lock both before waiting and after wait() returns False. _alive is set under _cv before the lock is released, so it is strictly safer than checking Thread.is_alive() (which has a release-then-stop window where the lock is free but the thread is still alive per the OS). ThreadTaskGroup._errors switches from list to deque — append is documented thread-safe vs. list.append being only atomic by CPython implementation accident — and the explicit lock around the append is gone (memory visibility is provided by _task_done's _cv acquire). examples/http/static_files.py collapses two thread_pool_handler wrappers into one now that workers are shared globally. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/static_files.py | 20 ++--- localpost/threadtools.py | 114 ++++++++++++------------- tests/threadtools/thread_task_group.py | 2 +- 3 files changed, 66 insertions(+), 70 deletions(-) diff --git a/examples/http/static_files.py b/examples/http/static_files.py index 1b1945f..0335755 100644 --- a/examples/http/static_files.py +++ b/examples/http/static_files.py @@ -1,4 +1,4 @@ -"""Static-file server with a separate worker pool from the API. +"""Static-file server alongside an API on the same worker pool. Run:: @@ -9,9 +9,9 @@ curl -I http://localhost:8000/static/ # HEAD: just headers curl -H 'Range: bytes=0-99' http://localhost:8000/static/ -The static handler uses ``socket.sendfile()`` for the body (zero-copy) -and lives in its own thread pool sized for I/O-bound concurrency, so a -slow client downloading a large file can't pin the small API pool. +The static handler uses ``socket.sendfile()`` for the body (zero-copy). +Both API and static handlers go through the same ``thread_pool_handler`` +— workers come from the process-wide pool and grow on demand. """ from __future__ import annotations @@ -66,15 +66,13 @@ async def app(): prefix=b"/static/", cache_control="public, max-age=3600", ) + api = build_api() - async with ( - thread_pool_handler(build_api()) as api_h, - thread_pool_handler(static) as static_h, - ): - def root_handler(ctx: HTTPReqCtx) -> BodyHandler | None: - return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) + def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: + return (static if ctx.request.path.startswith(b"/static/") else api)(ctx) - async with http_server(config, root_handler): + async with thread_pool_handler(dispatch) as wrapped: + async with http_server(config, wrapped): yield diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 84da4c8..7f13313 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -1,13 +1,12 @@ from __future__ import annotations import dataclasses as dc -import queue import threading import time from collections import deque from collections.abc import Callable, Iterator from concurrent.futures import Future -from typing import Any, Literal, Protocol, Self, final, override +from typing import Any, Protocol, Self, final, override from anyio import ( ClosedResourceError, @@ -417,60 +416,53 @@ def run(self) -> None: @final class _Worker: - """Idle-tracked worker thread with a 1-slot inbox. + """Idle-tracked worker thread with a per-worker inbox. - State machine: ``busy`` (running a task or freshly spawned) → ``idle`` - (re-parked, in the global ``_idle`` deque) → ``busy`` again on claim, - or ``dead`` if the inbox stays empty for ``_IDLE_TIMEOUT`` seconds. - - Idle workers stay listed in ``_idle`` until a dispatcher pops them. - A self-died worker leaves a tombstone in the deque; ``claim()`` - returns ``False`` for tombstones so the dispatcher pops the next - one (or spawns a fresh worker). + Lifecycle: ``alive`` until the thread self-exits after ``_IDLE_TIMEOUT`` + seconds with no work, after which the worker becomes a tombstone in the + global ``_idle`` deque. The dispatcher detects tombstones via + :meth:`submit` returning ``False`` and pops the next worker / spawns a + fresh one. """ - __slots__ = ("_inbox", "_lock", "_state", "_thread") + __slots__ = ("_alive", "_cv", "_inbox") def __init__(self) -> None: - self._inbox: queue.Queue[_Task] = queue.Queue(maxsize=1) - self._lock = threading.Lock() - # Born busy: spawned only with a task imminent (skips claim()). - self._state: Literal["busy", "idle", "dead"] = "busy" - self._thread = threading.Thread(target=self._run, daemon=True, name="localpost-worker") - self._thread.start() - - def claim(self) -> bool: - """Atomically transition idle → busy. Returns ``False`` for tombstones.""" - with self._lock: - if self._state == "idle": - self._state = "busy" - return True - return False - - def submit(self, task: _Task) -> None: - """Hand off a task to a worker that's already been claimed.""" - # Inbox capacity is 1; a claimed worker has an empty inbox by construction - # (the previous task was already dequeued before re-parking). - self._inbox.put(task) + self._inbox: deque[_Task] = deque() + self._cv = threading.Condition(threading.Lock()) + # Set under ``_cv`` before lock release in ``_run``; that ordering is + # what makes ``submit`` race-free vs ``Thread.is_alive()``, which can + # still report ``True`` after ``_run`` has released the lock but + # before CPython's bootstrap marks the thread stopped. + self._alive = True + threading.Thread(target=self._run, daemon=True, name="localpost-worker").start() + + def submit(self, task: _Task) -> bool: + """Hand off a task. Returns ``False`` if the worker has self-exited.""" + with self._cv: + if not self._alive: + return False + self._inbox.append(task) + self._cv.notify() + return True def _run(self) -> None: while True: - try: - task = self._inbox.get(timeout=_IDLE_TIMEOUT) - except queue.Empty: - with self._lock: - if self._state == "idle": - # Genuinely idle on timeout — die. Tombstone stays in - # the deque; the next dispatcher to pop it will retry. - self._state = "dead" - return - # Claimed during the timeout race — fall through and consume - # the put that's about to land in the inbox. - continue + with self._cv: + # Re-check inbox after each wait — covers both spurious wakeups + # and the lost-notify race where a submit lands between the + # outer ``inbox.popleft`` and the wait re-acquiring the lock. + while not self._inbox: + if not self._cv.wait(timeout=_IDLE_TIMEOUT): + # Timed out. One more inbox check under the lock to + # close the timeout-vs-notify race. + if not self._inbox: + self._alive = False + return + break + task = self._inbox.popleft() task.run() - with self._lock: - self._state = "idle" - _idle.append(self) + _idle.append(self) _idle: deque[_Worker] = deque() @@ -515,7 +507,12 @@ def __init__(self, *, name: str | None = None) -> None: self._lock = threading.Lock() self._cv = threading.Condition(self._lock) self._pending = 0 - self._errors: list[BaseException] = [] + # ``deque.append`` is documented thread-safe (vs ``list.append`` which + # is only atomic by CPython implementation accident). Workers append + # without holding any group-level lock; ``__exit__`` reads after + # drain, so the ``_cv`` release/acquire in ``_task_done`` provides + # the visibility barrier. + self._errors: deque[BaseException] = deque() self._closed = False def __enter__(self) -> Self: @@ -555,15 +552,13 @@ def start_soon[**P, R]( try: w = _idle.pop() except IndexError: - # No idle worker — spawn one. It starts in ``busy`` state, so - # we skip ``claim()`` and submit directly. - w = _Worker() - break - if w.claim(): - break + # No idle worker — spawn a fresh one. ``submit`` on a fresh + # worker is guaranteed to succeed (``_alive=True``). + _Worker().submit(task) + return fut + if w.submit(task): + return fut # Tombstone (self-died on idle timeout); pop the next one. - w.submit(task) - return fut def _task_done(self) -> None: with self._cv: @@ -572,5 +567,8 @@ def _task_done(self) -> None: self._cv.notify_all() def _record_error(self, exc: BaseException) -> None: - with self._lock: - self._errors.append(exc) + # No lock needed: ``list.append`` is atomic in CPython (GIL or + # free-threaded per-list mutex). Memory visibility to ``__exit__`` + # is established by ``_task_done``'s acquire of ``_cv``, which always + # runs after ``_record_error`` in the same task. + self._errors.append(exc) diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index e1a5d75..5cc5fc8 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -233,7 +233,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): # Wait past the idle timeout. time.sleep(fast_idle_timeout * 10) - assert fresh._state == "dead" + assert fresh._alive is False # Submitting again skips the dead tombstone and spawns fresh. with ThreadTaskGroup() as tg: From 3dd7bcf7e67dfdbc25264600f3571c8fdd02ceb6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 5 May 2026 20:21:40 +0400 Subject: [PATCH 183/286] feat(threadtools): propagate contextvars across ThreadTaskGroup tasks Each start_soon snapshots the caller's contextvars.Context with copy_context() and the worker runs the user callable inside that snapshot via Context.run. Mutations stay confined to the task's copy. Per-call (not per-group) capture matches asyncio.create_task, trio nursery.start_soon, and asyncio.to_thread semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools.py | 16 +++++- tests/threadtools/thread_task_group.py | 69 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 7f13313..e4f5559 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextvars import dataclasses as dc import threading import time @@ -400,11 +401,14 @@ class _Task: args: tuple[Any, ...] kwargs: dict[str, Any] group: ThreadTaskGroup + context: contextvars.Context + """Snapshot of the caller's ContextVars at ``start_soon`` time. The user + callable runs inside this context; mutations are confined to the task.""" def run(self) -> None: try: try: - result = self.fn(*self.args, **self.kwargs) + result = self.context.run(self.fn, *self.args, **self.kwargs) except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything self.future.set_exception(exc) self.group._record_error(exc) @@ -498,6 +502,12 @@ class ThreadTaskGroup: or exception. Reading the future is optional — a task exception is surfaced via the ``ExceptionGroup`` raised at ``__exit__`` even if the future is discarded. + + ``contextvars`` are propagated: each ``start_soon`` snapshots the + caller's context with :func:`contextvars.copy_context`, and the task + runs inside that snapshot. Mutations the task makes to ContextVars + stay confined to its copy — same semantics as + :func:`asyncio.to_thread` and Trio / AnyIO task spawn. """ __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending") @@ -547,7 +557,9 @@ def start_soon[**P, R]( raise RuntimeError("ThreadTaskGroup is closed") self._pending += 1 fut: Future[R] = Future() - task = _Task(fut, fn, args, kwargs, self) + # Snapshot the caller's context now (matches Trio / AnyIO / asyncio + # ``to_thread`` semantics); each ``start_soon`` captures independently. + task = _Task(fut, fn, args, kwargs, self, contextvars.copy_context()) while True: try: w = _idle.pop() diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 5cc5fc8..1dce3a6 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -1,5 +1,6 @@ """Tests for ``localpost.threadtools.ThreadTaskGroup``.""" +import contextvars import threading import time from concurrent.futures import Future @@ -297,3 +298,71 @@ def producer(tg: ThreadTaskGroup): def _record(i: int, sink: list[int], lock: threading.Lock) -> None: with lock: sink.append(i) + + +# --------------------------------------------------------------------------- +# ContextVar propagation +# --------------------------------------------------------------------------- + + +def test_task_sees_caller_context(): + """A ContextVar set in the caller is visible inside the task.""" + var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var") + var.set("caller-value") + + with ThreadTaskGroup() as tg: + fut = tg.start_soon(var.get) + + assert fut.result(timeout=5) == "caller-value" + + +def test_task_mutation_does_not_leak_to_caller(): + """ContextVar.set inside a task is confined to the task's context copy.""" + var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var", default="original") + + def mutate(): + var.set("task-mutated") + return var.get() + + with ThreadTaskGroup() as tg: + fut = tg.start_soon(mutate) + + assert fut.result(timeout=5) == "task-mutated" + assert var.get() == "original" # caller's view unchanged + + +def test_each_start_soon_captures_independently(): + """Two ``start_soon`` calls see different values when the body mutates + the ContextVar between them — capture is per-call, not per-group.""" + var: contextvars.ContextVar[int] = contextvars.ContextVar("test_var", default=0) + + with ThreadTaskGroup() as tg: + var.set(1) + f1 = tg.start_soon(var.get) + var.set(2) + f2 = tg.start_soon(var.get) + + assert f1.result(timeout=5) == 1 + assert f2.result(timeout=5) == 2 + + +def test_concurrent_tasks_see_their_own_context(): + """N concurrent tasks each captured a different ContextVar value; + each must see its own, not another task's.""" + var: contextvars.ContextVar[int] = contextvars.ContextVar("test_var") + + barrier = threading.Barrier(10) + + def observe() -> int: + # Sync all tasks at the same wall-clock moment so they're guaranteed + # to be running concurrently when they read the ContextVar. + barrier.wait(timeout=5) + return var.get() + + with ThreadTaskGroup() as tg: + futs: list[Future[int]] = [] + for i in range(10): + var.set(i) + futs.append(tg.start_soon(observe)) + + assert sorted(f.result(timeout=5) for f in futs) == list(range(10)) From 52e258540f34821c92207cfc8ba24a9bca0bcde4 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 00:46:18 +0400 Subject: [PATCH 184/286] refactor(http)!: slim HTTPReqCtx; add remote_addr / local_addr / scheme / stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare HTTPReqCtx for non-native transports (WSGI deployment in a follow-up). The shape now mirrors Granian's RSGI scope — single ctx object, methods drive the response 0→100, addressing exposed as ``"host:port"`` strings, scheme reified. Public Protocol changes: - Drop ``selector`` / ``conn`` from ``HTTPReqCtx``. Internal callers (``_pool.py``, ``emit_handler_error``) re-typed against a private ``_NativeReqCtx(HTTPReqCtx)`` Protocol that adds ``conn`` back. - Add ``remote_addr: str | None``, ``local_addr: str``, ``scheme: str``. Native ctx populates from ``conn.addr`` / ``selector.config``; ``wrap_wsgi``'s ``_build_environ`` now consumes them and also fills ``REMOTE_ADDR`` / ``REMOTE_PORT``. - ``borrowed`` / ``borrow()`` stay on the Protocol; on transports that always own the connection these become ``True`` / a no-op CM. New ``HTTPReqCtx.stream(response, chunks)``: - Declarative streaming — transport owns iteration, picks its own pacing / cancellation policy. Native impl (``native_stream``) uses the imperative trio with per-chunk ``check_cancelled``. - Sugar over ``start_response`` / ``send`` / ``finish_response``; the imperative API stays for ``compress.py`` and hand-rolled streaming. - ``localpost.openapi._stream_sse`` collapses from a ~30-line manual loop into a single declarative call. User-facing migration: ``ctx.conn.sock.getpeername()[0]`` → ``(ctx.remote_addr or "").rpartition(":")[0]``. Updated the rate-limit middleware example. Plans saved at ``plans/wsgi-deployment.md`` (RSGI alignment, HTTP/2 transparency notes) and ``plans/websocket-support.md`` (sibling ``WebSocketCtx`` Protocol sketch — not implemented). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/middleware_rate_limit.py | 2 +- localpost/http/_base.py | 104 +++++++++-- localpost/http/_pool.py | 22 ++- localpost/http/compress.py | 21 ++- localpost/http/server_h11.py | 19 ++ localpost/http/server_httptools.py | 19 ++ localpost/http/wsgi.py | 17 +- localpost/openapi/operation.py | 41 ++--- plans/websocket-support.md | 94 ++++++++++ plans/wsgi-deployment.md | 229 +++++++++++++++++++++++++ tests/http/app.py | 4 +- 11 files changed, 503 insertions(+), 69 deletions(-) create mode 100644 plans/websocket-support.md create mode 100644 plans/wsgi-deployment.md diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py index f252eda..b65df1d 100644 --- a/examples/http/middleware_rate_limit.py +++ b/examples/http/middleware_rate_limit.py @@ -74,7 +74,7 @@ def rate_limit(max_requests: int = 10, window_seconds: float = 1.0) -> Middlewar def middleware(inner: RequestHandler) -> RequestHandler: def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: - client_ip = ctx.conn.sock.getpeername()[0] + client_ip = (ctx.remote_addr or "").rpartition(":")[0] or "unknown" if not counter.is_allowed(client_ip): ctx.complete(_TOO_MANY, b"") return None diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 5ceb267..99606f5 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -56,7 +56,9 @@ "Selector", "SelectorCallback", "TrackHere", + "_NativeReqCtx", "compose", + "native_stream", "start_http_server", ] @@ -168,22 +170,28 @@ def _build_canned(status_code: int, body: bytes) -> tuple[Response, bytes]: class HTTPReqCtx(Protocol): """Per-request context handed to a :data:`RequestHandler`. - Both server backends populate concrete implementations of this Protocol - with the same observable surface. ``selector`` and ``conn`` give access - to the underlying selector / connection for advanced use cases — e.g. - :func:`thread_pool_handler` reaches into them to borrow the connection - for a worker. They're declared as read-only properties so covariant - subtypes (each backend's concrete ``HTTPConn`` from ``server_h11`` / - ``server_httptools``) satisfy the Protocol. - - The ``body`` attribute is empty when a :data:`RequestHandler` runs - (pre-body phase). It is populated by the selector with the fully - buffered request body before invoking a returned :data:`BodyHandler`. - - The ``attrs`` mapping is per-request mutable state for cross-cutting - concerns to thread information through. :class:`localpost.http.Router` - writes the matched ``RouteMatch`` here; middlewares can attach auth - info, tracing transactions, etc. + Transport-agnostic surface — implemented by both the native + ``localpost.http`` server backends and external transports + (e.g. :func:`localpost.http.wsgi.to_wsgi`'s WSGI bridge). + + ``body`` is empty when a :data:`RequestHandler` runs (pre-body phase). + It is populated by the selector / WSGI adapter with the fully + buffered request body before a returned :data:`BodyHandler` runs. + + ``attrs`` is per-request mutable state for cross-cutting concerns + to thread information through. :class:`localpost.http.Router` writes + the matched ``RouteMatch`` here; middlewares can attach auth info, + tracing transactions, etc. + + The ``remote_addr`` / ``local_addr`` / ``scheme`` fields mirror + Granian RSGI's ``scope.client`` / ``scope.server`` / ``scope.scheme`` + — addresses are formatted as ``"host:port"``. ``remote_addr`` is + ``None`` when the peer address isn't available (rare; e.g. some + UNIX-domain transports). + + ``borrowed`` / :meth:`borrow` are degenerate on transports that + don't manage their own connection lifetime (the WSGI bridge always + reports ``borrowed=True`` and :meth:`borrow` is a no-op CM). """ request: Request @@ -192,9 +200,11 @@ class HTTPReqCtx(Protocol): attrs: dict[Any, Any] @property - def selector(self) -> Selector: ... + def remote_addr(self) -> str | None: ... @property - def conn(self) -> BaseHTTPConn: ... + def local_addr(self) -> str: ... + @property + def scheme(self) -> str: ... @property def borrowed(self) -> bool: ... def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... @@ -203,6 +213,19 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: def send(self, chunk: Buffer, /) -> None: ... def finish_response(self) -> None: ... def complete(self, response: Response, body: bytes | None = None) -> None: ... + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + """Emit ``response`` then drain ``chunks`` to the wire. + + Declarative streaming — the transport owns iteration, so it can + choose its own pacing / cancellation policy. The native server + checks :func:`localpost.http.check_cancelled` between chunks and + returns silently if the client has gone away. External transports + (WSGI bridge) hand the iterator straight to their host server. + + Sugar over :meth:`start_response` + :meth:`send` (per-chunk) + + :meth:`finish_response`. Pure-imperative callers still have the + trio available; ``stream`` is the path most operations want. + """ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: """Emit ``response`` and stream ``count`` bytes from ``file`` starting at ``offset`` via :func:`socket.sendfile` (zero-copy). Terminal — like @@ -217,6 +240,18 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) """ +class _NativeReqCtx(HTTPReqCtx, Protocol): + """Internal Protocol — the native ``localpost.http`` ctx. + + Adds back the connection handle that internal callers (worker pool, + handler-error recovery) need. External transports (WSGI / ASGI) do + not implement this — they only satisfy :class:`HTTPReqCtx`. + """ + + @property + def conn(self) -> BaseHTTPConn: ... + + BodyHandler = Callable[[HTTPReqCtx], None] """Continuation invoked by the selector after the full request body has been buffered into ``ctx.body``. Must complete the response (or borrow the conn).""" @@ -319,13 +354,44 @@ def _send_all(conn: BaseHTTPConn, payload: bytes | bytearray | memoryview) -> No sent += n -def emit_handler_error(ctx: HTTPReqCtx) -> None: +def native_stream(ctx: HTTPReqCtx, response: Response, chunks: Iterator[bytes]) -> None: + """Default :meth:`HTTPReqCtx.stream` impl on top of the imperative trio. + + Delegates to ``ctx.start_response`` / ``ctx.send`` / ``ctx.finish_response``, + interleaving :func:`localpost.http.check_cancelled` between chunks. On + a clean disconnect (``RequestCancelled``) returns silently — the conn + is in an uncertain state and will be closed by the worker pool. + + ``LookupError`` from ``check_cancelled`` (no request context active — + e.g. on the selector thread) is treated as "no cancellation + available" and silently skipped. + """ + # Local import to avoid the import cycle (cancel imports HTTPReqCtx). + from localpost.http._cancel import RequestCancelled, check_cancelled # noqa: PLC0415 + + ctx.start_response(response) + try: + for chunk in chunks: + try: + check_cancelled() + except LookupError: + pass + ctx.send(chunk) + except RequestCancelled: + return + ctx.finish_response() + + +def emit_handler_error(ctx: _NativeReqCtx) -> None: """Best-effort recovery when a request handler raises. Emits a 500 response if no headers have been sent yet; otherwise closes the connection (we can't go back and prepend a status line to bytes already on the wire). All I/O failures are swallowed — the goal is to avoid amplifying one error into another. + + Native-only. WSGI / ASGI transports surface handler errors through + their own protocol-level error path, not through ``ctx.conn.close()``. """ logger = logging.getLogger(LOGGER_NAME) if ctx.response_status is None: diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index a2154b2..4e3ed1a 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -28,6 +28,7 @@ import threading from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager, suppress +from typing import cast from anyio import to_thread @@ -38,6 +39,7 @@ BodyHandler, HTTPReqCtx, RequestHandler, + _NativeReqCtx, emit_handler_error, ) from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request @@ -49,7 +51,7 @@ # -------------------------------------------------------------------------- -_WorkFn = Callable[[HTTPReqCtx], None] +_WorkFn = Callable[[_NativeReqCtx], None] class _Pool: @@ -70,8 +72,12 @@ def __init__(self, tg: threadtools.ThreadTaskGroup, shutdown_event: threading.Ev def dispatch_buffered(self, body_handler: BodyHandler) -> BodyHandler: """Wrap a :data:`BodyHandler` so when it's invoked post-body it borrows the conn and queues for a worker. The worker runs - ``body_handler(ctx)`` with ``ctx.body`` already populated.""" - return self._make_dispatcher(body_handler) + ``body_handler(ctx)`` with ``ctx.body`` already populated. + + Native-only: the returned ``BodyHandler`` is cast at the boundary + — at runtime the ctx is always a :class:`_NativeReqCtx` because + the pool is composed under :func:`http_server`.""" + return cast(BodyHandler, self._make_dispatcher(body_handler)) def dispatch_streaming(self, handler: _WorkFn) -> RequestHandler: """Wrap a streaming handler so it runs in a worker on a borrowed @@ -83,7 +89,7 @@ def dispatch_streaming(self, handler: _WorkFn) -> RequestHandler: via ``ctx.receive(...)`` and completes the response.""" dispatcher = self._make_dispatcher(handler) - def pre_body(ctx: HTTPReqCtx) -> BodyHandler | None: + def pre_body(ctx: _NativeReqCtx) -> BodyHandler | None: defer = getattr(ctx, "_defer_streaming_dispatch", None) if defer is not None: defer(dispatcher) @@ -91,13 +97,13 @@ def pre_body(ctx: HTTPReqCtx) -> BodyHandler | None: dispatcher(ctx) return None # we borrowed; selector free - return pre_body + return cast(RequestHandler, pre_body) def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: tg = self._tg shutdown_event = self._shutdown_event - def dispatched(ctx: HTTPReqCtx) -> None: + def dispatched(ctx: _NativeReqCtx) -> None: ctx.conn.selector.stop_tracking(ctx.conn) cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) tg.start_soon(_run_request, ctx, cancel, fn) @@ -105,7 +111,7 @@ def dispatched(ctx: HTTPReqCtx) -> None: return dispatched -def _run_request(ctx: HTTPReqCtx, cancel: RequestCancel, fn: _WorkFn) -> None: +def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: _WorkFn) -> None: """Run a single request on the worker thread. Mirrors the failure handling the previous worker loop performed: @@ -176,7 +182,7 @@ async def _pool_context() -> AsyncGenerator[_Pool]: await to_thread.run_sync(tg.__exit__, None, None, None) -def _emit_body_too_large(ctx: HTTPReqCtx) -> None: +def _emit_body_too_large(ctx: _NativeReqCtx) -> None: """Map worker-side body-limit failures to 413 instead of generic 500.""" if ctx.response_status is None: with suppress(Exception): diff --git a/localpost/http/compress.py b/localpost/http/compress.py index e22ac76..bccc7c0 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -28,12 +28,12 @@ import importlib import importlib.util import zlib -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from contextlib import AbstractContextManager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, BinaryIO, Final -from localpost.http._base import BaseHTTPConn, BodyHandler, HTTPReqCtx, RequestHandler, Selector +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler, native_stream from localpost.http._types import InformationalResponse, Request, Response if TYPE_CHECKING: @@ -419,12 +419,16 @@ def response_status(self) -> int | None: return self._inner.response_status @property - def selector(self) -> Selector: - return self._inner.selector + def remote_addr(self) -> str | None: + return self._inner.remote_addr @property - def conn(self) -> BaseHTTPConn: - return self._inner.conn + def local_addr(self) -> str: + return self._inner.local_addr + + @property + def scheme(self) -> str: + return self._inner.scheme @property def borrowed(self) -> bool: @@ -484,6 +488,11 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) # are at odds; see plans/compression-middleware.md). self._inner.sendfile(response, file, offset, count) + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + # Default impl: drives the imperative trio on ``self``, which the + # compression middleware already intercepts per-chunk. + native_stream(self, response, chunks) + # ----- intercepted ----- def complete(self, response: Response, body: bytes | None = None) -> None: diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 23d504b..ea1b39f 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -40,6 +40,7 @@ Selector, _send_all, emit_handler_error, + native_stream, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.config import DEFAULT_BUFFER_SIZE @@ -342,6 +343,21 @@ class _HTTPReqCtx: attrs: dict[Any, Any] = field(default_factory=dict) _pending_header_bytes: bytes | None = None + @property + def remote_addr(self) -> str | None: + host, port = self.conn.addr + return f"{host}:{port}" if host else None + + @property + def local_addr(self) -> str: + sel = self.selector + return f"{sel.config.host}:{sel.port}" + + @property + def scheme(self) -> str: + # No TLS support today; native server is plain HTTP. + return "http" + @property def borrowed(self) -> bool: return not self.conn.tracked @@ -380,6 +396,9 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.send(body) self.finish_response() + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + native_stream(self, response, chunks) + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: # ``Content-Length`` framing is required — otherwise h11's writer # state can't reconcile what we wrote out-of-band. The static handler diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index ac96ab1..b2282d1 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -43,6 +43,7 @@ Selector, _send_all, emit_handler_error, + native_stream, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.config import DEFAULT_BUFFER_SIZE @@ -507,6 +508,21 @@ class _HTTPReqCtx: """False for HEAD / 1xx / 204 / 304 responses, where response body bytes must not be written even if the handler passes a body to ``complete``.""" + @property + def remote_addr(self) -> str | None: + host, port = self.conn.addr + return f"{host}:{port}" if host else None + + @property + def local_addr(self) -> str: + sel = self.selector + return f"{sel.config.host}:{sel.port}" + + @property + def scheme(self) -> str: + # No TLS support today; native server is plain HTTP. + return "http" + @property def borrowed(self) -> bool: return not self.conn.tracked @@ -556,6 +572,9 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.send(body) self.finish_response() + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + native_stream(self, response, chunks) + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: # ``Content-Length`` framing is required: chunked needs per-chunk # framing bytes that ``socket.sendfile`` can't produce, and a diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index d7ca76d..1c6db9a 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -132,6 +132,10 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: path = unquote_to_bytes(request.path).decode("iso-8859-1") query_string = request.query_string.decode("iso-8859-1") + server_host, _, server_port = ctx.local_addr.rpartition(":") + if not server_host: + server_host, server_port = ctx.local_addr, "" + environ: dict[str, Any] = { "REQUEST_METHOD": request.method.decode("ascii"), "SCRIPT_NAME": "", @@ -139,17 +143,24 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: "QUERY_STRING": query_string, "CONTENT_TYPE": "", "CONTENT_LENGTH": "", - "SERVER_NAME": ctx.selector.config.host, - "SERVER_PORT": str(ctx.selector.port), + "SERVER_NAME": server_host, + "SERVER_PORT": server_port, "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", "wsgi.version": (1, 0), - "wsgi.url_scheme": "http", + "wsgi.url_scheme": ctx.scheme, "wsgi.input": _RequestBodyStream(ctx.body), "wsgi.errors": sys.stderr, "wsgi.multithread": True, "wsgi.multiprocess": False, "wsgi.run_once": False, } + if ctx.remote_addr is not None: + client_host, _, client_port = ctx.remote_addr.rpartition(":") + if client_host: + environ["REMOTE_ADDR"] = client_host + environ["REMOTE_PORT"] = client_port + else: + environ["REMOTE_ADDR"] = ctx.remote_addr # Single header pass: h11 normalizes names to lowercase bytes, so we can # work with bytes directly and decode each name/value exactly once. diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index 9f328dc..5cff0c3 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -23,7 +23,6 @@ from typing import Annotated, Any, Self, Union, get_args, get_origin, get_type_hints from localpost.http import BodyHandler, HTTPReqCtx, RequestHandler -from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._types import Response as _Response from localpost.http.router import URITemplate from localpost.openapi import spec as openapi_spec @@ -576,26 +575,15 @@ def wrapped(ctx: HTTPReqCtx) -> OpResult: def _stream_sse(ctx: HTTPReqCtx, result: EventStreamResult[Any], adapters: AdapterRegistry) -> None: """Run ``result``'s body as an SSE stream over ``ctx``. - Sends the SSE response headers (chunked transfer encoding is implicit - — we don't set ``content-length`` so h11 picks chunked), then writes - one event per yielded item until the iterator exhausts or the client - disconnects (signalled via :func:`localpost.http.check_cancelled`). - Any user-set headers on ``result`` are merged in (without overriding - the framework-set ``content-type`` / ``cache-control`` / + Builds the SSE response (chunked transfer encoding is implicit — we + don't set ``content-length`` so h11 picks chunked) and hands the + event iterator to :meth:`HTTPReqCtx.stream`. The transport owns + iteration: the native server interleaves cancellation checks per + chunk, the WSGI bridge hands the iterator straight to the WSGI + server. Any user-set headers on ``result`` are merged in (without + overriding the framework-set ``content-type`` / ``cache-control`` / ``x-accel-buffering``). """ - # ``check_cancelled`` only works when a request context is active — - # i.e. when the handler runs under the worker pool. When the SSE op - # runs directly on the selector (no pool, e.g. in tests), skip the - # check; the handler's iteration is bounded by the generator itself. - try: - check_cancelled() - cancel_supported = True - except LookupError: - cancel_supported = False - except RequestCancelled: - return - headers = list(_SSE_RESPONSE_HEADERS) seen = {name for name, _ in headers} for name, value in _iter_headers(result.headers): @@ -603,14 +591,7 @@ def _stream_sse(ctx: HTTPReqCtx, result: EventStreamResult[Any], adapters: Adapt continue headers.append((name, value)) - ctx.start_response(_Response(status_code=result.status_code, headers=headers)) - try: - for chunk in iter_events(result.body, adapters): - if cancel_supported: - check_cancelled() - ctx.send(chunk) - except RequestCancelled: - # Client went away; stop iterating. Don't try to finish_response - # — the connection state is uncertain. - return - ctx.finish_response() + ctx.stream( + _Response(status_code=result.status_code, headers=headers), + iter_events(result.body, adapters), + ) diff --git a/plans/websocket-support.md b/plans/websocket-support.md new file mode 100644 index 0000000..8419910 --- /dev/null +++ b/plans/websocket-support.md @@ -0,0 +1,94 @@ +# WebSocket support — sketch (not yet implemented) + +## Context + +`HTTPReqCtx` (after the WSGI deployment refactor in +`plans/wsgi-deployment.md`) is shaped for a request/response lifecycle. +WebSocket connections need a different lifecycle — long-lived, bidirectional +message stream after the handshake. They don't fit `HTTPReqCtx` cleanly, +so this is a sibling Protocol. + +This plan is a forward-compatibility sketch — we're not implementing +WS now. The point is to make sure the design we do now (Protocol shape, +router shape, transport adapters) leaves the door open. + +## Protocol — `WebSocketCtx` + +```python +class WebSocketCtx(Protocol): + request: Request # initial handshake request + remote_addr: str | None + local_addr: str | None + scheme: str # "ws" | "wss" + attrs: dict[Any, Any] + + def accept(self, *, subprotocol: str | None = None) -> None: ... + def reject(self, status: int = 403) -> None: ... + def receive(self) -> WSMessage: ... # blocks; returns text / bytes / Close + def send_bytes(self, data: bytes) -> None: ... + def send_str(self, data: str) -> None: ... + def close(self, code: int = 1000) -> None: ... + +WSHandler = Callable[[WebSocketCtx], None] +``` + +`WSMessage` is a small ADT — `WSText(str)` / `WSBinary(bytes)` / +`WSClose(code, reason)`. + +`request` is the initial handshake request (path, headers, etc.) so +auth / routing decisions reuse existing helpers. + +## Router + +`Routes.add_ws("/path", handler)` — a separate dispatch table from +the HTTP method table. The connection-upgrade decision (when the native +server sees `Upgrade: websocket`) consults this table; success → +`WebSocketCtx` flow, miss → 404 over HTTP. + +`Router._match` gains one more variant in the result type. Per-route +middlewares stay HTTP-only; WS middlewares are a follow-up if needed. + +## Transport fit + +| Transport | WS support | +| --------- | ---------- | +| native `localpost.http` | needs WS framing in a backend; long-lived borrow already fits the borrow/return state machine. Sync handlers; ~hundreds of concurrent sockets per process. | +| WSGI | **not supported.** WSGI has no upgrade primitive. `to_wsgi(handler)` raises if any WS routes are registered, or dispatches HTTP-only. | +| ASGI / RSGI | Protocol-side fit is clean (both have native WS scopes). Out of scope for this sketch. | + +## Concurrency model + +Sync `WebSocketCtx`. Each connection holds one worker thread for its +lifetime. Suitable for "simplicity first" — users who need 10k concurrent +sockets deploy under an async transport (ASGI/RSGI adapter, future). + +Cancellation: same `check_cancelled()` shape as HTTP, called between +`receive()` returns and during `send_*`. Selector-side disconnect detection +already exists for the borrowed-conn case; reuses without changes. + +## OpenAPI integration (future) + +OpenAPI 3.x doesn't formally describe WebSocket endpoints. Options: + +- AsyncAPI 2/3 spec emitted alongside the OpenAPI doc. +- Keep WS routes opaque in the OpenAPI doc (don't list them). +- Vendor extension (`x-websocket: true`) — non-standard but discoverable. + +Decision deferred until WS is actually implemented. + +## Pre-work in scope of the WSGI plan + +The Protocol additions in `plans/wsgi-deployment.md` (`remote_addr`, +`local_addr`, `scheme`) are the same fields `WebSocketCtx` needs. No +extra design work today — just don't paint ourselves into a corner. + +## Open questions to revisit before implementing + +1. **Backpressure on send.** Native sync impl: blocking write. Is that + sufficient, or do we need a queue + `try_send`? +2. **Per-message vs streaming frames.** RSGI exposes per-message; we + probably do too. Continuation frames are server-internal. +3. **Subprotocol negotiation.** Surface as `accept(subprotocol=...)` + parameter and a `request.headers` lookup helper. +4. **Sentry integration.** A WS connection is one transaction; spans + per `receive()` / `send_*()`? Defer until use-case is concrete. diff --git a/plans/wsgi-deployment.md b/plans/wsgi-deployment.md new file mode 100644 index 0000000..482070c --- /dev/null +++ b/plans/wsgi-deployment.md @@ -0,0 +1,229 @@ +# WSGI deployment for `Router` and `HttpApp` + +## Context + +Today `Router` and `localpost.openapi.HttpApp` only run under +`localpost.http`'s native server (`http_server` / `start_http_server`). +The router and the OpenAPI framework only touch a small slice of +`HTTPReqCtx`, but the Protocol exposes localpost-server-only bits +(`selector`, `conn`, `borrow`) that prevent a WSGI implementation from +satisfying it. + +Goal: deploy `HttpApp` (and bare `Router`) under Gunicorn / uWSGI / +Werkzeug. Single Protocol — no parallel `RequestCtx` — by trimming +`HTTPReqCtx` until both transports can satisfy it. ASGI is out of scope +for this plan. + +## Surface audit + +Used by router + `localpost.openapi`: +- `request` (`.path` / `.method` / `.query_string` / `.headers`) +- `body`, `attrs`, `response_status` +- `start_response` / `send` / `finish_response` / `complete` + +Used by static handler: +- `sendfile(response, file, offset, count)` + +Used by user-facing examples (`middleware_rate_limit.py`): +- `ctx.conn.sock.getpeername()[0]` — replace with `ctx.remote_addr`. + +Used internally only (native server / `_pool.py` / `emit_handler_error`): +- `selector`, `conn`, `borrow`, `borrowed`. + +`ctx.selector` is read in three places: `_pool.py` (concrete native; not +in Protocol after the trim), and `wsgi.py:_build_environ` for +``SERVER_NAME`` / ``SERVER_PORT``. A new `local_addr: str | None` +covers the latter without exposing the selector. + +## Protocol changes + +After the trim: + +```python +class HTTPReqCtx(Protocol): + request: Request + body: bytes + response_status: int | None + attrs: dict[Any, Any] + remote_addr: str | None + local_addr: str | None + scheme: str # "http" | "https" + borrowed: bool + def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... + def receive(self, size: int = ..., /) -> bytes: ... + def start_response(self, r: Response | InformationalResponse, /) -> None: ... + def send(self, chunk: Buffer, /) -> None: ... + def finish_response(self) -> None: ... + def complete(self, r: Response, body: bytes | None = None) -> None: ... + def stream(self, r: Response, chunks: Iterator[bytes]) -> None: ... + def sendfile(self, r: Response, file: BinaryIO, offset: int, count: int) -> None: ... +``` + +Removed from Protocol (kept on the concrete native ctx): +- `selector` — two callsites in `wsgi.py` (`_build_environ` for the + inverse `wrap_wsgi` adapter); thread `(host, port)` through directly + or reach the concrete ctx at those sites. +- `conn` — internal callers (`_pool.py`, `_base.emit_handler_error`, + native backends) re-typed against the concrete native ctx; user-facing + need (peer address) is covered by the new `remote_addr` field. + +No-op on WSGI: +- `borrowed` — always `True` (the WSGI worker already owns the + connection — that's exactly what borrowed means). +- `borrow()` — `nullcontext(self)`. + +Added: +- `remote_addr: str | None` — natively from `socket.getpeername()`, + under WSGI from `environ['REMOTE_ADDR']`. +- `local_addr: str | None` — natively from the listening socket + (replaces the `ctx.selector.config.host` / `ctx.selector.port` reach), + under WSGI from `environ['SERVER_NAME']` + `environ['SERVER_PORT']`. +- `scheme: str` — `"http"` or `"https"`. Natively defaults to `"http"` + (no TLS today); under WSGI from `environ['wsgi.url_scheme']`. Useful + for the OpenAPI ``servers`` list and redirect builders behind a + TLS-terminating load balancer. +- `stream(response, chunks)` — declarative streaming (see below). + +## SSE refactor — `ctx.stream(response, chunks)` + +Today `_stream_sse` (`operation.py:576`) drives a manual +`start_response` / `send` loop with cancellation interleaving. This +becomes: + +```python +def _stream_sse(ctx, result, adapters): + headers = _merge_sse_headers(result.headers) + ctx.stream( + _Response(status_code=result.status_code, headers=headers), + iter_events(result.body, adapters), + ) +``` + +Native impl wraps the existing imperative trio with cancellation: + +```python +def stream(self, response, chunks): + self.start_response(response) + try: + for chunk in chunks: + try: check_cancelled() + except LookupError: pass + self.send(chunk) + except RequestCancelled: + return + self.finish_response() +``` + +WSGI impl captures `(response, chunks)` and returns the iterator +straight to the WSGI server — zero threads, zero queue: + +```python +def stream(self, response, chunks): + self._streaming = (response, chunks) +``` + +The imperative trio (`start_response` / `send` / `finish_response`) +stays on the Protocol — `compress.py` still wraps it for incremental +compression, and hand-written streaming handlers keep working. WSGI +falls back to thread+queue for the imperative trio only. + +## WSGI adapter + +```python +def to_wsgi(handler: RequestHandler) -> WSGIApplication: + def wsgi_app(environ, start_response): + ctx = _WSGIReqCtx(environ) + body_handler = handler(ctx) + if body_handler is not None: + body_handler(ctx) + return ctx._respond(start_response) + return wsgi_app +``` + +`_WSGIReqCtx._respond(start_response)` branches: +- eager `complete()` → `start_response(...)`; return `[body]`. +- `stream(...)` captured → `start_response(...)`; return `chunks`. +- imperative `start_response/send/.../finish_response` → thread+queue + fallback. + +`sendfile` — use `environ['wsgi.file_wrapper']` if present, else +chunked read+yield. + +## Public API + +```python +HttpApp.as_wsgi(self) -> WSGIApplication +Router.as_wsgi(self) -> WSGIApplication +``` + +Deployment: `gunicorn myapp:app.as_wsgi()`. `thread_pool_handler` does +not apply — the WSGI server's worker model is the pool. +`check_cancelled()` is a no-op under WSGI (no socket handle inside the +WSGI app); `_stream_sse` already gracefully handles `LookupError`. + +## Footprint + +- `_base.py` — slim Protocol; concrete native ctx adds `selector` / + `conn` as instance attributes (not in Protocol). Add `stream()` + default impl. +- `_pool.py`, `emit_handler_error` — re-typed to concrete native ctx + (~5 lines). +- `wsgi.py` (existing inverse adapter) — drop `ctx.selector.config.host` + reach; thread `(host, port)` into `_build_environ`. +- `examples/http/middleware_rate_limit.py` — `ctx.conn.sock.getpeername()` + → `ctx.remote_addr`. +- New: `to_wsgi(handler)` + `_WSGIReqCtx` in `wsgi.py`. +- New: `HttpApp.as_wsgi()` / `Router.as_wsgi()`. +- `_stream_sse` rewritten to use `ctx.stream(...)`. + +## Order of work + +1. Slim Protocol (drop `selector` / `conn` / `borrow*`); add + `remote_addr`. Re-type internal callers. +2. Add `ctx.stream()` to Protocol + native impl; rewrite `_stream_sse`. +3. `to_wsgi(handler)` + `_WSGIReqCtx` with eager / `stream` / + imperative-fallback branches. +4. `HttpApp.as_wsgi()` / `Router.as_wsgi()` + example + tests. + +## Forward compatibility — RSGI alignment + +The shape after the trim is intentionally close to Granian's RSGI: +single ctx object, methods drive the response 0→100, handler keeps +control until exit. The mapping: + +| `HTTPReqCtx` | RSGI | +| ------------------------------------- | --------------------------------- | +| `request.{path,method,headers,...}` | `scope` | +| `request.http_version` (`b"1.1"` …) | `scope.http_version` | +| `remote_addr` / `local_addr` | `scope.client` / `scope.server` | +| `scheme` | `scope.scheme` | +| `body`, `receive(size)` | `protocol.__call__()` / `__aiter__()` | +| `complete(response, body)` | `response_bytes(...)` | +| `stream(response, chunks)` | `response_stream(...)` + `send_bytes(...)` | +| `sendfile(response, file, ...)` | `response_file_range(...)` | + +This means an RSGI deployment adapter (`to_rsgi(handler)`) is a +straight protocol translation if/when we want it — no Protocol redesign. + +### HTTP/2 + +Transparent. No Protocol changes — `request.http_version` already +takes `b"2"`. When we eventually want HTTP/2, swap the parser +(h2 / Hypercorn / Granian) — handler code is unchanged. `stream()` runs +over an HTTP/2 stream the same as it does over `Transfer-Encoding: +chunked`. + +### WebSocket + +Sibling Protocol, not a member of `HTTPReqCtx`. See +`plans/websocket-support.md` for the sketch. WSGI cannot carry WS; +`to_wsgi(handler)` raises if the registered routes include any WS +handlers, or just dispatches HTTP-only. + +## Out of scope + +- ASGI / RSGI deployment. +- HTTP/2 transport (Protocol is ready; needs a parser swap). +- WebSocket support (separate plan). +- Compression on the WSGI streaming path (works today via thread+queue + fallback; revisit if needed). diff --git a/tests/http/app.py b/tests/http/app.py index f9e1a09..ec7cdaa 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -614,8 +614,8 @@ async def test_httptools_deferred_streaming_dispatch_sees_current_feed_body(self def handler(ctx: HTTPReqCtx): def dispatch(req_ctx: HTTPReqCtx) -> None: - req_ctx.conn.selector.stop_tracking(req_ctx.conn) - conn = cast(Any, req_ctx.conn) + conn = cast(Any, req_ctx).conn + conn.selector.stop_tracking(conn) captured["buffer_before_receive"] = bytes(conn._streaming_body_buf) captured["eom_before_receive"] = conn._streaming_eom chunks: list[bytes] = [] From 3ed455eb18c9ba6e580a796476f57fa52aae5eaf Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Tue, 5 May 2026 20:46:44 +0000 Subject: [PATCH 185/286] warmup --- benchmarks/http/apps/_cli.py | 11 +++- benchmarks/http/apps/localpost_flask.py | 4 +- benchmarks/http/apps/localpost_h11.py | 4 +- benchmarks/http/apps/localpost_httptools.py | 4 +- .../http/apps/localpost_httptools_inline.py | 2 +- benchmarks/http/apps/localpost_wsgi.py | 4 +- localpost/threadtools.py | 22 ++++++++ tests/threadtools/thread_task_group.py | 51 ++++++++++++++++++- 8 files changed, 94 insertions(+), 8 deletions(-) diff --git a/benchmarks/http/apps/_cli.py b/benchmarks/http/apps/_cli.py index e7e4cb7..5e54a1c 100644 --- a/benchmarks/http/apps/_cli.py +++ b/benchmarks/http/apps/_cli.py @@ -23,13 +23,20 @@ class AppArgs: port: int selectors: int acceptor: bool + warmup: int def parse_args(default_port: int = 8000) -> AppArgs: - """Port + selectors + acceptor. Used by LocalPost bench apps.""" + """Port + selectors + acceptor + warmup. Used by LocalPost bench apps.""" p = argparse.ArgumentParser() p.add_argument("--port", type=int, default=default_port) p.add_argument("--selectors", type=int, default=1) p.add_argument("--acceptor", action="store_true") + p.add_argument( + "--warmup", + type=int, + default=32, + help="Pre-spawn N worker threads before serving (default: 32).", + ) a = p.parse_args() - return AppArgs(port=a.port, selectors=a.selectors, acceptor=a.acceptor) + return AppArgs(port=a.port, selectors=a.selectors, acceptor=a.acceptor, warmup=a.warmup) diff --git a/benchmarks/http/apps/localpost_flask.py b/benchmarks/http/apps/localpost_flask.py index fc79efc..41aafe0 100644 --- a/benchmarks/http/apps/localpost_flask.py +++ b/benchmarks/http/apps/localpost_flask.py @@ -6,6 +6,7 @@ from benchmarks.http.apps._cli import parse_port from benchmarks.http.apps._flask_app import build_app +from localpost import threadtools from localpost.hosting import run_app, service from localpost.http import ServerConfig, http_server, thread_pool_handler from localpost.http.flask import flask_handler @@ -13,11 +14,12 @@ def main() -> int: port = parse_port() + threadtools.warmup(32) cfg = ServerConfig(host="127.0.0.1", port=port) @service async def app(): - async with thread_pool_handler(flask_handler(build_app()), max_concurrency=32) as wrapped: + async with thread_pool_handler(flask_handler(build_app())) as wrapped: async with http_server(cfg, wrapped): yield diff --git a/benchmarks/http/apps/localpost_h11.py b/benchmarks/http/apps/localpost_h11.py index 0421ca4..24cfceb 100644 --- a/benchmarks/http/apps/localpost_h11.py +++ b/benchmarks/http/apps/localpost_h11.py @@ -7,6 +7,7 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost import threadtools from localpost.hosting import run_app from localpost.http import HTTPReqCtx, Response, ServerConfig from localpost.http.app import HttpApp @@ -14,7 +15,8 @@ def main() -> int: args = parse_args() - app = HttpApp(max_concurrency=32) + threadtools.warmup(args.warmup) + app = HttpApp() @app.get("/ping") def ping(): diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/http/apps/localpost_httptools.py index 36d1a9a..b9c159b 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/http/apps/localpost_httptools.py @@ -10,6 +10,7 @@ from benchmarks.http.apps._cli import parse_args from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from localpost import threadtools from localpost.hosting import run_app from localpost.http import HTTPReqCtx, Response, ServerConfig from localpost.http.app import HttpApp @@ -17,7 +18,8 @@ def main() -> int: args = parse_args() - app = HttpApp(max_concurrency=32) + threadtools.warmup(args.warmup) + app = HttpApp() @app.get("/ping") def ping(): diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/http/apps/localpost_httptools_inline.py index 8335a6e..9fd8007 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/http/apps/localpost_httptools_inline.py @@ -18,7 +18,7 @@ def main() -> int: args = parse_args() - app = HttpApp(max_concurrency=0) # inline: no pool + app = HttpApp(pooled=False) # inline: no pool @app.get("/ping") def ping(): diff --git a/benchmarks/http/apps/localpost_wsgi.py b/benchmarks/http/apps/localpost_wsgi.py index 9da25f5..b69425a 100644 --- a/benchmarks/http/apps/localpost_wsgi.py +++ b/benchmarks/http/apps/localpost_wsgi.py @@ -6,17 +6,19 @@ from benchmarks.http.apps._cli import parse_port from benchmarks.http.apps._flask_app import build_app +from localpost import threadtools from localpost.hosting import run_app, service from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi def main() -> int: port = parse_port() + threadtools.warmup(32) cfg = ServerConfig(host="127.0.0.1", port=port) @service async def app(): - async with thread_pool_handler(wrap_wsgi(build_app()), max_concurrency=32) as wrapped: + async with thread_pool_handler(wrap_wsgi(build_app())) as wrapped: async with http_server(cfg, wrapped): yield diff --git a/localpost/threadtools.py b/localpost/threadtools.py index e4f5559..aab787d 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -21,6 +21,7 @@ "ReceiveChannel", "SendChannel", "ThreadTaskGroup", + "warmup", ] CHECK_TIMEOUT: float = 1.0 @@ -473,6 +474,27 @@ def _run(self) -> None: """Global LIFO stack of idle workers, shared across all ``ThreadTaskGroup``s.""" +def warmup(count: int, /) -> None: + """Pre-spawn ``count`` worker threads and park them in the global idle pool. + + Useful at process startup to amortise thread-creation cost so the first + bursts of work don't pay it. Pre-warmed workers are indistinguishable + from organically spawned ones — they self-exit on the same idle timeout + if unused. + + One-shot: ``warmup(8)`` always spawns 8 fresh workers, regardless of + how many idle workers already exist. Calling it again from a long-idle + process re-warms. + + Raises: + ValueError: ``count`` is negative. + """ + if count < 0: + raise ValueError("count must be >= 0") + for _ in range(count): + _idle.append(_Worker()) + + @final class ThreadTaskGroup: """Trio-style task group running sync callables on a shared thread pool. diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 1dce3a6..5f3e772 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -8,7 +8,7 @@ import pytest from localpost import threadtools -from localpost.threadtools import ThreadTaskGroup +from localpost.threadtools import ThreadTaskGroup, warmup @pytest.fixture @@ -300,6 +300,55 @@ def _record(i: int, sink: list[int], lock: threading.Lock) -> None: sink.append(i) +# --------------------------------------------------------------------------- +# warmup() +# --------------------------------------------------------------------------- + + +def test_warmup_adds_workers_to_idle_pool(): + """``warmup(N)`` spawns N workers and puts them in the global idle deque.""" + threadtools._idle.clear() + warmup(4) + assert len(threadtools._idle) == 4 + # Each entry is a distinct, alive worker. + workers = list(threadtools._idle) + assert all(w._alive for w in workers) + assert len(set(id(w) for w in workers)) == 4 + + +def test_warmup_workers_are_used_by_dispatch(): + """A subsequent ``start_soon`` reuses a pre-warmed worker rather than + spawning a fresh one.""" + threadtools._idle.clear() + warmup(2) + pre = {id(w) for w in threadtools._idle} + + seen: set[int] = set() + seen_lock = threading.Lock() + + def record_self() -> None: + with seen_lock: + seen.add(threading.get_ident()) + + with ThreadTaskGroup() as tg: + tg.start_soon(record_self).result(timeout=5) + + # The worker that ran is one of the pre-warmed set. + post = {id(w) for w in threadtools._idle} + assert pre & post # at least one of the warmed workers is still parked + + +def test_warmup_zero_is_noop(): + threadtools._idle.clear() + warmup(0) + assert len(threadtools._idle) == 0 + + +def test_warmup_negative_raises(): + with pytest.raises(ValueError, match=">= 0"): + warmup(-1) + + # --------------------------------------------------------------------------- # ContextVar propagation # --------------------------------------------------------------------------- From 016b66a771a8c21906b48afd4bb6177c9790c246 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 00:58:32 +0400 Subject: [PATCH 186/286] feat(http): to_wsgi adapter; HttpApp.as_wsgi() / Router.as_wsgi() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve a localpost ``RequestHandler`` (or ``HttpApp`` / ``Router``) under a WSGI host (Gunicorn / uWSGI / Werkzeug). Same Protocol — ``_WSGIReqCtx`` satisfies ``HTTPReqCtx`` directly, no parallel abstraction. Three response branches in ``_WSGIReqCtx._respond``: - ``complete(resp, body)`` → ``[body]`` iterable. - ``stream(resp, chunks)`` → the iterator handed to the WSGI server lazily; per-chunk flushing without threads / queues. - imperative ``start_response`` / ``send`` / ``finish_response`` → chunks buffered into a list (handler runs synchronously in the WSGI app call). No per-chunk pacing on this path; for true streaming use ``ctx.stream``. Also: ``ctx.sendfile`` uses ``environ['wsgi.file_wrapper']`` when the host provides it; otherwise falls back to chunked read+yield. Public API: - ``localpost.http.to_wsgi(handler) -> WSGIApplication`` - ``Router.as_wsgi()`` and ``HttpApp.as_wsgi()`` — sugar over ``to_wsgi`` for the typical deployment. Tests: 25 unit tests covering eager / streaming / imperative / sendfile / router 404 / 405 / OpenAPI doc / SSE lazy iteration. End-to-end smoke via ``wsgiref.simple_server`` confirmed. Example: ``examples/openapi/wsgi_app.py`` (Gunicorn or stdlib ``wsgiref``). Notes: - ``thread_pool_handler`` does not apply under WSGI — the host's worker model is the request-side pool. - ``check_cancelled()`` is a no-op (no socket handle inside the WSGI app); long-running streams surface client disconnect as ``BrokenPipeError`` from the host's per-chunk write. Closes the WSGI side of plans/wsgi-deployment.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/openapi/wsgi_app.py | 78 ++++++++ localpost/http/__init__.py | 5 +- localpost/http/router.py | 12 ++ localpost/http/wsgi.py | 315 ++++++++++++++++++++++++++++- localpost/openapi/app.py | 17 ++ tests/http/wsgi_to.py | 372 +++++++++++++++++++++++++++++++++++ tests/openapi/wsgi.py | 215 ++++++++++++++++++++ 7 files changed, 1009 insertions(+), 5 deletions(-) create mode 100644 examples/openapi/wsgi_app.py create mode 100644 tests/http/wsgi_to.py create mode 100644 tests/openapi/wsgi.py diff --git a/examples/openapi/wsgi_app.py b/examples/openapi/wsgi_app.py new file mode 100644 index 0000000..9d23fea --- /dev/null +++ b/examples/openapi/wsgi_app.py @@ -0,0 +1,78 @@ +"""Deploy ``localpost.openapi.HttpApp`` under a WSGI server (Gunicorn / uWSGI / Werkzeug). + +Run with Gunicorn:: + + uv pip install gunicorn + uv run gunicorn examples.openapi.wsgi_app:app -b 127.0.0.1:8000 + +Or with the stdlib's reference WSGI server (no extra dep):: + + uv run python examples/openapi/wsgi_app.py + +Then:: + + curl http://localhost:8000/hello/world + curl http://localhost:8000/openapi.json + curl -N http://localhost:8000/feed # SSE stream — chunks flushed per event + +Notes: + +- ``HttpApp.as_wsgi()`` returns a standard WSGI callable. Worker + concurrency is the WSGI server's responsibility — ``thread_pool_handler`` + does not apply. +- SSE / streaming via ``ctx.stream`` is handed to the WSGI server as a + lazy iterator: events flush per yield, no extra threads. +- ``check_cancelled()`` is a no-op under WSGI; a long-running stream + surfaces client disconnects as ``BrokenPipeError`` on the next yield. +""" + +from collections.abc import Iterator +from dataclasses import dataclass + +from localpost.openapi import Event, HttpApp, NotFound + + +@dataclass +class Book: + id: str + title: str + + +_books: dict[str, Book] = { + "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy"), +} + + +app = HttpApp() + + +@app.get("/hello/{name}") +def hello(name: str) -> str: + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + book = _books.get(book_id) + if book is None: + return NotFound(f"book not found: {book_id}") + return book + + +@app.get("/feed") +def feed() -> Iterator[Event[str]]: + """Lazy SSE — each ``yield`` lands on the wire as the WSGI server iterates.""" + for i in range(5): + yield Event(data=f"event-{i}") + + +# WSGI entry point — gunicorn examples.openapi.wsgi_app:wsgi_app +wsgi_app = app.as_wsgi() + + +if __name__ == "__main__": + from wsgiref.simple_server import make_server + + with make_server("127.0.0.1", 8000, wsgi_app) as srv: + print("Serving on http://127.0.0.1:8000 (Ctrl-C to stop)") + srv.serve_forever() diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 58ecb6e..d2e9ebd 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -27,7 +27,7 @@ route_match, ) from localpost.http.static import static_handler -from localpost.http.wsgi import wrap_wsgi +from localpost.http.wsgi import to_wsgi, wrap_wsgi __all__ = [ # config @@ -59,7 +59,8 @@ "RouteMatch", "URITemplate", "route_match", - # WSGI adapter + # WSGI adapters + "to_wsgi", "wrap_wsgi", # hosting "http_server", diff --git a/localpost/http/router.py b/localpost/http/router.py index a810551..b6bad4d 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -317,6 +317,18 @@ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: return dispatch + def as_wsgi(self): + """Return a WSGI application that dispatches via this router. + + Sugar over :func:`localpost.http.wsgi.to_wsgi(self.as_handler())`. + Deploy with Gunicorn / uWSGI / Werkzeug — the WSGI worker model + is the request-side concurrency layer (``thread_pool_handler`` + does not apply). + """ + from localpost.http.wsgi import to_wsgi # noqa: PLC0415 + + return to_wsgi(self.as_handler()) + # -------------------------------------------------------------------------- # Match result types diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 1c6db9a..8e9c558 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -1,16 +1,21 @@ from __future__ import annotations +import contextlib import sys -from collections.abc import Buffer, Callable +from collections.abc import Buffer, Callable, Iterable, Iterator +from dataclasses import dataclass, field +from http import HTTPStatus from io import RawIOBase -from typing import Any, final, override +from typing import Any, BinaryIO, final, override from urllib.parse import unquote_to_bytes from wsgiref.types import WSGIApplication from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._types import InformationalResponse, Request from localpost.http._types import Response as _Response +from localpost.http.config import DEFAULT_BUFFER_SIZE -__all__ = ["wrap_wsgi"] +__all__ = ["to_wsgi", "wrap_wsgi"] @final @@ -176,3 +181,307 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: environ[key.decode("ascii")] = value_str return environ + + +# --------------------------------------------------------------------------- +# Reverse direction: serve a localpost RequestHandler under a WSGI server. +# --------------------------------------------------------------------------- + + +@final +@dataclass(slots=True, eq=False) +class _WSGIReqCtx: + """:class:`HTTPReqCtx` implementation backed by a WSGI ``environ``. + + Body is read from ``wsgi.input`` and pre-buffered into ``ctx.body`` + before the handler runs (matches the JSON-API common case the native + server is tuned for). ``receive`` slices the buffered body for any + handler that wants chunked reads. + + Three response shapes are supported: + + - **Eager** (``complete``): captured into ``_completed`` and returned + as a single-element iterable. + - **Declarative streaming** (``stream``): the chunk iterator is + handed to the WSGI server lazily — no thread / queue, the WSGI + worker drives iteration and flushing. + - **Imperative** (``start_response`` + ``send`` + ``finish_response``): + chunks are appended to a list. The handler runs synchronously, so + the full list is returned at the end. **No per-chunk pacing** on + this path — for true streaming use :meth:`stream`. + + ``borrow()`` is a no-op CM (the WSGI worker already owns the + connection); ``borrowed`` is always ``True``. + """ + + request: Request + body: bytes + remote_addr: str | None + local_addr: str + scheme: str + _environ: dict[str, Any] + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _body_cursor: int = 0 + + # Response capture state — at most one of these is populated. + _completed: tuple[_Response, bytes] | None = None + _streaming: tuple[_Response, Iterator[bytes]] | None = None + _imperative_response: _Response | None = None + _imperative_chunks: list[bytes] = field(default_factory=list) + _sendfile: tuple[_Response, BinaryIO, int, int] | None = None + + @property + def borrowed(self) -> bool: + return True + + def borrow(self) -> contextlib.AbstractContextManager[HTTPReqCtx]: + # The WSGI worker already owns the connection — borrow is degenerate. + return contextlib.nullcontext(self) + + def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + if self._body_cursor >= len(self.body): + return b"" + end = self._body_cursor + size + chunk = self.body[self._body_cursor : end] + self._body_cursor += len(chunk) + return chunk + + # ----- response capture ----- + + def complete(self, response: _Response, body: bytes | None = None) -> None: + self._check_not_started() + self.response_status = response.status_code + self._completed = (response, body or b"") + + def start_response(self, response: _Response | InformationalResponse, /) -> None: + if isinstance(response, InformationalResponse): + # WSGI's start_response is final-response only; 1xx isn't surfaced. + return + self._check_not_started() + self.response_status = response.status_code + self._imperative_response = response + + def send(self, chunk: Buffer, /) -> None: + if self._imperative_response is None: + raise RuntimeError("send() before start_response()") + self._imperative_chunks.append(bytes(chunk)) + + def finish_response(self) -> None: + if self._imperative_response is None: + raise RuntimeError("finish_response() before start_response()") + + def stream(self, response: _Response, chunks: Iterator[bytes], /) -> None: + self._check_not_started() + self.response_status = response.status_code + self._streaming = (response, chunks) + + def sendfile(self, response: _Response, file: BinaryIO, offset: int, count: int) -> None: + self._check_not_started() + self.response_status = response.status_code + self._sendfile = (response, file, offset, count) + + # ----- WSGI-side dispatch ----- + + def _respond(self, start_response: Callable[..., Any]) -> Iterable[bytes]: + if self._completed is not None: + response, body = self._completed + start_response(_status_line(response), _wsgi_headers(response.headers)) + return [body] if body else [] + if self._streaming is not None: + response, chunks = self._streaming + start_response(_status_line(response), _wsgi_headers(response.headers)) + return chunks + if self._sendfile is not None: + response, file, offset, count = self._sendfile + start_response(_status_line(response), _wsgi_headers(response.headers)) + file.seek(offset) + file_wrapper = self._environ.get("wsgi.file_wrapper") + if file_wrapper is not None: + return file_wrapper(_LimitedReader(file, count), DEFAULT_BUFFER_SIZE) + return _read_chunks(file, count, DEFAULT_BUFFER_SIZE) + if self._imperative_response is not None: + response = self._imperative_response + start_response(_status_line(response), _wsgi_headers(response.headers)) + return self._imperative_chunks + raise RuntimeError("Handler returned without producing a response") + + def _check_not_started(self) -> None: + if ( + self._completed is not None + or self._streaming is not None + or self._sendfile is not None + or self._imperative_response is not None + ): + raise RuntimeError("Response already started") + + +def to_wsgi(handler: RequestHandler) -> WSGIApplication: + """Serve a localpost :data:`RequestHandler` under a WSGI server. + + The returned WSGI app builds a :class:`HTTPReqCtx` from the WSGI + ``environ``, drives the handler (pre-body and body-handler phases + both run synchronously inside the WSGI app call), and translates the + ctx's captured response into the WSGI return shape: + + - ``ctx.complete(...)`` → ``[body]`` iterable, single ``start_response``. + - ``ctx.stream(...)`` → the chunk iterator handed straight to the WSGI + server (lazy, true per-chunk flushing). + - ``ctx.sendfile(...)`` → ``wsgi.file_wrapper`` if the host server + provides it, else a chunked read+yield fallback. + - imperative ``start_response`` / ``send`` / ``finish_response`` → + chunks buffered in a list and returned as the WSGI body iterable. + No per-chunk pacing on this path; if you need true streaming, + use :meth:`HTTPReqCtx.stream`. + + Deployment:: + + # myapp.py + from localpost.http.wsgi import to_wsgi + from localpost.openapi import HttpApp + + app = HttpApp() + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + wsgi_app = to_wsgi(app._build_router_handler()) # or app.as_wsgi() + + Then ``gunicorn myapp:wsgi_app``. + + :func:`localpost.http.thread_pool_handler` does not apply — the WSGI + server's worker model is the pool. :func:`check_cancelled` is a + no-op (the WSGI app has no socket handle); SSE streams over + :meth:`HTTPReqCtx.stream` surface client disconnects as + :class:`BrokenPipeError` from the host's per-chunk write. + """ + + def wsgi_app(environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: + ctx = _build_wsgi_ctx(environ) + body_handler = handler(ctx) + if body_handler is not None: + body_handler(ctx) + return ctx._respond(start_response) + + return wsgi_app + + +def _build_wsgi_ctx(environ: dict[str, Any]) -> _WSGIReqCtx: + method = environ.get("REQUEST_METHOD", "GET").encode("ascii") + path_info = environ.get("PATH_INFO", "").encode("iso-8859-1") + query_string = environ.get("QUERY_STRING", "").encode("iso-8859-1") + target = path_info + (b"?" + query_string if query_string else b"") + + headers: list[tuple[bytes, bytes]] = [] + if environ.get("CONTENT_TYPE"): + headers.append((b"content-type", str(environ["CONTENT_TYPE"]).encode("iso-8859-1"))) + if environ.get("CONTENT_LENGTH"): + headers.append((b"content-length", str(environ["CONTENT_LENGTH"]).encode("iso-8859-1"))) + for key, value in environ.items(): + if not key.startswith("HTTP_"): + continue + # HTTP_X_FOO -> x-foo + name = key[5:].replace("_", "-").lower().encode("ascii") + headers.append((name, str(value).encode("iso-8859-1"))) + + proto = environ.get("SERVER_PROTOCOL", "HTTP/1.1") + http_version = proto.split("/", 1)[1].encode("ascii") if "/" in proto else b"1.1" + + request = Request( + method=method, + target=target, + path=path_info, + query_string=query_string, + headers=tuple(headers), + http_version=http_version, + ) + + body = _read_body(environ) + + server_host = str(environ.get("SERVER_NAME", "")) + server_port = str(environ.get("SERVER_PORT", "")) + local_addr = f"{server_host}:{server_port}" if server_port else server_host + + remote_host = environ.get("REMOTE_ADDR") + if remote_host: + remote_port = environ.get("REMOTE_PORT") + remote_addr: str | None = f"{remote_host}:{remote_port}" if remote_port else str(remote_host) + else: + remote_addr = None + + return _WSGIReqCtx( + request=request, + body=body, + remote_addr=remote_addr, + local_addr=local_addr, + scheme=str(environ.get("wsgi.url_scheme", "http")), + _environ=environ, + ) + + +def _read_body(environ: dict[str, Any]) -> bytes: + cl_raw = environ.get("CONTENT_LENGTH") or "" + try: + cl = int(cl_raw) if cl_raw else 0 + except ValueError: + cl = 0 + if cl <= 0: + return b"" + stream = environ.get("wsgi.input") + if stream is None: + return b"" + return stream.read(cl) + + +def _status_line(response: _Response) -> str: + if response.reason: + reason = response.reason.decode("iso-8859-1") + else: + try: + reason = HTTPStatus(response.status_code).phrase + except ValueError: + reason = "" + return f"{response.status_code} {reason}" + + +def _wsgi_headers(headers: Any) -> list[tuple[str, str]]: + return [(name.decode("iso-8859-1"), value.decode("iso-8859-1")) for name, value in headers] + + +@final +class _LimitedReader: + """``file_wrapper``-friendly view of a file restricted to ``count`` bytes + starting from the current position. Servers that consume + ``wsgi.file_wrapper`` either ``sendfile()`` the underlying fd or fall + back to ``.read(blksize)`` — both paths are honoured here. + """ + + __slots__ = ("_file", "_remaining") + + def __init__(self, file: BinaryIO, count: int) -> None: + self._file = file + self._remaining = count + + def read(self, n: int = -1) -> bytes: + if self._remaining <= 0: + return b"" + if n < 0 or n > self._remaining: + n = self._remaining + data = self._file.read(n) + self._remaining -= len(data) + return data + + def fileno(self) -> int: + return self._file.fileno() + + +def _read_chunks(file: BinaryIO, count: int, blksize: int) -> Iterator[bytes]: + """Fallback for hosts without ``wsgi.file_wrapper``.""" + remaining = count + while remaining > 0: + chunk = file.read(min(blksize, remaining)) + if not chunk: + return + remaining -= len(chunk) + yield chunk diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 39a8b24..47cf4c6 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -211,6 +211,23 @@ async def _app_service(): return _app_service() + def as_wsgi(self): + """Return a WSGI application that serves this :class:`HttpApp`. + + Sugar over :func:`localpost.http.wsgi.to_wsgi`: builds the same + router used by :meth:`service` and adapts it for deployment + under Gunicorn / uWSGI / Werkzeug. Deploy with e.g. + ``gunicorn myapp:app.as_wsgi()``. + + The WSGI worker model is the request-side concurrency layer — + :func:`localpost.http.thread_pool_handler` does not apply, and + :func:`localpost.http.check_cancelled` is a no-op (no socket + handle inside the WSGI app). + """ + from localpost.http.wsgi import to_wsgi # noqa: PLC0415 + + return to_wsgi(self._build_router_handler()) + # ----- Internals ----- def _build_router_handler(self) -> RequestHandler: diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py new file mode 100644 index 0000000..15573ef --- /dev/null +++ b/tests/http/wsgi_to.py @@ -0,0 +1,372 @@ +"""Tests for ``localpost.http.wsgi.to_wsgi`` — serving a localpost +``RequestHandler`` under a WSGI server. + +Drives the WSGI app directly (no real WSGI server needed); asserts on the +captured ``start_response`` arguments and the returned body iterable. +""" + +from __future__ import annotations + +import io +from collections.abc import Iterable, Iterator +from typing import Any + +import pytest + +from localpost.http import ( + HTTPReqCtx, + Response, + Routes, + to_wsgi, +) + +# -------------------------------------------------------------------------- +# Helpers — drive the WSGI app and capture the response. +# -------------------------------------------------------------------------- + + +def _base_environ( + *, + method: str = "GET", + path: str = "/", + query: str = "", + headers: dict[str, str] | None = None, + body: bytes = b"", + server: tuple[str, str] = ("localhost", "8000"), + client: tuple[str, str] | None = ("203.0.113.4", "54321"), + scheme: str = "http", + file_wrapper: bool = False, +) -> dict[str, Any]: + environ: dict[str, Any] = { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "QUERY_STRING": query, + "SERVER_NAME": server[0], + "SERVER_PORT": server[1], + "SERVER_PROTOCOL": "HTTP/1.1", + "wsgi.version": (1, 0), + "wsgi.url_scheme": scheme, + "wsgi.input": io.BytesIO(body), + "wsgi.errors": io.StringIO(), + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + "CONTENT_LENGTH": str(len(body)) if body else "", + "CONTENT_TYPE": "", + } + if client is not None: + environ["REMOTE_ADDR"] = client[0] + environ["REMOTE_PORT"] = client[1] + if file_wrapper: + environ["wsgi.file_wrapper"] = lambda f, blksize=8192: _IterFile(f, blksize) + if headers: + for k, v in headers.items(): + if k.lower() == "content-type": + environ["CONTENT_TYPE"] = v + elif k.lower() == "content-length": + environ["CONTENT_LENGTH"] = v + else: + environ["HTTP_" + k.upper().replace("-", "_")] = v + return environ + + +class _IterFile: + """Minimal ``wsgi.file_wrapper`` stand-in — reads in ``blksize`` chunks.""" + + def __init__(self, fileobj: Any, blksize: int) -> None: + self._f = fileobj + self._blk = blksize + + def __iter__(self) -> Iterator[bytes]: + while True: + chunk = self._f.read(self._blk) + if not chunk: + return + yield chunk + + +def _drive(app: Any, environ: dict[str, Any]) -> tuple[str, list[tuple[str, str]], bytes]: + """Invoke the WSGI app once. Returns (status_line, headers, body).""" + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter: Iterable[bytes] = app(environ, start_response) + body = b"".join(body_iter) + close = getattr(body_iter, "close", None) + if close is not None: + close() + return captured["status"], captured["headers"], body + + +# -------------------------------------------------------------------------- +# Tests +# -------------------------------------------------------------------------- + + +class TestToWsgiComplete: + def test_simple_get(self): + def handler(ctx: HTTPReqCtx): + ctx.complete( + Response(200, [(b"content-type", b"text/plain"), (b"content-length", b"5")]), + b"hello", + ) + + status, headers, body = _drive(to_wsgi(handler), _base_environ()) + assert status.startswith("200 ") + assert ("content-type", "text/plain") in headers + assert body == b"hello" + + def test_204_no_body(self): + def handler(ctx: HTTPReqCtx): + ctx.complete(Response(204, [(b"content-length", b"0")])) + + status, _headers, body = _drive(to_wsgi(handler), _base_environ()) + assert status.startswith("204 ") + assert body == b"" + + def test_request_method_path_query(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["method"] = ctx.request.method + seen["path"] = ctx.request.path + seen["query"] = ctx.request.query_string + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(method="POST", path="/items/42", query="x=1&y=2") + _drive(to_wsgi(handler), environ) + assert seen["method"] == b"POST" + assert seen["path"] == b"/items/42" + assert seen["query"] == b"x=1&y=2" + + def test_request_body_buffered(self): + seen: dict[str, bytes] = {} + + def handler(ctx: HTTPReqCtx): + seen["body"] = ctx.body + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ( + method="POST", + body=b'{"hello":"world"}', + headers={"Content-Type": "application/json"}, + ) + _drive(to_wsgi(handler), environ) + assert seen["body"] == b'{"hello":"world"}' + + def test_headers_lowercased_and_passed_through(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["headers"] = dict(ctx.request.headers) + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ( + headers={ + "Content-Type": "application/json", + "Content-Length": "0", + "Authorization": "Bearer abc", + "X-Trace-Id": "t-1", + } + ) + _drive(to_wsgi(handler), environ) + h = seen["headers"] + assert h[b"content-type"] == b"application/json" + assert h[b"authorization"] == b"Bearer abc" + assert h[b"x-trace-id"] == b"t-1" + + def test_remote_addr_local_addr_scheme(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["remote"] = ctx.remote_addr + seen["local"] = ctx.local_addr + seen["scheme"] = ctx.scheme + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(server=("api.example.com", "443"), scheme="https") + _drive(to_wsgi(handler), environ) + assert seen["remote"] == "203.0.113.4:54321" + assert seen["local"] == "api.example.com:443" + assert seen["scheme"] == "https" + + def test_remote_addr_none_when_unknown(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["remote"] = ctx.remote_addr + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(client=None) + _drive(to_wsgi(handler), environ) + assert seen["remote"] is None + + +class TestToWsgiStream: + def test_stream_iterator_handed_off_lazily(self): + consumed: list[int] = [] + + def gen() -> Iterator[bytes]: + for i in range(3): + consumed.append(i) + yield f"chunk-{i}\n".encode() + + def handler(ctx: HTTPReqCtx): + ctx.stream( + Response(200, [(b"content-type", b"text/event-stream")]), + gen(), + ) + + environ = _base_environ() + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter = to_wsgi(handler)(environ, start_response) + # Lazy: nothing produced yet beyond what to_wsgi materialised. + assert consumed == [] + # Drain: WSGI server pacing. + chunks = list(body_iter) + assert consumed == [0, 1, 2] + assert b"".join(chunks) == b"chunk-0\nchunk-1\nchunk-2\n" + assert captured["status"].startswith("200 ") + + +class TestToWsgiImperative: + def test_imperative_chunks_buffered(self): + def handler(ctx: HTTPReqCtx): + ctx.start_response(Response(200, [(b"content-type", b"text/plain")])) + ctx.send(b"foo") + ctx.send(b"bar") + ctx.finish_response() + + status, headers, body = _drive(to_wsgi(handler), _base_environ()) + assert status.startswith("200 ") + assert ("content-type", "text/plain") in headers + assert body == b"foobar" + + def test_send_before_start_response_raises(self): + def handler(ctx: HTTPReqCtx): + ctx.send(b"oops") # bug + + with pytest.raises(RuntimeError, match=r"send.*before start_response"): + _drive(to_wsgi(handler), _base_environ()) + + +class TestToWsgiSendfile: + def test_sendfile_with_file_wrapper(self, tmp_path): + path = tmp_path / "f.txt" + path.write_bytes(b"abcdefghij") + + def handler(ctx: HTTPReqCtx): + f = path.open("rb") + ctx.sendfile( + Response(200, [(b"content-length", b"4")]), + f, + offset=2, + count=4, + ) + + status, _headers, body = _drive(to_wsgi(handler), _base_environ(file_wrapper=True)) + assert status.startswith("200 ") + assert body == b"cdef" + + def test_sendfile_without_file_wrapper_falls_back_to_chunks(self, tmp_path): + path = tmp_path / "f.txt" + path.write_bytes(b"abcdefghij") + + def handler(ctx: HTTPReqCtx): + f = path.open("rb") + ctx.sendfile( + Response(200, [(b"content-length", b"5")]), + f, + offset=0, + count=5, + ) + + status, _headers, body = _drive(to_wsgi(handler), _base_environ(file_wrapper=False)) + assert status.startswith("200 ") + assert body == b"abcde" + + +class TestToWsgiRouter: + def test_router_dispatch_404(self): + routes = Routes() + + @routes.get("/known") + def known(ctx: HTTPReqCtx): + ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") + + app = routes.build().as_wsgi() + status, _headers, body = _drive(app, _base_environ(path="/missing")) + assert status.startswith("404 ") + assert body == b"Not Found" + + def test_router_dispatch_405(self): + routes = Routes() + + @routes.get("/x") + def get_x(ctx: HTTPReqCtx): + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + app = routes.build().as_wsgi() + status, headers, _body = _drive(app, _base_environ(method="POST", path="/x")) + assert status.startswith("405 ") + assert ("Allow", "GET") in headers + + def test_router_match_with_path_args(self): + routes = Routes() + + @routes.get("/items/{id}") + def get_item(ctx: HTTPReqCtx): + from localpost.http.router import route_match # noqa: PLC0415 + + match = route_match(ctx) + body = match.path_args["id"].encode() + ctx.complete(Response(200, [(b"content-length", str(len(body)).encode())]), body) + + app = routes.build().as_wsgi() + status, _headers, body = _drive(app, _base_environ(path="/items/42")) + assert status.startswith("200 ") + assert body == b"42" + + +class TestToWsgiBorrow: + def test_borrowed_always_true(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["borrowed"] = ctx.borrowed + with ctx.borrow() as inner: + seen["inner"] = inner is ctx + seen["borrowed_inside"] = ctx.borrowed + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + _drive(to_wsgi(handler), _base_environ()) + assert seen["borrowed"] is True + assert seen["borrowed_inside"] is True + assert seen["inner"] is True + + +class TestToWsgiReceive: + def test_receive_slices_buffered_body(self): + chunks: list[bytes] = [] + + def handler(ctx: HTTPReqCtx): + while True: + c = ctx.receive(4) + if not c: + break + chunks.append(c) + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ(method="POST", body=b"abcdefghij") + _drive(to_wsgi(handler), environ) + assert chunks == [b"abcd", b"efgh", b"ij"] diff --git a/tests/openapi/wsgi.py b/tests/openapi/wsgi.py new file mode 100644 index 0000000..5a03d9a --- /dev/null +++ b/tests/openapi/wsgi.py @@ -0,0 +1,215 @@ +"""Tests for ``HttpApp.as_wsgi()`` — OpenAPI app under WSGI deployment. + +Drives the WSGI app directly (no live server). Covers the operation +pipeline, OpenAPI doc serving, and SSE streaming through ``ctx.stream``. +""" + +from __future__ import annotations + +import io +import json +from collections.abc import Iterable, Iterator +from dataclasses import dataclass +from typing import Any + +from localpost.openapi import Event, HttpApp, NotFound + + +@dataclass +class Pet: + name: str + age: int + + +@dataclass +class Book: + id: str + + +def _drive(app: Any, environ: dict[str, Any]) -> tuple[str, list[tuple[str, str]], bytes, Iterable[bytes]]: + """Returns (status, headers, joined_body_bytes, body_iter). + + Some tests need to inspect the iterator's chunk shape (lazy streaming), + so we return both the joined bytes and the underlying iter. + """ + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter: Iterable[bytes] = app(environ, start_response) + body = b"".join(body_iter) + close = getattr(body_iter, "close", None) + if close is not None: + close() + return captured["status"], captured["headers"], body, body_iter + + +def _get(path: str, query: str = "", headers: dict[str, str] | None = None) -> dict[str, Any]: + environ: dict[str, Any] = { + "REQUEST_METHOD": "GET", + "PATH_INFO": path, + "QUERY_STRING": query, + "SERVER_NAME": "localhost", + "SERVER_PORT": "8000", + "SERVER_PROTOCOL": "HTTP/1.1", + "wsgi.version": (1, 0), + "wsgi.url_scheme": "http", + "wsgi.input": io.BytesIO(b""), + "wsgi.errors": io.StringIO(), + "wsgi.multithread": True, + "wsgi.multiprocess": False, + "wsgi.run_once": False, + "CONTENT_LENGTH": "", + "CONTENT_TYPE": "", + "REMOTE_ADDR": "127.0.0.1", + "REMOTE_PORT": "5555", + } + if headers: + for k, v in headers.items(): + environ["HTTP_" + k.upper().replace("-", "_")] = v + return environ + + +def _post_json(path: str, payload: bytes) -> dict[str, Any]: + environ = _get(path) + environ["REQUEST_METHOD"] = "POST" + environ["wsgi.input"] = io.BytesIO(payload) + environ["CONTENT_LENGTH"] = str(len(payload)) + environ["CONTENT_TYPE"] = "application/json" + return environ + + +# -------------------------------------------------------------------------- +# Tests +# -------------------------------------------------------------------------- + + +class TestHttpAppAsWsgiBasic: + def test_simple_get_string_return(self): + app = HttpApp() + + @app.get("/hello/{name}") + def hello(name: str) -> str: + return f"Hello, {name}!" + + wsgi_app = app.as_wsgi() + status, headers, body, _ = _drive(wsgi_app, _get("/hello/world")) + assert status.startswith("200 ") + assert ("content-type", "text/plain; charset=utf-8") in headers + assert body == b"Hello, world!" + + def test_post_json_body(self): + app = HttpApp() + + @app.post("/pets") + def create(pet: Pet) -> Pet: + return pet + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive( + wsgi_app, _post_json("/pets", b'{"name":"Rex","age":3}') + ) + assert status.startswith("200 ") + assert json.loads(body) == {"name": "Rex", "age": 3} + + def test_query_param(self): + app = HttpApp() + + @app.get("/search") + def search(q: str, limit: int = 10) -> dict[str, Any]: + return {"q": q, "limit": limit} + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _get("/search", "q=hello&limit=5")) + assert status.startswith("200 ") + assert json.loads(body) == {"q": "hello", "limit": 5} + + def test_404_via_null_return(self): + app = HttpApp() + + @app.get("/books/{book_id}") + def get_book(book_id: str) -> Book | NotFound[str]: + if book_id == "42": + return Book(id=book_id) + return NotFound(f"no such book: {book_id}") + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _get("/books/99")) + assert status.startswith("404 ") + assert b"no such book: 99" in body + + def test_router_404_for_unknown_path(self): + app = HttpApp() + + @app.get("/x") + def x() -> str: + return "x" + + wsgi_app = app.as_wsgi() + status, _headers, body, _ = _drive(wsgi_app, _get("/y")) + assert status.startswith("404 ") + assert body == b"Not Found" + + +class TestHttpAppAsWsgiOpenAPIDoc: + def test_serves_openapi_json(self): + app = HttpApp() + + @app.get("/hello") + def hello() -> str: + return "hi" + + wsgi_app = app.as_wsgi() + status, headers, body, _ = _drive(wsgi_app, _get("/openapi.json")) + assert status.startswith("200 ") + assert ("content-type", "application/json") in headers + doc = json.loads(body) + assert doc["openapi"].startswith("3.") + assert "/hello" in doc["paths"] + + def test_serves_swagger_ui(self): + app = HttpApp() + wsgi_app = app.as_wsgi() + status, headers, body, _ = _drive(wsgi_app, _get("/docs")) + assert status.startswith("200 ") + assert any(k == "content-type" and "text/html" in v for k, v in headers) + assert b"swagger-ui" in body.lower() + + +class TestHttpAppAsWsgiStreaming: + def test_sse_generator_streams_lazily(self): + consumed: list[int] = [] + + app = HttpApp() + + @app.get("/feed") + def feed() -> Iterator[Event[str]]: + for i in range(3): + consumed.append(i) + yield Event(data=f"event-{i}") + + wsgi_app = app.as_wsgi() + environ = _get("/feed") + + captured: dict[str, Any] = {} + + def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None) -> Any: + captured["status"] = status + captured["headers"] = headers + return lambda _: None + + body_iter = wsgi_app(environ, start_response) + # Pre-iteration: lazy — only what to_wsgi needed to materialise + # (which is nothing — stream() captures the iterator and returns it). + assert consumed == [] + # Drain — chunks come out one at a time as the WSGI host iterates. + chunks = list(body_iter) + assert consumed == [0, 1, 2] + assert captured["status"].startswith("200 ") + assert any(k == "content-type" and "text/event-stream" in v for k, v in captured["headers"]) + joined = b"".join(chunks) + assert b"data: event-0" in joined + assert b"data: event-2" in joined From 9fb7927e787d000ad164ff5c695b785a2c4fb03c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 01:25:33 +0400 Subject: [PATCH 187/286] refactor(http,openapi): consolidate shared helpers; drop redundant locals http: lift `content_length` and `scan_response_headers` into `_base.py` and replace the per-backend duplicates in `server_h11.py` / `server_httptools.py`. Unify the canned-error reason table with the httptools backend's response-writer reasons under a single `REASON_PHRASES` dict. http: add `_sentry.py` with a `request_transaction` context manager that opens an isolation scope, starts a transaction, and applies the standard `http.method` / `http.url` tags. `flask_sentry` and `router_sentry` now share that setup. openapi/middleware: hoist the `OpResult` import out of `_FunctionMiddleware.__call__` (no actual cycle); the runtime hot path no longer pays for an import-inside-function. Drive-by: rename `_wsgi_write_deprecated` to `_wsgi_write_unsupported`, and clear pre-existing lint failures in tests (StrEnum, set comprehension, and a handful of `noqa` markers for path-var shadowing / multi-statement `pytest.raises` blocks). 716 unit tests pass; `just format-all` clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/_base.py | 76 ++++++++++++++++++++++++-- localpost/http/_sentry.py | 33 +++++++++++ localpost/http/compress.py | 52 ++++++++++-------- localpost/http/flask_sentry.py | 49 +++++++---------- localpost/http/router_sentry.py | 12 ++-- localpost/http/server_h11.py | 23 ++------ localpost/http/server_httptools.py | 71 +++--------------------- localpost/http/static.py | 7 +-- localpost/http/wsgi.py | 12 ++-- localpost/openapi/middleware.py | 13 ++--- localpost/openapi/operation.py | 4 +- localpost/threadtools.py | 4 +- tests/http/backend_parity.py | 4 +- tests/http/compress.py | 16 ++---- tests/http/service.py | 2 + tests/openapi/app.py | 9 ++- tests/openapi/polish.py | 1 - tests/openapi/schemas.py | 8 +-- tests/openapi/spec.py | 2 +- tests/openapi/wsgi.py | 4 +- tests/threadtools/thread_task_group.py | 8 +-- 21 files changed, 205 insertions(+), 205 deletions(-) create mode 100644 localpost/http/_sentry.py diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 99606f5..dd63c78 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -98,6 +98,56 @@ class _OpCleanup: _Op = _OpTrack | _OpClose | _OpCleanup +# -------------------------------------------------------------------------- +# Header-scanning helpers (parser-agnostic; both backends use them) +# -------------------------------------------------------------------------- + + +def content_length(headers: Any) -> int | None: + """Return the ``Content-Length`` value as an int, or ``None`` if absent / malformed. + + Both parsers normalise header names to lowercase bytes, so a direct + equality check is enough. + """ + for name, value in headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + +def scan_response_headers(headers: Any) -> tuple[bool, bool, bool]: + """One-pass scan: ``(has_connection_close, has_framing, has_chunked)``. + + - ``has_framing`` — either ``Content-Length`` or ``Transfer-Encoding`` + is set, i.e. the auto-frame branch in ``start_response`` should be + skipped. + - ``has_chunked`` — ``Transfer-Encoding`` carries a ``chunked`` token + anywhere in its value (per RFC 7230 §3.3.1, ``chunked`` must be the + final encoding). When true, the httptools backend's ``_chunked`` + flag must be set so subsequent ``send`` calls actually wrap chunks + with ``\\r\\n\\r\\n`` framing. + + Combined to avoid multiple walks per response. + """ + has_close = False + has_framing = False + has_chunked = False + for name, value in headers: + n = name.lower() + if n == b"connection" and b"close" in value.lower(): + has_close = True + elif n == b"content-length": + has_framing = True + elif n == b"transfer-encoding": + has_framing = True + if b"chunked" in value.lower(): + has_chunked = True + return has_close, has_framing, has_chunked + + # -------------------------------------------------------------------------- # Canned protocol-error responses # -------------------------------------------------------------------------- @@ -108,14 +158,29 @@ class _OpCleanup: # response — see ``_PRESERIALIZED`` below — so error paths skip # ``_serialize_response`` entirely. -# Reason phrases used by the pre-serialised wire form below. Kept here (not -# in the httptools backend) so it stays parser-agnostic and the constants -# below resolve at module import time even when httptools isn't installed. -_CANNED_REASONS: dict[int, bytes] = { +# RFC 7231 §6.1 reason phrases for the codes the server side actually emits. +# Single source of truth for both the canned-error wire form below and +# the httptools backend's response writer (``_serialize_response``). Kept +# here so it resolves at module import time even when httptools isn't +# installed. +REASON_PHRASES: dict[int, bytes] = { + 100: b"Continue", + 200: b"OK", + 204: b"No Content", + 301: b"Moved Permanently", + 302: b"Found", + 304: b"Not Modified", 400: b"Bad Request", + 401: b"Unauthorized", + 403: b"Forbidden", + 404: b"Not Found", + 405: b"Method Not Allowed", 408: b"Request Timeout", 413: b"Payload Too Large", + 417: b"Expectation Failed", 500: b"Internal Server Error", + 501: b"Not Implemented", + 502: b"Bad Gateway", 503: b"Service Unavailable", } @@ -138,7 +203,7 @@ def _build_canned(status_code: int, body: bytes) -> tuple[Response, bytes]: prelude = bytearray(b"HTTP/1.1 ") prelude += str(status_code).encode("ascii") prelude += b" " - prelude += _CANNED_REASONS[status_code] + prelude += REASON_PHRASES[status_code] prelude += b"\r\n" for name, value in response.headers: prelude += name @@ -226,6 +291,7 @@ def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: :meth:`finish_response`. Pure-imperative callers still have the trio available; ``stream`` is the path most operations want. """ + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: """Emit ``response`` and stream ``count`` bytes from ``file`` starting at ``offset`` via :func:`socket.sendfile` (zero-copy). Terminal — like diff --git a/localpost/http/_sentry.py b/localpost/http/_sentry.py new file mode 100644 index 0000000..3390800 --- /dev/null +++ b/localpost/http/_sentry.py @@ -0,0 +1,33 @@ +"""Shared helpers for the Sentry tracing wrappers. + +Used by :mod:`localpost.http.router_sentry` and +:mod:`localpost.http.flask_sentry`. Not part of the public API. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +import sentry_sdk +from sentry_sdk.tracing import Span + + +@contextmanager +def request_transaction( + *, + op: str, + name: str, + source: str, + method: str, + url: str, +) -> Iterator[Span]: + """Open a Sentry isolation scope + transaction for an HTTP request. + + Sets standard request tags (``http.method``, ``http.url``); callers + record the response status via ``tx.set_http_status`` before exit. + """ + with sentry_sdk.isolation_scope(), sentry_sdk.start_transaction(op=op, name=name, source=source) as tx: + tx.set_tag("http.method", method) + tx.set_data("http.url", url) + yield tx diff --git a/localpost/http/compress.py b/localpost/http/compress.py index bccc7c0..25cd0ea 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -45,24 +45,26 @@ ] -DEFAULT_COMPRESSIBLE_TYPES: Final[frozenset[bytes]] = frozenset({ - b"text/html", - b"text/plain", - b"text/css", - b"text/xml", - b"text/csv", - b"text/javascript", - b"text/event-stream", - b"application/json", - b"application/javascript", - b"application/xml", - b"application/xhtml+xml", - b"application/manifest+json", - b"application/x-yaml", - b"application/rss+xml", - b"application/atom+xml", - b"image/svg+xml", -}) +DEFAULT_COMPRESSIBLE_TYPES: Final[frozenset[bytes]] = frozenset( + { + b"text/html", + b"text/plain", + b"text/css", + b"text/xml", + b"text/csv", + b"text/javascript", + b"text/event-stream", + b"application/json", + b"application/javascript", + b"application/xml", + b"application/xhtml+xml", + b"application/manifest+json", + b"application/x-yaml", + b"application/rss+xml", + b"application/atom+xml", + b"image/svg+xml", + } +) # -------------------------------------------------------------------------- @@ -586,12 +588,14 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: body_handler: BodyHandler = result def proxied_body(real_ctx: HTTPReqCtx) -> None: - body_handler(_CompressedCtx( # type: ignore[arg-type] - _inner=real_ctx, - _encoding=chosen, - _min_size=min_size, - _compressible_types=compressible_types, - )) + body_handler( + _CompressedCtx( # type: ignore[arg-type] + _inner=real_ctx, + _encoding=chosen, + _min_size=min_size, + _compressible_types=compressible_types, + ) + ) return proxied_body diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index 775761d..67318ae 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -30,6 +30,7 @@ from flask import request as flask_request from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._sentry import request_transaction from localpost.http.flask import _write_response from localpost.http.wsgi import _build_environ @@ -50,35 +51,27 @@ def run_flask_with_sentry(ctx: HTTPReqCtx) -> None: path = environ["PATH_INFO"] with ( - sentry_sdk.isolation_scope(), - sentry_sdk.start_transaction( - op=op, - name=f"{method} {path}", - source="url", - ) as tx, + request_transaction(op=op, name=f"{method} {path}", source="url", method=method, url=path) as tx, + app.request_context(environ), ): - tx.set_tag("http.method", method) - tx.set_data("http.url", path) - - with app.request_context(environ): - # Once routing has matched, rename the transaction to the route rule - # for low cardinality. - rule = flask_request.url_rule - if rule is not None: - sentry_sdk.get_current_scope().set_transaction_name( - f"{method} {rule.rule}", - source="route", - ) - - try: - response = app.full_dispatch_request() - except Exception as exc: # noqa: BLE001 - response = app.handle_exception(exc) - tx.set_http_status(response.status_code) - try: - _write_response(ctx, response) - finally: - response.close() + # Once routing has matched, rename the transaction to the route rule + # for low cardinality. + rule = flask_request.url_rule + if rule is not None: + sentry_sdk.get_current_scope().set_transaction_name( + f"{method} {rule.rule}", + source="route", + ) + + try: + response = app.full_dispatch_request() + except Exception as exc: # noqa: BLE001 + response = app.handle_exception(exc) + tx.set_http_status(response.status_code) + try: + _write_response(ctx, response) + finally: + response.close() def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: return run_flask_with_sentry diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 19ed26a..2fee36a 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -35,9 +35,8 @@ def get_book(ctx): ... import sys -import sentry_sdk - from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._sentry import request_transaction from localpost.http.router import Router, _MatchOk __all__ = ["sentry_router_handler"] @@ -68,18 +67,15 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: tx_name = f"{method} {path}" source = "url" - scope_cm = sentry_sdk.isolation_scope() - scope_cm.__enter__() - tx_cm = sentry_sdk.start_transaction(op=op, name=tx_name, source=source) + # Manual __enter__/__exit__ because the transaction may need to + # span a deferred body handler returned below. + tx_cm = request_transaction(op=op, name=tx_name, source=source, method=method, url=target) tx = tx_cm.__enter__() - tx.set_tag("http.method", method) - tx.set_data("http.url", target) def finalize(exc_info: tuple = (None, None, None)) -> None: if ctx.response_status is not None: tx.set_http_status(ctx.response_status) tx_cm.__exit__(*exc_info) - scope_cm.__exit__(*exc_info) try: result = inner(ctx) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index ea1b39f..7588c98 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -39,8 +39,10 @@ RequestHandler, Selector, _send_all, + content_length, emit_handler_error, native_stream, + scan_response_headers, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.config import DEFAULT_BUFFER_SIZE @@ -55,21 +57,6 @@ def _to_h11_response(r: Response | InformationalResponse) -> h11.Response | h11. return h11.InformationalResponse(status_code=r.status_code, headers=headers, reason=r.reason) -def _content_length(headers) -> int | None: - # h11 normalizes header names to lowercase bytes — direct equality is enough. - for name, value in headers: - if name == b"content-length": - try: - return int(value) - except ValueError: - return None - return None - - -def _has_response_framing(headers) -> bool: - return any(name.lower() in {b"content-length", b"transfer-encoding"} for name, _ in headers) - - @final @dataclass(frozen=True, slots=True, eq=False) class _SendfilePlaceholder: @@ -232,7 +219,7 @@ def _loop(self) -> None: if not self.tracked: return elif isinstance(event, h11.Request): - cl = _content_length(event.headers) + cl = content_length(event.headers) if cl is not None and cl > self.selector.config.max_body_size: raise BodyTooLarge(cl) # h11 hands us ``bytes`` for method/target/version and a list @@ -403,7 +390,7 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) # ``Content-Length`` framing is required — otherwise h11's writer # state can't reconcile what we wrote out-of-band. The static handler # always sets it; bail loudly if a caller forgets. - if _content_length(response.headers) != count: + if content_length(response.headers) != count: raise ValueError("sendfile requires Content-Length matching ``count``") self.start_response(response) # Advance h11's ContentLengthWriter counter via a placeholder Data @@ -474,7 +461,7 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: def start_response(self, response: Response | InformationalResponse, /) -> None: if isinstance(response, Response): self.response_status = response.status_code - if self.request.method == b"HEAD" and not _has_response_framing(response.headers): + if self.request.method == b"HEAD" and not scan_response_headers(response.headers)[1]: response = Response( status_code=response.status_code, headers=[*response.headers, (b"content-length", b"0")], diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index b2282d1..e4c02f0 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -26,7 +26,7 @@ import socket import time -from collections.abc import Buffer, Callable, Iterator, Sequence +from collections.abc import Buffer, Callable, Iterator from contextlib import contextmanager from dataclasses import dataclass, field from typing import Any, BinaryIO, final @@ -36,14 +36,17 @@ from localpost.http._base import ( BAD_REQUEST_WIRE, PAYLOAD_TOO_LARGE_WIRE, + REASON_PHRASES, REQUEST_TIMEOUT_WIRE, BaseHTTPConn, BodyHandler, RequestHandler, Selector, _send_all, + content_length, emit_handler_error, native_stream, + scan_response_headers, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response from localpost.http.config import DEFAULT_BUFFER_SIZE @@ -51,35 +54,12 @@ __all__ = ["HTTPConn"] -# RFC 7231 §6.1 reason phrases for the codes the server-side actually emits. -_DEFAULT_REASONS: dict[int, bytes] = { - 100: b"Continue", - 200: b"OK", - 204: b"No Content", - 301: b"Moved Permanently", - 302: b"Found", - 304: b"Not Modified", - 400: b"Bad Request", - 401: b"Unauthorized", - 403: b"Forbidden", - 404: b"Not Found", - 405: b"Method Not Allowed", - 408: b"Request Timeout", - 413: b"Payload Too Large", - 417: b"Expectation Failed", - 500: b"Internal Server Error", - 501: b"Not Implemented", - 502: b"Bad Gateway", - 503: b"Service Unavailable", -} - - def _serialize_response(r: Response | InformationalResponse) -> bytes: """Serialise a status + headers block to wire bytes (no body).""" out = bytearray(b"HTTP/1.1 ") out += str(r.status_code).encode("ascii") out += b" " - out += r.reason or _DEFAULT_REASONS.get(r.status_code, b"") + out += r.reason or REASON_PHRASES.get(r.status_code, b"") out += b"\r\n" for name, value in r.headers: out += name @@ -90,36 +70,6 @@ def _serialize_response(r: Response | InformationalResponse) -> bytes: return bytes(out) -def _scan_response_headers(headers: Sequence[tuple[bytes, bytes]]) -> tuple[bool, bool, bool]: - """One-pass scan: ``(has_connection_close, has_framing, has_chunked)``. - - - ``has_framing`` — either ``Content-Length`` or ``Transfer-Encoding`` - is set, i.e. the auto-frame branch in :meth:`start_response` should - be skipped. - - ``has_chunked`` — ``Transfer-Encoding`` carries a ``chunked`` token - anywhere in its value (per RFC 7230 §3.3.1, ``chunked`` must be the - final encoding). When true, ``_chunked`` must be set so subsequent - ``send`` calls actually wrap chunks with ``\\r\\n\\r\\n`` - framing — otherwise the wire is malformed. - - Combined to avoid multiple walks per response. - """ - has_close = False - has_framing = False - has_chunked = False - for name, value in headers: - n = name.lower() - if n == b"connection" and b"close" in value.lower(): - has_close = True - elif n == b"content-length": - has_framing = True - elif n == b"transfer-encoding": - has_framing = True - if b"chunked" in value.lower(): - has_chunked = True - return has_close, has_framing, has_chunked - - def _response_allows_body(request_method: bytes, status_code: int) -> bool: return request_method != b"HEAD" and not (100 <= status_code < 200 or status_code in {204, 304}) @@ -238,14 +188,7 @@ def on_headers_complete(self) -> None: self._cur_target_buf = None # Eager content-length cap check. - cl: int | None = None - for n, v in self._cur_headers: - if n == b"content-length": - try: - cl = int(v) - except ValueError: - cl = None - break + cl = content_length(self._cur_headers) if cl is not None and cl > self.selector.config.max_body_size: self._body_too_large = cl self._cur_oversize = True @@ -685,7 +628,7 @@ def start_response(self, response: Response | InformationalResponse, /) -> None: self.response_status = response.status_code self.conn._response_started = True self._body_allowed = _response_allows_body(self.request.method, response.status_code) - has_close, has_framing, has_chunked = _scan_response_headers(response.headers) + has_close, has_framing, has_chunked = scan_response_headers(response.headers) if has_close or not self._keep_alive: self.conn._close_after_response = True if self._body_allowed: diff --git a/localpost/http/static.py b/localpost/http/static.py index d088263..8b93dad 100644 --- a/localpost/http/static.py +++ b/localpost/http/static.py @@ -66,8 +66,7 @@ def static_handler( from localpost.http.static import static_handler h = thread_pool_handler( - static_handler("/var/www", prefix=b"/static/", - cache_control="public, max-age=31536000, immutable"), + static_handler("/var/www", prefix=b"/static/", cache_control="public, max-age=31536000, immutable"), ) """ root_path = Path(os.fspath(root)).resolve(strict=True) @@ -97,7 +96,7 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: _send_not_found(ctx, method) return None - target = _resolve(root_path, url_path[len(prefix):], index=index) + target = _resolve(root_path, url_path[len(prefix) :], index=index) if target is None: _send_not_found(ctx, method) return None @@ -380,5 +379,3 @@ def _send_range_not_satisfiable( ), None if method == b"HEAD" else _RANGE_NOT_SATISFIABLE_BODY, ) - - diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 8e9c558..0038e87 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -61,6 +61,10 @@ def readinto(self, buffer: Buffer, /) -> int: return take +def _wsgi_write_unsupported(_: bytes) -> None: + raise NotImplementedError("The WSGI write() callable is deprecated and not supported") + + def wrap_wsgi(app: WSGIApplication) -> RequestHandler: """Wrap a WSGI application as a native :class:`RequestHandler`. @@ -93,7 +97,7 @@ def start_response( headers=wire_headers, reason=reason.encode("iso-8859-1") if reason else b"", ) - return _wsgi_write_deprecated + return _wsgi_write_unsupported body_iter = app(environ, start_response) try: @@ -126,10 +130,6 @@ def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: return pre_body -def _wsgi_write_deprecated(_: bytes) -> None: - raise NotImplementedError("The WSGI write() callable is deprecated and not supported") - - def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: request = ctx.request # ``request.path`` / ``request.query_string`` are pre-split by the @@ -342,10 +342,12 @@ def to_wsgi(handler: RequestHandler) -> WSGIApplication: app = HttpApp() + @app.get("/hello/{name}") def hello(name: str) -> str: return f"Hello, {name}!" + wsgi_app = to_wsgi(app._build_router_handler()) # or app.as_wsgi() Then ``gunicorn myapp:wsgi_app``. diff --git a/localpost/openapi/middleware.py b/localpost/openapi/middleware.py index d447bfc..2f39c3a 100644 --- a/localpost/openapi/middleware.py +++ b/localpost/openapi/middleware.py @@ -65,10 +65,11 @@ def me() -> User: ... from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from localpost.openapi.results import OpResult + if TYPE_CHECKING: from localpost.http import HTTPReqCtx from localpost.openapi import spec - from localpost.openapi.results import OpResult from localpost.openapi.schemas import SchemaRegistry __all__ = ["ApiOperation", "OpMiddleware", "op_middleware"] @@ -138,16 +139,14 @@ class _FunctionMiddleware: root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: - from localpost.openapi.results import OpResult as _OpResult # noqa: PLC0415 - kwargs: dict[str, object] = {self.call_next_param: call_next} for name, resolver in self.arg_resolvers: value = resolver(ctx) - if isinstance(value, _OpResult): + if isinstance(value, OpResult): return value kwargs[name] = value result = self.target(**kwargs) - if isinstance(result, _OpResult): + if isinstance(result, OpResult): return result name = getattr(self.target, "__qualname__", None) or repr(self.target) raise TypeError( @@ -178,9 +177,7 @@ def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) op = replace(op, responses={**op.responses, code: response}) return op - def with_root( - self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] - ) -> _FunctionMiddleware: + def with_root(self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI]) -> _FunctionMiddleware: """Return a copy that also contributes ``contribution`` at the root. Used by auth middlewares to register their ``SecurityScheme`` diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index 5cff0c3..a374d5f 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -104,9 +104,7 @@ def create( path_var_names = set(template.variable_names) registry = adapters or default_registry() - sig, arg_resolvers, arg_factories = build_arg_resolvers( - fn, path_var_names=path_var_names, adapters=registry - ) + sig, arg_resolvers, arg_factories = build_arg_resolvers(fn, path_var_names=path_var_names, adapters=registry) # Validate path bindings: every {var} in the template must be # claimed by a parameter (whether implicitly via name match or diff --git a/localpost/threadtools.py b/localpost/threadtools.py index aab787d..d1175fd 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -570,9 +570,7 @@ def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> N # — matches Trio semantics for ``KeyboardInterrupt`` / ``SystemExit``. raise BaseExceptionGroup(label, all_errors) - def start_soon[**P, R]( - self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs - ) -> Future[R]: + def start_soon[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future.""" with self._lock: if self._closed: diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index e28302b..3d87d94 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -263,9 +263,7 @@ def handler(ctx: HTTPReqCtx) -> None: ctx.send(b"chunk2") ctx.finish_response() - with serve_backend_in_thread(handler) as port, socket.create_connection( - ("127.0.0.1", port), timeout=5 - ) as s: + with serve_backend_in_thread(handler) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") s.settimeout(5) buf = b"" diff --git a/tests/http/compress.py b/tests/http/compress.py index 0933b8d..c9ecaa9 100644 --- a/tests/http/compress.py +++ b/tests/http/compress.py @@ -507,9 +507,7 @@ def _sse_handler(events: list[bytes]): def handler(ctx: HTTPReqCtx) -> BodyHandler | None: # No Content-Length → streaming path. - ctx.start_response( - Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]) - ) + ctx.start_response(Response(status_code=200, headers=[(b"content-type", b"text/event-stream")])) for ev in events: ctx.send(b"data: " + ev + b"\n\n") ctx.finish_response() @@ -632,9 +630,7 @@ def test_first_event_decodes_before_stream_ends(self, serve_backend_in_thread): gate = threading.Event() def handler(ctx: HTTPReqCtx) -> BodyHandler | None: - ctx.start_response( - Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]) - ) + ctx.start_response(Response(status_code=200, headers=[(b"content-type", b"text/event-stream")])) ctx.send(b"data: first\n\n") # Block until the test has read+decoded "data: first". If # SYNC_FLUSH didn't happen, this deadlocks at the 5s timeout. @@ -644,12 +640,8 @@ def handler(ctx: HTTPReqCtx) -> BodyHandler | None: return None h = compress_handler(handler, algorithms=("gzip",)) - with serve_backend_in_thread(h) as port, socket.create_connection( - ("127.0.0.1", port), timeout=5 - ) as s: - s.sendall( - b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n" - ) + with serve_backend_in_thread(h) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n") buf = bytearray() s.settimeout(5) while b"\r\n\r\n" not in buf: diff --git a/tests/http/service.py b/tests/http/service.py index 1ed320f..3eb1ee0 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -34,6 +34,7 @@ streaming_pool_handler, thread_pool_handler, ) + pytestmark = pytest.mark.anyio @@ -254,6 +255,7 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: lt.shutdown() await lt.stopped + class TestMultiSelector: """``selectors=N > 1`` spawns N independent ``BaseServer`` threads bound to the same address via ``SO_REUSEPORT``. Tests assert correctness; the diff --git a/tests/openapi/app.py b/tests/openapi/app.py index 1965af1..9536259 100644 --- a/tests/openapi/app.py +++ b/tests/openapi/app.py @@ -29,7 +29,6 @@ ) from localpost.openapi.operation import Operation - # --- Fakes --------------------------------------------------------------- @@ -192,7 +191,7 @@ def test_op_result_subclass_uses_its_status(self): app = HttpApp() @app.get("/b/{id}") - def get(id: str) -> Book | NotFound[str]: + def get(id: str) -> Book | NotFound[str]: # noqa: A002 return NotFound(f"missing: {id}") op = app.operations[0] @@ -205,7 +204,7 @@ def test_no_content_emits_empty_body(self): app = HttpApp() @app.delete("/b/{id}") - def delete(id: str) -> NoContent: + def delete(id: str) -> NoContent: # noqa: A002 return NoContent() op = app.operations[0] @@ -368,7 +367,7 @@ def test_response_schemas_per_status(self): app = HttpApp() @app.get("/b/{id}") - def get(id: str) -> Book | NotFound[str] | BadRequest[str]: + def get(id: str) -> Book | NotFound[str] | BadRequest[str]: # noqa: A002 return Book(id=id, title="x") doc = app.openapi_doc.to_dict() @@ -394,7 +393,7 @@ def test_components_populated(self): app = HttpApp() @app.get("/b/{id}") - def get(id: str) -> Book: + def get(id: str) -> Book: # noqa: A002 return Book(id=id, title="x") doc = app.openapi_doc.to_dict() diff --git a/tests/openapi/polish.py b/tests/openapi/polish.py index 8882b53..0eac847 100644 --- a/tests/openapi/polish.py +++ b/tests/openapi/polish.py @@ -11,7 +11,6 @@ from localpost.openapi import FromHeader, FromPath, FromQuery, HttpApp, NotFound from tests.openapi.app import make_ctx, run_op - # --- Optional handling --------------------------------------------------- diff --git a/tests/openapi/schemas.py b/tests/openapi/schemas.py index eb40b3c..1ea033d 100644 --- a/tests/openapi/schemas.py +++ b/tests/openapi/schemas.py @@ -4,7 +4,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from enum import Enum +from enum import StrEnum from typing import Any, Literal import msgspec @@ -26,7 +26,7 @@ class Author(msgspec.Struct): age: int -class Status(str, Enum): +class Status(StrEnum): OPEN = "open" CLOSED = "closed" @@ -160,9 +160,7 @@ def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: self.schema_calls.append(t) return {"$ref": ref_template.format(name="Marker")} - def components( - self, types: Sequence[Any], /, *, ref_template: str - ) -> dict[str, dict[str, Any]]: + def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: self.components_calls.append(types) return {"Marker": {"type": "object", "x-fake": True}} diff --git a/tests/openapi/spec.py b/tests/openapi/spec.py index 579d088..82ae5ac 100644 --- a/tests/openapi/spec.py +++ b/tests/openapi/spec.py @@ -98,7 +98,7 @@ def test_oauth2_with_device_flow(self): flows=spec.OAuthFlows( device_authorization=spec.OAuthFlow( device_authorization_url="https://auth.example/device", - token_url="https://auth.example/token", + token_url="https://auth.example/token", # noqa: S106 scopes={"read": "Read access"}, ) ), diff --git a/tests/openapi/wsgi.py b/tests/openapi/wsgi.py index 5a03d9a..bf9b4e4 100644 --- a/tests/openapi/wsgi.py +++ b/tests/openapi/wsgi.py @@ -109,9 +109,7 @@ def create(pet: Pet) -> Pet: return pet wsgi_app = app.as_wsgi() - status, _headers, body, _ = _drive( - wsgi_app, _post_json("/pets", b'{"name":"Rex","age":3}') - ) + status, _headers, body, _ = _drive(wsgi_app, _post_json("/pets", b'{"name":"Rex","age":3}')) assert status.startswith("200 ") assert json.loads(body) == {"name": "Rex", "age": 3} diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 5f3e772..5bc3ad9 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -15,7 +15,7 @@ def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch): """Make idle-timeout tests fast: 100 ms instead of 60 s.""" monkeypatch.setattr(threadtools, "_IDLE_TIMEOUT", 0.1) - yield 0.1 + return 0.1 def _drain_idle_workers() -> None: @@ -73,7 +73,7 @@ class Boom(Exception): def bad(): raise Boom("nope") - with pytest.raises(ExceptionGroup) as ei: + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 with ThreadTaskGroup() as tg: fut = tg.start_soon(bad) # Future records the exception too @@ -104,7 +104,7 @@ class Task(Exception): def bad(): raise Task("task") - with pytest.raises(ExceptionGroup) as ei: + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 with ThreadTaskGroup() as tg: tg.start_soon(bad) time.sleep(0.05) # let the task land @@ -313,7 +313,7 @@ def test_warmup_adds_workers_to_idle_pool(): # Each entry is a distinct, alive worker. workers = list(threadtools._idle) assert all(w._alive for w in workers) - assert len(set(id(w) for w in workers)) == 4 + assert len({id(w) for w in workers}) == 4 def test_warmup_workers_are_used_by_dispatch(): From b855dad3f89d309439fa9f342c2241ba97e078b2 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 01:31:31 +0400 Subject: [PATCH 188/286] refactor(openapi/spec): replace 23 hand-written to_dict methods with a generic helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dataclasses in ``spec.py`` all serialised by walking their fields, emitting non-default values, and applying a snake_case → camelCase rename. Replaced the per-class boilerplate with two helpers: - ``_to_dict(obj, *, always=...)`` walks ``dataclasses.fields(obj)``, skipping any field whose value matches its declared default. Names map to JSON keys via the snake_case → camelCase rule, with explicit overrides for ``ref`` (→ ``$ref``) and ``location`` (→ ``in``). - ``_dump`` recursively flattens nested dataclasses and tuple/dict containers for JSON output. Per-class behaviour is preserved by passing ``always=...`` for fields that emit even at their default (``Info.title`` / ``version``, ``OpenAPI.openapi`` / ``info``, ``Response.description``, ``Parameter.location``, ``OAuthFlow.scopes``, ``Reference.ref``). ``PathItem.to_dict`` keeps a small explicit body because of the ``$ref`` early-return short-circuit. Net: 149 fewer lines (-26%), no behaviour change. All 142 openapi tests pass, full unit suite (716) clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/spec.py | 321 ++++++++++---------------------------- 1 file changed, 86 insertions(+), 235 deletions(-) diff --git a/localpost/openapi/spec.py b/localpost/openapi/spec.py index 1de8d57..1394200 100644 --- a/localpost/openapi/spec.py +++ b/localpost/openapi/spec.py @@ -7,6 +7,7 @@ from __future__ import annotations +import dataclasses from dataclasses import dataclass, field, replace from typing import Any, Literal @@ -16,6 +17,67 @@ SecuritySchemeType = Literal["apiKey", "http", "oauth2", "openIdConnect", "mutualTLS"] +# --- Serialisation helpers ------------------------------------------------ + +# Field-name → output-key overrides for fields whose JSON name doesn't +# follow the snake_case → camelCase rule (e.g. ``ref`` → ``$ref``). +_KEY_OVERRIDES: dict[str, str] = { + "ref": "$ref", + "location": "in", +} + + +def _camel(name: str) -> str: + head, *tail = name.split("_") + return head + "".join(p.capitalize() for p in tail) + + +def _key(name: str) -> str: + return _KEY_OVERRIDES.get(name, _camel(name)) + + +def _dump(value: Any) -> Any: + """Recursively serialise dataclasses/containers for JSON output. + + Tuples become lists; nested dataclasses are flattened via their own + ``to_dict``. Plain JSON-compatible values pass through. + """ + if hasattr(value, "to_dict"): + return value.to_dict() + if isinstance(value, dict): + return {k: _dump(v) for k, v in value.items()} + if isinstance(value, (tuple, list)): + return [_dump(v) for v in value] + return value + + +_MISSING = dataclasses.MISSING + + +def _to_dict(obj: Any, *, always: tuple[str, ...] = ()) -> dict[str, Any]: + """Generic ``to_dict`` for the spec dataclasses. + + Walks ``dataclasses.fields(obj)``, skipping any field whose value + matches its declared default (so ``""`` strings, empty dicts/tuples, + ``None`` and ``False`` are omitted unless their name is listed in + ``always``). Field names map to JSON keys via :func:`_key` + (snake_case → camelCase, with overrides for ``ref`` and ``location``). + """ + out: dict[str, Any] = {} + for f in dataclasses.fields(obj): + v = getattr(obj, f.name) + if f.name not in always: + if f.default is not _MISSING and v == f.default: + continue + if f.default_factory is not _MISSING and v == f.default_factory(): + continue + out[_key(f.name)] = _dump(v) + return out + + +# --- Spec dataclasses ----------------------------------------------------- + + @dataclass(frozen=True, slots=True) class Reference: """JSON ``$ref`` to another component.""" @@ -25,12 +87,7 @@ class Reference: description: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"$ref": self.ref} - if self.summary: - d["summary"] = self.summary - if self.description: - d["description"] = self.description - return d + return _to_dict(self, always=("ref",)) @dataclass(frozen=True, slots=True) @@ -40,14 +97,7 @@ class Contact: email: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.name: - d["name"] = self.name - if self.url: - d["url"] = self.url - if self.email: - d["email"] = self.email - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -57,12 +107,7 @@ class License: url: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"name": self.name} - if self.identifier: - d["identifier"] = self.identifier - if self.url: - d["url"] = self.url - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -76,18 +121,7 @@ class Info: license: License | None = None def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"title": self.title, "version": self.version} - if self.summary: - d["summary"] = self.summary - if self.description: - d["description"] = self.description - if self.terms_of_service: - d["termsOfService"] = self.terms_of_service - if self.contact: - d["contact"] = self.contact.to_dict() - if self.license: - d["license"] = self.license.to_dict() - return d + return _to_dict(self, always=("title", "version")) @dataclass(frozen=True, slots=True) @@ -97,12 +131,7 @@ class ServerVariable: description: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"default": self.default} - if self.enum: - d["enum"] = list(self.enum) - if self.description: - d["description"] = self.description - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -112,12 +141,7 @@ class Server: variables: dict[str, ServerVariable] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"url": self.url} - if self.description: - d["description"] = self.description - if self.variables: - d["variables"] = {k: v.to_dict() for k, v in self.variables.items()} - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -126,10 +150,7 @@ class ExternalDocs: description: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"url": self.url} - if self.description: - d["description"] = self.description - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -142,16 +163,7 @@ class Tag: """3.2 addition: name of the parent tag for nested grouping.""" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"name": self.name} - if self.summary: - d["summary"] = self.summary - if self.description: - d["description"] = self.description - if self.external_docs: - d["externalDocs"] = self.external_docs.to_dict() - if self.parent: - d["parent"] = self.parent - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -162,7 +174,7 @@ class TagGroup: tags: tuple[str, ...] def to_dict(self) -> dict[str, Any]: - return {"name": self.name, "tags": list(self.tags)} + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -174,18 +186,7 @@ class Encoding: allow_reserved: bool = False def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.content_type: - d["contentType"] = self.content_type - if self.headers: - d["headers"] = {k: v.to_dict() for k, v in self.headers.items()} - if self.style: - d["style"] = self.style - if self.explode is not None: - d["explode"] = self.explode - if self.allow_reserved: - d["allowReserved"] = True - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -196,16 +197,7 @@ class Example: external_value: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.summary: - d["summary"] = self.summary - if self.description: - d["description"] = self.description - if self.value is not None: - d["value"] = self.value - if self.external_value: - d["externalValue"] = self.external_value - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -216,16 +208,7 @@ class MediaType: encoding: dict[str, Encoding] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.schema: - d["schema"] = self.schema - if self.example is not None: - d["example"] = self.example - if self.examples: - d["examples"] = {k: v.to_dict() for k, v in self.examples.items()} - if self.encoding: - d["encoding"] = {k: v.to_dict() for k, v in self.encoding.items()} - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -237,18 +220,7 @@ class Header: content: dict[str, MediaType] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.description: - d["description"] = self.description - if self.required: - d["required"] = True - if self.deprecated: - d["deprecated"] = True - if self.schema: - d["schema"] = self.schema - if self.content: - d["content"] = {k: v.to_dict() for k, v in self.content.items()} - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -263,20 +235,7 @@ class Parameter: explode: bool | None = None def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"name": self.name, "in": self.location} - if self.required: - d["required"] = True - if self.description: - d["description"] = self.description - if self.deprecated: - d["deprecated"] = True - if self.schema: - d["schema"] = self.schema - if self.style: - d["style"] = self.style - if self.explode is not None: - d["explode"] = self.explode - return d + return _to_dict(self, always=("location",)) @dataclass(frozen=True, slots=True) @@ -286,14 +245,7 @@ class RequestBody: content: dict[str, MediaType] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.description: - d["description"] = self.description - if self.required: - d["required"] = True - if self.content: - d["content"] = {k: v.to_dict() for k, v in self.content.items()} - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -303,12 +255,7 @@ class Response: content: dict[str, MediaType] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"description": self.description} - if self.headers: - d["headers"] = {k: v.to_dict() for k, v in self.headers.items()} - if self.content: - d["content"] = {k: v.to_dict() for k, v in self.content.items()} - return d + return _to_dict(self, always=("description",)) SecurityRequirement = dict[str, tuple[str, ...]] @@ -332,32 +279,7 @@ class Operation: callbacks: dict[str, dict[str, Any]] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.summary: - d["summary"] = self.summary - if self.operation_id: - d["operationId"] = self.operation_id - if self.description: - d["description"] = self.description - if self.tags: - d["tags"] = list(self.tags) - if self.parameters: - d["parameters"] = [p.to_dict() for p in self.parameters] - if self.request_body is not None: - d["requestBody"] = self.request_body.to_dict() - if self.responses: - d["responses"] = {code: r.to_dict() for code, r in self.responses.items()} - if self.callbacks: - d["callbacks"] = dict(self.callbacks) - if self.deprecated: - d["deprecated"] = True - if self.security: - d["security"] = [{k: list(v) for k, v in req.items()} for req in self.security] - if self.servers: - d["servers"] = [s.to_dict() for s in self.servers] - if self.external_docs: - d["externalDocs"] = self.external_docs.to_dict() - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -382,9 +304,9 @@ def to_dict(self) -> dict[str, Any]: for method, op in self.operations.items(): d[method] = op.to_dict() if self.parameters: - d["parameters"] = [p.to_dict() for p in self.parameters] + d["parameters"] = [_dump(p) for p in self.parameters] if self.servers: - d["servers"] = [s.to_dict() for s in self.servers] + d["servers"] = [_dump(s) for s in self.servers] return d @@ -401,16 +323,7 @@ class OAuthFlow: """3.2 addition: device-authorization URL for the device flow.""" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"scopes": dict(self.scopes)} - if self.authorization_url: - d["authorizationUrl"] = self.authorization_url - if self.token_url: - d["tokenUrl"] = self.token_url - if self.refresh_url: - d["refreshUrl"] = self.refresh_url - if self.device_authorization_url: - d["deviceAuthorizationUrl"] = self.device_authorization_url - return d + return _to_dict(self, always=("scopes",)) @dataclass(frozen=True, slots=True) @@ -423,18 +336,7 @@ class OAuthFlows: """3.2 addition.""" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.implicit: - d["implicit"] = self.implicit.to_dict() - if self.password: - d["password"] = self.password.to_dict() - if self.client_credentials: - d["clientCredentials"] = self.client_credentials.to_dict() - if self.authorization_code: - d["authorizationCode"] = self.authorization_code.to_dict() - if self.device_authorization: - d["deviceAuthorization"] = self.device_authorization.to_dict() - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -452,22 +354,7 @@ class SecurityScheme: open_id_connect_url: str = "" def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"type": self.type} - if self.description: - d["description"] = self.description - if self.name: - d["name"] = self.name - if self.location: - d["in"] = self.location - if self.scheme: - d["scheme"] = self.scheme - if self.bearer_format: - d["bearerFormat"] = self.bearer_format - if self.flows: - d["flows"] = self.flows.to_dict() - if self.open_id_connect_url: - d["openIdConnectUrl"] = self.open_id_connect_url - return d + return _to_dict(self) @dataclass(frozen=True, slots=True) @@ -482,24 +369,7 @@ class Components: path_items: dict[str, PathItem] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {} - if self.schemas: - d["schemas"] = dict(self.schemas) - if self.responses: - d["responses"] = {k: v.to_dict() for k, v in self.responses.items()} - if self.parameters: - d["parameters"] = {k: v.to_dict() for k, v in self.parameters.items()} - if self.request_bodies: - d["requestBodies"] = {k: v.to_dict() for k, v in self.request_bodies.items()} - if self.headers: - d["headers"] = {k: v.to_dict() for k, v in self.headers.items()} - if self.security_schemes: - d["securitySchemes"] = {k: v.to_dict() for k, v in self.security_schemes.items()} - if self.examples: - d["examples"] = {k: v.to_dict() for k, v in self.examples.items()} - if self.path_items: - d["pathItems"] = {k: v.to_dict() for k, v in self.path_items.items()} - return d + return _to_dict(self) def is_empty(self) -> bool: return not ( @@ -549,26 +419,7 @@ def with_components(self, components: Components) -> OpenAPI: return replace(self, components=components) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"openapi": self.openapi, "info": self.info.to_dict()} - if self.json_schema_dialect: - d["jsonSchemaDialect"] = self.json_schema_dialect - if self.servers: - d["servers"] = [s.to_dict() for s in self.servers] - if self.paths: - d["paths"] = {p: item.to_dict() for p, item in self.paths.items()} - if self.webhooks: - d["webhooks"] = {k: v.to_dict() for k, v in self.webhooks.items()} - if not self.components.is_empty(): - d["components"] = self.components.to_dict() - if self.security: - d["security"] = [{k: list(v) for k, v in req.items()} for req in self.security] - if self.tags: - d["tags"] = [t.to_dict() for t in self.tags] - if self.tag_groups: - d["tagGroups"] = [g.to_dict() for g in self.tag_groups] - if self.external_docs: - d["externalDocs"] = self.external_docs.to_dict() - return d + return _to_dict(self, always=("openapi", "info")) def to_json(self) -> bytes: return msgspec.json.encode(self.to_dict()) From 7117652172400c044004e562dc508363ea01ff32 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 01:49:39 +0400 Subject: [PATCH 189/286] feat(threadtools)!: per-primitive check_cancelled, drop module alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each cancellable primitive (CancellableLock, cancellable_condition, cancellable_semaphore, Channel.create) now accepts a ``check_cancelled`` callable, defaulting to a module-private no-op. Callers opt in to anyio cancellation by passing ``anyio.from_thread.check_cancelled`` explicitly. The module-level ``check_cancelled = from_thread.check_cancelled`` alias is removed. Channel state threads the callable through into its lock and two conditions, so a single channel has one coherent policy. SendChannel.put / ReceiveChannel.get no longer call check_cancelled at the top of their loops — every blocking path already routes through the cancellable lock acquire or condition wait. Single uncontended fast-path ops won't poll cancellation, which matches stdlib queue semantics. Tests previously monkeypatched the module alias; with no-op as the default, the fixture is no longer needed. --- localpost/threadtools.py | 78 ++++++++++++++++++++++-------- tests/threadtools/channel_props.py | 21 +------- tests/threadtools/channels.py | 45 +++++++---------- 3 files changed, 76 insertions(+), 68 deletions(-) diff --git a/localpost/threadtools.py b/localpost/threadtools.py index d1175fd..7ee1c3f 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -13,7 +13,6 @@ ClosedResourceError, EndOfStream, WouldBlock, - from_thread, ) __all__ = [ @@ -27,8 +26,12 @@ CHECK_TIMEOUT: float = 1.0 """Timeout (seconds) for cancellation checks (e.g. in the server loop).""" -# Alias, so it's possible to override if needed -check_cancelled = from_thread.check_cancelled +def _noop_check() -> None: + """Default cancellation probe — a no-op. + + Pass ``anyio.from_thread.check_cancelled`` (or any custom callable) to a + primitive's ``check_cancelled`` argument to opt in to cancellation. + """ current_time = time.monotonic @@ -49,13 +52,28 @@ class CancellableLock: these names. This is intentional, not a leaky abstraction. """ - __slots__ = ("__exit__", "_acquire_restore", "_is_owned", "_release_save", "locked", "release", "source") - - def __init__(self, lock: threading.Lock | threading.RLock | None = None) -> None: + __slots__ = ( + "__exit__", + "_acquire_restore", + "_check_cancelled", + "_is_owned", + "_release_save", + "locked", + "release", + "source", + ) + + def __init__( + self, + lock: threading.Lock | threading.RLock | None = None, + *, + check_cancelled: Callable[[], None] = _noop_check, + ) -> None: lock = lock or threading.Lock() self.source = lock self.release = lock.release self.__exit__ = lock.__exit__ + self._check_cancelled = check_cancelled if hasattr(self.source, "locked"): self.locked = lock.locked # type: ignore if hasattr(lock, "_release_save"): @@ -71,22 +89,29 @@ def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: if timeout is None or timeout < 0: # No timeout — loop until acquired, checking for cancellation while not self.source.acquire(timeout=CHECK_TIMEOUT): - check_cancelled() + self._check_cancelled() return True # Finite timeout — respect the deadline deadline = current_time() + timeout while (remaining := deadline - current_time()) > 0: if self.source.acquire(timeout=min(CHECK_TIMEOUT, remaining)): return True - check_cancelled() + self._check_cancelled() return False __enter__ = acquire -def cancellable_condition(lock: CancellableLock | None = None) -> threading.Condition: +def cancellable_condition( + lock: CancellableLock | None = None, + *, + check_cancelled: Callable[[], None] = _noop_check, +) -> threading.Condition: # ``Condition`` accepts any duck-typed lock with the right private attrs; # ``CancellableLock`` mirrors them (see its docstring). + # Note: ``check_cancelled`` controls the condition's ``wait`` loop only. + # The lock keeps whatever probe it was constructed with — pass a lock built + # with the same callable if you want a single coherent policy. cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore orig_wait = cond.wait @@ -111,12 +136,14 @@ def cancellable_wait(timeout: float | None = None) -> bool: return cond -def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: +def cancellable_semaphore( + value: int = 1, *, check_cancelled: Callable[[], None] = _noop_check +) -> threading.BoundedSemaphore: # ``Semaphore`` / ``BoundedSemaphore`` use a private ``_cond`` for blocking # waits; swap it for our cancellable variant so the semaphore's blocking # ``acquire`` becomes cancellation-aware. source = threading.BoundedSemaphore(value) - source._cond = cancellable_condition() # type: ignore + source._cond = cancellable_condition(check_cancelled=check_cancelled) # type: ignore return source @@ -128,14 +155,22 @@ def cancellable_semaphore(value: int = 1) -> threading.BoundedSemaphore: @final class Channel[T]: @staticmethod - def create(capacity: int | None = None) -> tuple[SendChannel[T], ReceiveChannel[T]]: + def create( + capacity: int | None = None, + *, + check_cancelled: Callable[[], None] = _noop_check, + ) -> tuple[SendChannel[T], ReceiveChannel[T]]: """Create a channel sender/receiver pair. Args: capacity: Buffer size. None means unbounded, 0 means rendezvous (put blocks until a receiver consumes the item), N>0 means bounded. + check_cancelled: Cancellation probe invoked from blocking lock / + condition waits. Defaults to a no-op; pass + ``anyio.from_thread.check_cancelled`` to make the channel + cancellation-aware from inside an anyio worker thread. """ - with ChannelState(capacity) as state: + with ChannelState(capacity, check_cancelled=check_cancelled) as state: state.open_send_channels += 1 tx = SendChannel(state) state.open_receive_channels += 1 @@ -214,7 +249,9 @@ class ChannelState[T]: not_empty: threading.Condition not_full: threading.Condition - def __init__(self, capacity: int | None = None): + def __init__( + self, capacity: int | None = None, *, check_cancelled: Callable[[], None] = _noop_check + ): if capacity is not None and capacity < 0: raise ValueError("capacity must be >= 0 or None") self.buffer = deque() @@ -235,9 +272,9 @@ def __init__(self, capacity: int | None = None): # holds other in-flight items. self.pending_handoffs = 0 self.items_consumed = 0 - self._lock = CancellableLock(threading.RLock()) - self.not_empty = cancellable_condition(self._lock) - self.not_full = cancellable_condition(self._lock) + self._lock = CancellableLock(threading.RLock(), check_cancelled=check_cancelled) + self.not_empty = cancellable_condition(self._lock, check_cancelled=check_cancelled) + self.not_full = cancellable_condition(self._lock, check_cancelled=check_cancelled) @property def can_put(self) -> bool: @@ -301,8 +338,9 @@ def put(self, item: T, /) -> None: # Phase 1: wait for space in the buffer. # Body of ``put_nowait`` is inlined here to avoid re-entering the lock # and to skip the ``WouldBlock`` exception on the contended path. + # Cancellation is observed inside ``state.__enter__`` (cancellable lock + # acquire) and ``state.not_full.wait`` (cancellable condition). while True: - check_cancelled() with state: if self._closed: raise ClosedResourceError("send channel has been closed") @@ -330,7 +368,6 @@ def put(self, item: T, /) -> None: if state.open_receive_channels == 0: return # No receivers left state.not_full.wait() - check_cancelled() def close(self) -> None: with self._state as state: @@ -357,8 +394,9 @@ def clone(self): @override def get(self) -> T: + # Cancellation is observed inside ``state.__enter__`` (cancellable lock + # acquire) and ``state.not_empty.wait`` (cancellable condition). state = self._state - check_cancelled() while not self._closed: with state: if state.buffer: diff --git a/tests/threadtools/channel_props.py b/tests/threadtools/channel_props.py index 4223227..eba2882 100644 --- a/tests/threadtools/channel_props.py +++ b/tests/threadtools/channel_props.py @@ -32,22 +32,9 @@ rule, ) -from localpost import threadtools from localpost.threadtools import Channel -def _noop_check_cancelled() -> None: - return None - - -@pytest.fixture -def no_anyio(monkeypatch: pytest.MonkeyPatch): - # The channel calls ``threadtools.check_cancelled()`` (an alias of - # ``anyio.from_thread.check_cancelled``); outside an anyio thread context - # that raises. Stub it for the duration of the test. - monkeypatch.setattr(threadtools, "check_cancelled", _noop_check_cancelled) - - class _ChannelMachine(RuleBasedStateMachine): """Drives a ``Channel`` with one capacity, one producer-mode flag. @@ -234,30 +221,24 @@ class _SingleProducerBounded4Machine(_ChannelMachine): single_producer: ClassVar[bool] = True -# Expose the auto-generated unittest TestCases to pytest. ``usefixtures`` -# applies ``no_anyio`` for the lifetime of each Hypothesis test method. +# Expose the auto-generated unittest TestCases to pytest. -@pytest.mark.usefixtures("no_anyio") class TestChannelUnbounded(_UnboundedMachine.TestCase): pass -@pytest.mark.usefixtures("no_anyio") class TestChannelBounded1(_Bounded1Machine.TestCase): pass -@pytest.mark.usefixtures("no_anyio") class TestChannelBounded4(_Bounded4Machine.TestCase): pass -@pytest.mark.usefixtures("no_anyio") class TestChannelSingleProducerUnbounded(_SingleProducerUnboundedMachine.TestCase): pass -@pytest.mark.usefixtures("no_anyio") class TestChannelSingleProducerBounded4(_SingleProducerBounded4Machine.TestCase): pass diff --git a/tests/threadtools/channels.py b/tests/threadtools/channels.py index 0c3822e..5d53790 100644 --- a/tests/threadtools/channels.py +++ b/tests/threadtools/channels.py @@ -4,21 +4,10 @@ import pytest from anyio import ClosedResourceError, EndOfStream, WouldBlock -from localpost import threadtools from localpost.threadtools import Channel, SendChannel -@pytest.fixture -def no_anyio(): - original_check_cancelled = threadtools.check_cancelled - threadtools.check_cancelled = lambda: None - try: - yield - finally: - threadtools.check_cancelled = original_check_cancelled - - -def test_basic_send_receive(no_anyio): +def test_basic_send_receive(): """Test basic send and receive operations.""" sender, receiver = Channel.create() @@ -37,7 +26,7 @@ def test_basic_send_receive(no_anyio): receiver.close() -def test_multiple_senders_single_receiver(no_anyio): +def test_multiple_senders_single_receiver(): """Test multiple senders with a single receiver.""" sender, receiver = Channel.create() results = [] @@ -79,7 +68,7 @@ def send_messages(thread_sender: SendChannel[str], sender_id: int): assert len(sender_msgs) == messages_per_sender -def test_single_sender_multiple_receivers(no_anyio): +def test_single_sender_multiple_receivers(): """Test single sender with multiple receivers.""" sender, receiver = Channel.create() results = {i: [] for i in range(3)} @@ -123,7 +112,7 @@ def receive_messages(recv, receiver_id: int): assert len(receiver_results) > 0 -def test_no_receivers_error(no_anyio): +def test_no_receivers_error(): """Test that sending without receivers raises ClosedResourceError.""" sender, receiver = Channel.create() receiver.close() @@ -134,7 +123,7 @@ def test_no_receivers_error(no_anyio): sender.close() -def test_end_of_channel(no_anyio): +def test_end_of_channel(): """Test that receivers get EndOfStream when all senders close.""" sender1, receiver = Channel.create() sender2 = sender1.clone() @@ -158,7 +147,7 @@ def test_end_of_channel(no_anyio): receiver.close() -def test_closed_channel_errors(no_anyio): +def test_closed_channel_errors(): """Test operations on closed channels raise errors.""" sender, receiver = Channel.create() @@ -173,7 +162,7 @@ def test_closed_channel_errors(no_anyio): receiver.get() -def test_blocking_receive(no_anyio): +def test_blocking_receive(): """Test that receive blocks until item is available.""" sender, receiver = Channel.create() result = [] @@ -205,13 +194,13 @@ def receive(): assert result == ["delayed message"] -def test_negative_capacity_rejected(no_anyio): +def test_negative_capacity_rejected(): """Channel capacities are None, 0, or a positive integer.""" with pytest.raises(ValueError, match="capacity"): Channel.create(capacity=-1) -def test_rendezvous_put_nowait_requires_waiting_receiver(no_anyio): +def test_rendezvous_put_nowait_requires_waiting_receiver(): """A capacity=0 channel has no spare buffer slot for put_nowait.""" sender, receiver = Channel.create(capacity=0) try: @@ -222,7 +211,7 @@ def test_rendezvous_put_nowait_requires_waiting_receiver(no_anyio): receiver.close() -def test_rendezvous_put_nowait_hands_off_to_waiting_receiver(no_anyio): +def test_rendezvous_put_nowait_hands_off_to_waiting_receiver(): sender, receiver = Channel.create(capacity=0) received: list[str] = [] @@ -247,7 +236,7 @@ def receive() -> None: receiver.close() -def test_rendezvous_put_blocks_until_receiver_consumes(no_anyio): +def test_rendezvous_put_blocks_until_receiver_consumes(): sender, receiver = Channel.create(capacity=0) sent = threading.Event() @@ -279,7 +268,7 @@ def _wait_for(predicate, timeout: float = 1.0) -> bool: return predicate() -def test_rendezvous_put_nowait_with_multiple_receivers(no_anyio): +def test_rendezvous_put_nowait_with_multiple_receivers(): """N concurrent put_nowait calls succeed when N receivers are waiting.""" n = 4 sender, receiver = Channel.create(capacity=0) @@ -318,7 +307,7 @@ def receive(r): sender.close() -def test_rendezvous_put_nowait_decrements_after_consume(no_anyio): +def test_rendezvous_put_nowait_decrements_after_consume(): """pending_handoffs returns to 0 after each successful handoff.""" sender, receiver = Channel.create(capacity=0) @@ -342,7 +331,7 @@ def receive_one(r, sink: list[str]) -> None: receiver.close() -def test_rendezvous_concurrent_blocking_puts_pair_with_receivers(no_anyio): +def test_rendezvous_concurrent_blocking_puts_pair_with_receivers(): """N concurrent put() calls all return when N receivers are waiting.""" n = 4 sender, receiver = Channel.create(capacity=0) @@ -402,7 +391,7 @@ def send(s, value: int): pass -def test_rendezvous_put_returns_when_its_own_item_consumed(no_anyio): +def test_rendezvous_put_returns_when_its_own_item_consumed(): """Blocking put waits for *its* item, not just any buffer drain.""" sender, receiver = Channel.create(capacity=0) sender_b = sender.clone() @@ -454,7 +443,7 @@ def send_b(): receiver.close() -def test_concurrent_stress(no_anyio): +def test_concurrent_stress(): """Stress test with many concurrent senders and receivers.""" num_senders = 5 num_receivers = 3 @@ -519,7 +508,7 @@ def receiver_work(r): assert sorted(received) == sorted(expected) -def test_channel_cleanup(no_anyio): +def test_channel_cleanup(): """Test that channels clean up properly when references are dropped.""" for _ in range(10): sender, receiver = Channel.create() From 7e02d49387c33ed228c902e817448f50021b263a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 01:54:02 +0400 Subject: [PATCH 190/286] docs(plans): add attrs/cattrs OpenAPI adapter plan Top-level design for an AttrsAdapter that plugs into the existing TypeAdapter seam (alongside msgspec/pydantic), with a small additive protocol change to let adapters recurse back into the SchemaRegistry for nested non-owned types. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/openapi-attrs.md | 301 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 plans/openapi-attrs.md diff --git a/plans/openapi-attrs.md b/plans/openapi-attrs.md new file mode 100644 index 0000000..c8f690c --- /dev/null +++ b/plans/openapi-attrs.md @@ -0,0 +1,301 @@ +# attrs / cattrs support in `localpost.openapi` + +## Context + +`localpost.openapi` already has a pluggable type-adapter seam +(`localpost/openapi/adapters/__init__.py`): a `TypeAdapter` protocol with +`claims` / `is_body_type` / `schema` / `components` / `decode` / `encode`, +dispatched by an `AdapterRegistry` whose last entry is a catch-all. Today +two adapters ship: `MsgspecAdapter` (catch-all, in `_msgspec.py`) and +`PydanticAdapter` (lazy-imported, in `_pydantic.py`). The README's +adapters docstring even names attrs as a future plug-in example. + +Goal: make `attrs.define`'d classes first-class — bodies decoded, +responses encoded, OpenAPI schemas generated — by adding a third adapter, +without touching anything outside `localpost/openapi/adapters/` +(plus the protocol shim in step 1, the registry wiring, and packaging). + +`attrs` is already installed transitively in the dev env (26.1.0); +`cattrs` is not. + +## Decisions + +1. **One adapter, attrs + cattrs together.** Require both at runtime when + the adapter is active. attrs alone forces us to reinvent + structuring/unstructuring; cattrs alone has no schema generator. + Treat cattrs as the implementation detail of decode/encode and attrs + as the introspection source for schemas. +2. **Position in `default_registry()`: `[pydantic?, attrs?, msgspec]`.** + attrs goes before msgspec because msgspec's `is_body_type` would + otherwise miss attrs classes (no `__dataclass_fields__`). +3. **Default cattrs converter**, overridable. Module-level + `cattrs.preconf.json.make_converter()` for default; allow + `AttrsAdapter(converter=...)` for users with custom hooks. Keep the + constructor tiny — no DI, no decorators. +4. **Schema strategy: hand-rolled walker that re-enters the registry.** + Walk `attrs.fields(cls)` and produce JSON Schema; for nested non-attrs + types, ask the registry. This requires a small additive change to the + `TypeAdapter` protocol (step 1 below). Other options were considered + and rejected: + - Convert attrs → dataclass at registration time and hand to msgspec. + Fragile: validators, converters, `field(default=...)` semantics + diverge from `dataclasses.field`. + - Use `cattrs-extras` or similar for schema. Adds another moving part. +5. **Validators-as-constraints out of scope for the first cut.** Emit + structural schema only (types, requiredness, `$ref`s). attrs + validators don't have a uniform `__metadata__` shape; mapping + `validators.in_` → `enum`, `matches_re` → `pattern`, etc., is a + follow-up. +6. **Keep `attrs`/`cattrs` out of the base `openapi` extra.** Same posture + as pydantic — users opt in. + +## Work breakdown + +### Step 1 — Extend `TypeAdapter` to allow registry recursion (additive) + +Files: `localpost/openapi/adapters/__init__.py`, `_msgspec.py`, +`_pydantic.py`, `localpost/openapi/schemas.py`. + +Why: the attrs adapter's `schema()` needs to recurse into nested types +(another attrs class, a msgspec.Struct, a pydantic model, a primitive) +without knowing which adapter owns each one. The registry already has +that dispatch. + +Change shape (kept additive — no breakage to external `TypeAdapter` +implementers): + +```python +# adapters/__init__.py +SchemaFor = Callable[[Any], dict[str, Any]] # ref or inline + +class TypeAdapter(Protocol): + ... + def schema(self, t: Any, /, *, ref_template: str, + schema_for: SchemaFor | None = None) -> dict[str, Any]: ... + def components(self, types: Sequence[Any], /, *, ref_template: str, + schema_for: SchemaFor | None = None + ) -> dict[str, dict[str, Any]]: ... +``` + +- Existing implementations (`MsgspecAdapter`, `PydanticAdapter`) accept + the new kwarg and ignore it. +- `SchemaRegistry.schema_for` passes itself as the `schema_for` callback + when delegating, so nested types coming back through the registry hit + the right adapter. +- `SchemaRegistry.components()` likewise threads `schema_for` through. + +Tradeoff: the protocol grows by one optional kwarg. Acceptable — the +README already calls these adapters "expected to be stateless and cheap", +and the kwarg is opt-in. + +### Step 2 — `AttrsAdapter` + +New file: `localpost/openapi/adapters/_attrs.py`. + +Skeleton: + +```python +from __future__ import annotations +from collections.abc import Sequence +from typing import Any, get_args, get_origin, Union, Literal +import attrs +import cattrs +from cattrs.errors import BaseValidationError +from cattrs.preconf.json import make_converter + +_JSON = "application/json" +_DEFAULT_CONVERTER = make_converter() + + +class AttrsAdapter: + name = "attrs" + validation_errors: tuple[type[Exception], ...] = (BaseValidationError,) + + def __init__(self, converter: cattrs.Converter | None = None) -> None: + self._converter = converter or _DEFAULT_CONVERTER + + def claims(self, t: Any, /) -> bool: + return isinstance(t, type) and attrs.has(t) + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t, /, *, ref_template, schema_for=None): + if self.claims(t): + return {"$ref": ref_template.format(name=t.__name__)} + # Non-attrs nested type — defer to the registry if we have it, + # otherwise emit empty (permissive) schema. + return schema_for(t) if schema_for else {} + + def components(self, types, /, *, ref_template, schema_for=None): + out: dict[str, dict[str, Any]] = {} + for t in types: + out[t.__name__] = self._build_object_schema( + t, ref_template=ref_template, schema_for=schema_for + ) + return out + + def decode(self, body, t, /, *, content_type): + if not body: + raise ValueError("empty request body") + return self._converter.loads(body, t) + + def encode(self, value, /): + return self._converter.dumps(value).encode("utf-8"), _JSON + + def _build_object_schema(self, t, *, ref_template, schema_for): + properties: dict[str, dict[str, Any]] = {} + required: list[str] = [] + for f in attrs.fields(t): + field_schema = self._field_schema( + f.type, ref_template=ref_template, schema_for=schema_for + ) + properties[f.name] = field_schema + if f.default is attrs.NOTHING: + required.append(f.name) + out: dict[str, Any] = { + "type": "object", + "title": t.__name__, + "properties": properties, + } + if required: + out["required"] = required + return out + + def _field_schema(self, t, *, ref_template, schema_for): + # Resolve string annotations once at registration time; + # attrs.resolve_types(cls) called from _build_object_schema. + ... + # Dispatch: + # - None / type(None) -> {"type": "null"} + # - Union/Optional -> oneOf, dropping None for required check + # - list[T], tuple[T, ...] -> {"type": "array", "items": ...} + # - dict[str, V] -> {"type": "object", "additionalProperties": ...} + # - Literal[...] -> {"enum": [...]} + # - attrs class -> $ref via self.schema(...) + # - everything else -> schema_for(t) if available else {} +``` + +Key points: + +- Call `attrs.resolve_types(t)` once per class in `components()` so + `f.type` is a real type, not a string. Cache the resolved set on the + adapter instance (`set[type]`) to avoid repeated work. +- Validation error mapping: cattrs raises `ClassValidationError` which is + a `BaseValidationError`; using the base catches the whole family. This + flows through the existing `FromBody` catch (`resolvers.py:374`) which + produces a `BadRequest`. +- Nested attrs types referenced by a field: emit `$ref` from + `_field_schema`, then **also** notify the registry so it gets added to + components. The cleanest way is to call `schema_for(nested_attrs_t)` — + the registry both records the type and returns the `$ref`. So + `_field_schema` can route every non-trivial branch through `schema_for` + for a uniform path. + +### Step 3 — Wire into `default_registry()` + +`localpost/openapi/adapters/__init__.py`: + +```python +@functools.cache +def default_registry() -> AdapterRegistry: + from localpost.openapi.adapters._msgspec import MsgspecAdapter + adapters: list[TypeAdapter] = [] + try: + from localpost.openapi.adapters._pydantic import PydanticAdapter + adapters.append(PydanticAdapter()) + except ImportError: + pass + try: + from localpost.openapi.adapters._attrs import AttrsAdapter + adapters.append(AttrsAdapter()) + except ImportError: + pass + adapters.append(MsgspecAdapter()) + return AdapterRegistry(adapters) +``` + +Order matters: pydantic first (most specific — `BaseModel` subclass +check), then attrs (`attrs.has`), then msgspec catch-all. + +### Step 4 — Packaging + +`pyproject.toml`: + +- New optional extra (does NOT touch the base `openapi` extra): + ```toml + openapi-attrs = [ + "attrs >=23", + "cattrs >=24", + ] + ``` +- `dev-openapi` group adds `attrs` and `cattrs` so our own examples and + tests exercise the path: + ```toml + dev-openapi = [ + "pydantic ~=2.7", + "attrs ~=24.2", + "cattrs ~=24.1", + ] + ``` + +### Step 5 — Tests + +- `tests/openapi/schemas.py`: mirror the pydantic block (lines 114-) with + two tests: + - `test_attrs_class_routes_to_attrs` — registry dispatches to + `name == "attrs"`. + - `test_attrs_schema_registered_in_components` — components include + the attrs class with expected `properties` / `required`. + - Bonus: `test_attrs_with_nested_msgspec_struct` — exercise the + `schema_for` recursion path (attrs class containing a + `msgspec.Struct` field, components must contain both). +- `tests/openapi/app.py`: end-to-end `test_attrs_body_is_parsed_and_serialized` + modeled on the pydantic test at line 405. Validation error should + produce a 400. +- All gated with `pytest.importorskip("attrs")` and + `pytest.importorskip("cattrs")`. + +### Step 6 — Example + +Add `examples/openapi/app_attrs.py` (small variant of `app.py`) using +`@attrs.define` for `Book`. Keeps the README quickstart untouched. + +### Step 7 — Docs + +- `localpost/openapi/README.md`: + - Argument-resolvers table (line 102): widen `FromBody` row to + "msgspec.Struct / dataclass / pydantic model / **attrs class**". + - Install section (line 22): note `pip install attrs cattrs` for attrs + handler support, alongside the existing pydantic note. +- `localpost/openapi/adapters/__init__.py` module docstring already names + attrs as the example custom adapter — update the snippet to reference + the now-built-in `AttrsAdapter` and clarify users only need a custom + one for *other* libraries. + +## Risks / open questions + +1. **Forward references in field types.** `attrs.fields(cls).type` may be + a string if the class uses `from __future__ import annotations`. + Mitigation: `attrs.resolve_types(cls)` at the start of + `_build_object_schema`. Wraps cleanly; `attrs` ships this helper. +2. **cattrs version.** Major releases have shuffled `preconf.json` and + exception class names. Pin `cattrs >=24` and verify on 24.1 + latest. +3. **Generic attrs classes (`@attrs.define` with `Generic[T]`).** Punt + for the first cut — emit the un-parameterised schema. Document. +4. **Free-threading.** `_DEFAULT_CONVERTER` is a module-level shared + object; cattrs converters are not documented as thread-safe for + register-on-first-use, but our default has no late registration. + Should be safe; flag for verification. +5. **Schema-walker scope creep.** The walker WILL grow (Annotated, + nested generics, recursive types). Keep it in `_attrs.py` and don't + let it leak into `schemas.py`. If a future adapter (e.g. protobuf) + needs the same primitives, factor then — not now. + +## Out of scope (explicit) + +- attrs without cattrs. +- attrs validators → JSON Schema constraints. +- Custom cattrs hook registration via `@app`-level decorators. +- `attr.s` (legacy API) — only the modern `attrs.define` / `@attrs.frozen` + are tested. Should work via `attrs.has` but not promised. From 3f4d4a5796d1dc4dc08c5261e54ed5c369335fa1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 01:57:09 +0400 Subject: [PATCH 191/286] feat(openapi): plumb schema_for callback through TypeAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional schema_for kwarg to TypeAdapter.schema/components so adapters can recurse back into the registry for types they don't own (e.g. an attrs class with a nested msgspec.Struct field). Existing adapters accept-and-ignore. SchemaRegistry threads its own schema_for through on every dispatch. Additive change to the Protocol — no behavioural shift for existing adapters or callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/adapters/__init__.py | 30 ++++++++++++++++++++++--- localpost/openapi/adapters/_msgspec.py | 15 +++++++++++-- localpost/openapi/adapters/_pydantic.py | 19 ++++++++++++---- localpost/openapi/schemas.py | 4 ++-- tests/openapi/schemas.py | 17 +++++++++++--- 5 files changed, 71 insertions(+), 14 deletions(-) diff --git a/localpost/openapi/adapters/__init__.py b/localpost/openapi/adapters/__init__.py index d76b786..1bc648f 100644 --- a/localpost/openapi/adapters/__init__.py +++ b/localpost/openapi/adapters/__init__.py @@ -20,16 +20,26 @@ from __future__ import annotations import functools -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import Any, Protocol __all__ = [ "AdapterRegistry", + "SchemaFor", "TypeAdapter", "default_registry", ] +SchemaFor = Callable[[Any], dict[str, Any]] +"""Registry callback an adapter can use to delegate nested-type schemas back to the registry. + +Returns a JSON Schema fragment (a ``$ref`` for named types, inline for primitives / unions), +exactly like :meth:`SchemaRegistry.schema_for`. Adapters that don't recurse into foreign types +can ignore the kwarg. +""" + + class TypeAdapter(Protocol): """Library-specific bridge for JSON Schema, decode, and encode. @@ -57,17 +67,31 @@ def is_body_type(self, t: Any, /) -> bool: """ ... - def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: """Return the JSON Schema fragment for ``t``. For named types, return ``{"$ref": ...}`` and let :meth:`components` produce the body. + + ``schema_for`` is the registry's own :meth:`SchemaRegistry.schema_for`, + passed when the adapter may need to recurse into types it doesn't + own (e.g. an attrs class with a nested :class:`msgspec.Struct` + field). Adapters that own a closed type universe can ignore it. """ ... - def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: """Return the ``components.schemas`` entries for every type previously passed to :meth:`schema`. + + ``schema_for`` has the same meaning as in :meth:`schema`. """ ... diff --git a/localpost/openapi/adapters/_msgspec.py b/localpost/openapi/adapters/_msgspec.py index 5691862..dd84107 100644 --- a/localpost/openapi/adapters/_msgspec.py +++ b/localpost/openapi/adapters/_msgspec.py @@ -14,6 +14,8 @@ import msgspec +from localpost.openapi.adapters import SchemaFor + __all__ = ["MsgspecAdapter"] @@ -40,7 +42,8 @@ def is_body_type(self, t: Any, /) -> bool: # NamedTuple: subclass of tuple with class-level field metadata. return hasattr(t, "__annotations__") and hasattr(t, "_fields") - def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for # msgspec owns its full type universe; no recursion needed. try: (schema,), _ = msgspec.json.schema_components([t], ref_template=ref_template) except (TypeError, RuntimeError): @@ -48,7 +51,15 @@ def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: return {} return schema - def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + del schema_for if not types: return {} _, components = msgspec.json.schema_components(list(types), ref_template=ref_template) diff --git a/localpost/openapi/adapters/_pydantic.py b/localpost/openapi/adapters/_pydantic.py index 844c843..520277f 100644 --- a/localpost/openapi/adapters/_pydantic.py +++ b/localpost/openapi/adapters/_pydantic.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, ValidationError +from localpost.openapi.adapters import SchemaFor + __all__ = ["PydanticAdapter"] @@ -25,12 +27,21 @@ def claims(self, t: Any, /) -> bool: def is_body_type(self, t: Any, /) -> bool: return self.claims(t) - def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: - # Body emitted in components(); inline ref keeps the schema fragment - # consistent with msgspec's $ref-for-named-types behaviour. + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for # Pydantic models are self-contained; no recursion needed. + # Body emitted in components(); inline ref keeps the schema fragment consistent with + # msgspec's $ref-for-named-types behaviour. return {"$ref": ref_template.format(name=t.__name__)} - def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + del schema_for # Pydantic's placeholder is ``{model}``, not ``{name}``. pydantic_template = ref_template.replace("{name}", "{model}") out: dict[str, dict[str, Any]] = {} diff --git a/localpost/openapi/schemas.py b/localpost/openapi/schemas.py index 1d717e6..36e5562 100644 --- a/localpost/openapi/schemas.py +++ b/localpost/openapi/schemas.py @@ -65,7 +65,7 @@ def schema_for(self, t: Any) -> dict[str, Any]: bucket = self._types_by_adapter.setdefault(adapter, []) if t not in bucket: bucket.append(t) - return adapter.schema(t, ref_template=REF_TEMPLATE) + return adapter.schema(t, ref_template=REF_TEMPLATE, schema_for=self.schema_for) def components(self) -> dict[str, dict[str, Any]]: """Return the resolved ``components.schemas`` dict for every type @@ -78,6 +78,6 @@ def components(self) -> dict[str, dict[str, Any]]: return self._components schemas: dict[str, dict[str, Any]] = {} for adapter, types in self._types_by_adapter.items(): - schemas.update(adapter.components(types, ref_template=REF_TEMPLATE)) + schemas.update(adapter.components(types, ref_template=REF_TEMPLATE, schema_for=self.schema_for)) self._components = schemas return schemas diff --git a/tests/openapi/schemas.py b/tests/openapi/schemas.py index 1ea033d..ac99ed8 100644 --- a/tests/openapi/schemas.py +++ b/tests/openapi/schemas.py @@ -10,7 +10,7 @@ import msgspec import pytest -from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.adapters import AdapterRegistry, SchemaFor, default_registry from localpost.openapi.adapters._msgspec import MsgspecAdapter from localpost.openapi.schemas import REF_TEMPLATE, SchemaRegistry @@ -156,11 +156,22 @@ def claims(self, t: Any, /) -> bool: def is_body_type(self, t: Any, /) -> bool: return self.claims(t) - def schema(self, t: Any, /, *, ref_template: str) -> dict[str, Any]: + def schema( + self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None + ) -> dict[str, Any]: + del schema_for self.schema_calls.append(t) return {"$ref": ref_template.format(name="Marker")} - def components(self, types: Sequence[Any], /, *, ref_template: str) -> dict[str, dict[str, Any]]: + def components( + self, + types: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + del schema_for self.components_calls.append(types) return {"Marker": {"type": "object", "x-fake": True}} From 5033bc800510030830317cf209fe6ed21bd4bb9c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 02:10:15 +0400 Subject: [PATCH 192/286] feat(openapi): attrs/cattrs TypeAdapter Adds an AttrsAdapter so handlers can take @attrs.define'd classes as request bodies and return them as responses, with the OpenAPI doc generated from attrs.fields(). Decode/encode go through a cattrs Converter; users can pass a custom one for hooks. Schema generation is a small walker that decomposes container/union field types and defers leaf types (msgspec.Struct, pydantic model, plain primitives) back to the registry via the new schema_for callback. Nested types are auto-registered as a side-effect, so cross-library nesting (attrs containing a msgspec.Struct, etc.) lands correctly in components.schemas. SchemaRegistry.components() now drains in passes so adapters that register more types during their own components() call (via schema_for) get a second pass. The registry lock is RLock for the same reason. Adapter is lazy-imported and skipped when attrs/cattrs aren't installed; new openapi-attrs extra opts in. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/openapi/README.md | 15 ++- localpost/openapi/adapters/__init__.py | 23 ++-- localpost/openapi/adapters/_attrs.py | 171 +++++++++++++++++++++++++ localpost/openapi/schemas.py | 25 +++- pyproject.toml | 10 ++ tests/openapi/app.py | 49 +++++++ tests/openapi/schemas.py | 67 +++++++++- uv.lock | 29 ++++- 8 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 localpost/openapi/adapters/_attrs.py diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index 5f4b675..bdb3429 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -15,8 +15,9 @@ Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of via an `update_doc` hook. There's no second place to declare auth. - **msgspec-first.** [msgspec](https://jcristharif.com/msgspec/) does request body decoding, response encoding, and JSON Schema generation. Pydantic - models are recognised automatically — but pydantic is **not** a runtime - dependency; install it yourself if you want to use it. + models *and* `attrs.define`'d classes are recognised automatically — but + neither is a runtime dependency of the `openapi` extra; install them + yourself if you want to use them. ## Install @@ -30,6 +31,14 @@ For Pydantic models in handlers: pip install pydantic ``` +For `attrs` classes in handlers (uses `cattrs` for structuring): + +```bash +pip install 'localpost[openapi-attrs]' +# or, equivalently: +pip install attrs cattrs +``` + ## Quick start ```python @@ -99,7 +108,7 @@ One per parameter. Picked from the annotation; explicit factories override: | Path variable | `FromPath()` | param name matches `{name}` in template | | Query string | `FromQuery()` | scalar parameter not in path, no body type | | Header | `FromHeader("X-…")` | only via explicit `Annotated[...]` | -| Request body | `FromBody()` | param annotated as `msgspec.Struct` / dataclass / pydantic model | +| Request body | `FromBody()` | param annotated as `msgspec.Struct` / dataclass / pydantic model / `attrs` class | | Request ctx | (none) | param annotated as `HTTPReqCtx` | Each resolver may short-circuit by returning an `OpResult` (e.g. validation diff --git a/localpost/openapi/adapters/__init__.py b/localpost/openapi/adapters/__init__.py index 1bc648f..ca5f7da 100644 --- a/localpost/openapi/adapters/__init__.py +++ b/localpost/openapi/adapters/__init__.py @@ -1,19 +1,19 @@ """Pluggable type adapters for JSON Schema, request decode, and response encode. A :class:`TypeAdapter` owns a family of types — msgspec for the catch-all, -pydantic for :class:`pydantic.BaseModel` subclasses, and so on. The -:class:`AdapterRegistry` dispatches by type, with the catch-all (msgspec) -always last. +pydantic for :class:`pydantic.BaseModel` subclasses, attrs for +:func:`attrs.define`'d classes, and so on. The :class:`AdapterRegistry` +dispatches by type, with the catch-all (msgspec) always last. The default registry is auto-built from what's installed: msgspec is a -hard runtime dependency; pydantic is detected at import time. To plug in -a custom adapter (e.g. attrs, protobuf), pass an explicit registry to -:class:`localpost.openapi.HttpApp`:: +hard runtime dependency; pydantic and attrs/cattrs are detected at import +time. To plug in a custom adapter (e.g. protobuf), pass an explicit +registry to :class:`localpost.openapi.HttpApp`:: from localpost.openapi import HttpApp from localpost.openapi.adapters import AdapterRegistry, default_registry - registry = AdapterRegistry([MyAttrsAdapter(), *default_registry().adapters]) + registry = AdapterRegistry([MyProtobufAdapter(), *default_registry().adapters]) app = HttpApp(adapters=registry) """ @@ -159,7 +159,8 @@ def default_registry() -> AdapterRegistry: """Return a process-wide :class:`AdapterRegistry` autoconfigured from installed libraries. - Order: pydantic (if importable), then msgspec as the catch-all. Cached. + Order: pydantic (if importable), attrs (if both ``attrs`` and ``cattrs`` are importable), + then msgspec as the catch-all. Cached. """ from localpost.openapi.adapters._msgspec import MsgspecAdapter # noqa: PLC0415 @@ -170,5 +171,11 @@ def default_registry() -> AdapterRegistry: adapters.append(PydanticAdapter()) except ImportError: pass + try: + from localpost.openapi.adapters._attrs import AttrsAdapter # noqa: PLC0415 + + adapters.append(AttrsAdapter()) + except ImportError: + pass adapters.append(MsgspecAdapter()) return AdapterRegistry(adapters) diff --git a/localpost/openapi/adapters/_attrs.py b/localpost/openapi/adapters/_attrs.py new file mode 100644 index 0000000..09c80ff --- /dev/null +++ b/localpost/openapi/adapters/_attrs.py @@ -0,0 +1,171 @@ +"""attrs/cattrs :class:`TypeAdapter`. Imported lazily by ``default_registry``; +import succeeds only when both :mod:`attrs` and :mod:`cattrs` are installed. + +Schema generation walks ``attrs.fields(cls)`` directly. Container and union +field types are decomposed by the walker; leaf types fall back through +the supplied ``schema_for`` callback so foreign nested types (msgspec +Structs, pydantic models, plain primitives) hit the right adapter via +:class:`SchemaRegistry`. + +Decode/encode is delegated to a ``cattrs`` JSON converter (default: +:func:`cattrs.preconf.json.make_converter`). Pass a custom converter to +``AttrsAdapter(converter=...)`` to register your own structure hooks. +""" + +from __future__ import annotations + +import json +import types +import typing +from collections.abc import Mapping, Sequence +from typing import Any + +import attrs +import cattrs +from cattrs.errors import BaseValidationError + +from localpost.openapi.adapters import SchemaFor + +__all__ = ["AttrsAdapter"] + + +_JSON_CONTENT_TYPE = "application/json" + +_PRIMITIVE_SCHEMAS: dict[type, dict[str, Any]] = { + str: {"type": "string"}, + bool: {"type": "boolean"}, + int: {"type": "integer"}, + float: {"type": "number"}, + bytes: {"type": "string", "format": "binary"}, +} + + +class AttrsAdapter: + name = "attrs" + validation_errors: tuple[type[Exception], ...] = (BaseValidationError,) + + def __init__(self, converter: cattrs.Converter | None = None) -> None: + # We deliberately use a plain ``Converter`` rather than the JSON preconf so users can + # plug in their own with custom structure hooks. The (un)structure step handles the + # type system; ``json`` handles the wire format. + self._converter = converter or cattrs.Converter() + # Classes whose forward-ref annotations have already been resolved. ``attrs.resolve_types`` + # mutates the class in place, so once is enough. + self._resolved: set[type] = set() + + def claims(self, t: Any, /) -> bool: + return isinstance(t, type) and attrs.has(t) + + def is_body_type(self, t: Any, /) -> bool: + return self.claims(t) + + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: + del schema_for # The class body itself is rendered in components(); we only emit a ref here. + return {"$ref": ref_template.format(name=t.__name__)} + + def components( + self, + types_: Sequence[Any], + /, + *, + ref_template: str, + schema_for: SchemaFor | None = None, + ) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for t in types_: + self._resolve(t) + out[t.__name__] = self._object_schema(t, ref_template=ref_template, schema_for=schema_for) + return out + + def decode(self, body: bytes, t: Any, /, *, content_type: str) -> object: + del content_type + if not body: + raise ValueError("empty request body") + return self._converter.structure(json.loads(body), t) + + def encode(self, value: object, /) -> tuple[bytes, str]: + return json.dumps(self._converter.unstructure(value)).encode("utf-8"), _JSON_CONTENT_TYPE + + # --- internals ------------------------------------------------------ + + def _resolve(self, t: type) -> None: + if t in self._resolved: + return + # ``attrs.resolve_types`` walks ``__annotations__`` and replaces string forward refs + # with real types. Idempotent and safe to call multiple times, but we cache to avoid + # re-entering ``typing.get_type_hints`` on every spec rebuild. + attrs.resolve_types(t) + self._resolved.add(t) + + def _object_schema(self, t: type, *, ref_template: str, schema_for: SchemaFor | None) -> dict[str, Any]: + properties: dict[str, dict[str, Any]] = {} + required: list[str] = [] + for f in attrs.fields(t): + field_schema = self._field_schema(f.type, ref_template=ref_template, schema_for=schema_for) + properties[f.name] = field_schema + if f.default is attrs.NOTHING: + required.append(f.name) + out: dict[str, Any] = { + "type": "object", + "title": t.__name__, + "properties": properties, + } + if required: + out["required"] = required + return out + + def _field_schema(self, t: Any, *, ref_template: str, schema_for: SchemaFor | None) -> dict[str, Any]: + if t is None or t is type(None): + return {"type": "null"} + if t in _PRIMITIVE_SCHEMAS: + return dict(_PRIMITIVE_SCHEMAS[t]) + + origin = typing.get_origin(t) + if origin is typing.Union or origin is types.UnionType: + args = typing.get_args(t) + return {"oneOf": [self._field_schema(a, ref_template=ref_template, schema_for=schema_for) for a in args]} + if origin is typing.Literal: + return {"enum": list(typing.get_args(t))} + if origin in (list, set, frozenset) or ( + isinstance(origin, type) and issubclass(origin, Sequence) and origin is not str + ): + (item_t,) = typing.get_args(t) or (Any,) + return { + "type": "array", + "items": self._field_schema(item_t, ref_template=ref_template, schema_for=schema_for), + } + if origin is tuple: + args = typing.get_args(t) + # ``tuple[T, ...]`` -> homogeneous array; fixed-length tuples fall through to a + # heterogeneous prefix-items array. + if len(args) == 2 and args[1] is Ellipsis: + return { + "type": "array", + "items": self._field_schema(args[0], ref_template=ref_template, schema_for=schema_for), + } + return { + "type": "array", + "prefixItems": [self._field_schema(a, ref_template=ref_template, schema_for=schema_for) for a in args], + "minItems": len(args), + "maxItems": len(args), + } + if origin is dict or (isinstance(origin, type) and issubclass(origin, Mapping)): + args = typing.get_args(t) + value_t = args[1] if len(args) == 2 else Any + return { + "type": "object", + "additionalProperties": self._field_schema(value_t, ref_template=ref_template, schema_for=schema_for), + } + + # Nested attrs class — emit ref + register via the registry so its body lands in + # components.schemas. + if isinstance(t, type) and attrs.has(t): + if schema_for is not None: + return schema_for(t) + return {"$ref": ref_template.format(name=t.__name__)} + + # Anything else (foreign types: msgspec.Struct, dataclass, pydantic model, datetime, + # UUID, …): defer to the registry. Without a callback, fall back to a permissive schema. + if schema_for is not None: + return schema_for(t) + return {} diff --git a/localpost/openapi/schemas.py b/localpost/openapi/schemas.py index 36e5562..4621fad 100644 --- a/localpost/openapi/schemas.py +++ b/localpost/openapi/schemas.py @@ -37,7 +37,9 @@ class SchemaRegistry: __slots__ = ("_adapters", "_components", "_lock", "_types_by_adapter") def __init__(self, adapters: AdapterRegistry | None = None) -> None: - self._lock = threading.Lock() + # Re-entrant: adapters that recurse via the ``schema_for`` callback (attrs -> + # msgspec for foreign nested types) take the same lock from the same thread. + self._lock = threading.RLock() self._adapters = adapters or default_registry() # We bucket types by the adapter that claims them. Insertion order # is preserved (CPython dict semantics), so generated component @@ -77,7 +79,24 @@ def components(self) -> dict[str, dict[str, Any]]: if self._components is not None: return self._components schemas: dict[str, dict[str, Any]] = {} - for adapter, types in self._types_by_adapter.items(): - schemas.update(adapter.components(types, ref_template=REF_TEMPLATE, schema_for=self.schema_for)) + # Drain in passes: an adapter's ``components()`` may register more types via + # ``schema_for`` (e.g. attrs adapter delegating a nested msgspec.Struct field + # back to the registry). Track per-adapter how many items have been processed + # and loop until no adapter has unprocessed work. + processed: dict[TypeAdapter, int] = {} + while True: + made_progress = False + # Snapshot keys: more adapters may appear during iteration. + for adapter in list(self._types_by_adapter.keys()): + bucket = self._types_by_adapter[adapter] + start = processed.get(adapter, 0) + if start >= len(bucket): + continue + pending = bucket[start:] + processed[adapter] = len(bucket) + schemas.update(adapter.components(pending, ref_template=REF_TEMPLATE, schema_for=self.schema_for)) + made_progress = True + if not made_progress: + break self._components = schemas return schemas diff --git a/pyproject.toml b/pyproject.toml index 7a1d4c6..cc12260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,13 @@ openapi = [ # "localpost[http]", "msgspec ~=0.19", ] +openapi-attrs = [ + # attrs handlers + cattrs for structuring/unstructuring. Schema generation walks + # ``attrs.fields(cls)`` directly. Both libraries are user-installed; not a runtime + # dep of the base ``openapi`` extra. + "attrs >=23", + "cattrs >=24", +] [dependency-groups] dev = [ @@ -95,6 +102,9 @@ dev-openapi = [ # dep of the openapi extra — end users install pydantic themselves. We pull # it in here so our own examples and tests can exercise that path. "pydantic ~=2.7", + # Same posture for attrs/cattrs (see the ``openapi-attrs`` extra). + "attrs >=23", + "cattrs >=24", ] dev-sentry = [ "sentry-sdk ~=2.51", diff --git a/tests/openapi/app.py b/tests/openapi/app.py index 9536259..7413605 100644 --- a/tests/openapi/app.py +++ b/tests/openapi/app.py @@ -102,6 +102,20 @@ class Pet(BaseModel): Pet = None # type: ignore[assignment,misc] +# attrs Toy lives at module scope for the same reason: forward-ref resolution +# in ``attrs.resolve_types`` walks ``__annotations__`` against module globals. +try: + import attrs + + @attrs.define + class Toy: + name: str + weight: int + +except ImportError: + Toy = None # type: ignore[assignment,misc] + + # --- Tests --------------------------------------------------------------- @@ -416,3 +430,38 @@ def create(pet: Pet) -> Pet: assert status == 200, body assert msgspec.json.decode(body) == {"name": "Rex", "age": 3} assert headers["content-type"] == "application/json" + + +class TestAttrs: + def test_attrs_body_is_parsed_and_serialized(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + app = HttpApp() + + @app.post("/toys") + def create(toy: Toy) -> Toy: + return toy + + op = app.operations[0] + ctx = make_ctx(method="POST", path="/toys", body=b'{"name":"Bone","weight":2}') + status, body, headers = run_op(op, ctx) + assert status == 200, body + assert msgspec.json.decode(body) == {"name": "Bone", "weight": 2} + assert headers["content-type"] == "application/json" + + def test_attrs_invalid_body_is_400(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + app = HttpApp() + + @app.post("/toys") + def create(toy: Toy) -> Toy: + return toy + + op = app.operations[0] + # Missing required ``weight`` field — cattrs raises ClassValidationError. + ctx = make_ctx(method="POST", path="/toys", body=b'{"name":"Bone"}') + status, _body, _headers = run_op(op, ctx) + assert status == 400 diff --git a/tests/openapi/schemas.py b/tests/openapi/schemas.py index ac99ed8..a40973a 100644 --- a/tests/openapi/schemas.py +++ b/tests/openapi/schemas.py @@ -31,6 +31,31 @@ class Status(StrEnum): CLOSED = "closed" +# attrs sample types live at module scope so ``attrs.resolve_types`` (which goes through +# ``typing.get_type_hints`` against the class's module globals) can resolve forward refs +# under ``from __future__ import annotations``. +try: + import attrs as _attrs + + @_attrs.define + class _AttrsPet: + name: str + age: int = 0 + + class _MsgspecTrinket(msgspec.Struct): + sku: str + + @_attrs.define + class _AttrsPetWithTrinket: + name: str + toy: _MsgspecTrinket + +except ImportError: + _AttrsPet = None # type: ignore[assignment,misc] + _MsgspecTrinket = None # type: ignore[assignment,misc] + _AttrsPetWithTrinket = None # type: ignore[assignment,misc] + + class TestPrimitives: def test_int(self): registry = SchemaRegistry() @@ -135,6 +160,44 @@ class Pet(BaseModel): assert "Pet" in components assert "name" in components["Pet"]["properties"] + def test_attrs_class_routes_to_attrs(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + registry = default_registry() + assert registry.for_type(_AttrsPet).name == "attrs" + + def test_attrs_schema_registered_in_components(self): + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + registry = SchemaRegistry() + schema = registry.schema_for(_AttrsPet) + assert schema == {"$ref": REF_TEMPLATE.format(name="_AttrsPet")} + + components = registry.components() + assert "_AttrsPet" in components + body = components["_AttrsPet"] + assert body["type"] == "object" + assert body["properties"]["name"] == {"type": "string"} + assert body["properties"]["age"] == {"type": "integer"} + # ``age`` has a default; only ``name`` is required. + assert body["required"] == ["name"] + + def test_attrs_with_nested_msgspec_struct_recurses_via_registry(self): + """attrs adapter must defer foreign nested types to the registry's schema_for callback.""" + pytest.importorskip("attrs") + pytest.importorskip("cattrs") + + registry = SchemaRegistry() + registry.schema_for(_AttrsPetWithTrinket) + components = registry.components() + assert "_AttrsPetWithTrinket" in components + # _MsgspecTrinket must have been registered as a side-effect of the schema_for callback. + assert "_MsgspecTrinket" in components + assert components["_AttrsPetWithTrinket"]["properties"]["toy"] == { + "$ref": REF_TEMPLATE.format(name="_MsgspecTrinket") + } + class TestCustomAdapter: """Plug a fake adapter ahead of msgspec to confirm dispatch is extensible.""" @@ -156,9 +219,7 @@ def claims(self, t: Any, /) -> bool: def is_body_type(self, t: Any, /) -> bool: return self.claims(t) - def schema( - self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None - ) -> dict[str, Any]: + def schema(self, t: Any, /, *, ref_template: str, schema_for: SchemaFor | None = None) -> dict[str, Any]: del schema_for self.schema_calls.append(t) return {"$ref": ref_template.format(name="Marker")} diff --git a/uv.lock b/uv.lock index 77d0bf8..7c2174c 100644 --- a/uv.lock +++ b/uv.lock @@ -112,6 +112,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, ] +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -748,6 +761,10 @@ http-fast = [ openapi = [ { name = "msgspec" }, ] +openapi-attrs = [ + { name = "attrs" }, + { name = "cattrs" }, +] scheduler = [ { name = "humanize" }, { name = "pytimeparse2" }, @@ -779,6 +796,8 @@ dev-http = [ { name = "httpx" }, ] dev-openapi = [ + { name = "attrs" }, + { name = "cattrs" }, { name = "pydantic" }, ] dev-otel = [ @@ -810,7 +829,9 @@ tests-unit = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = "~=4.12" }, + { name = "attrs", marker = "extra == 'openapi-attrs'", specifier = ">=23" }, { name = "brotli", marker = "extra == 'http-compress'", specifier = "~=1.1" }, + { name = "cattrs", marker = "extra == 'openapi-attrs'", specifier = ">=24" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, @@ -818,7 +839,7 @@ requires-dist = [ { name = "msgspec", marker = "extra == 'openapi'", specifier = "~=0.19" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "openapi"] +provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "openapi", "openapi-attrs"] [package.metadata.requires-dev] bench = [ @@ -845,7 +866,11 @@ dev-http = [ { name = "flask", specifier = "~=3.1" }, { name = "httpx" }, ] -dev-openapi = [{ name = "pydantic", specifier = "~=2.7" }] +dev-openapi = [ + { name = "attrs", specifier = ">=23" }, + { name = "cattrs", specifier = ">=24" }, + { name = "pydantic", specifier = "~=2.7" }, +] dev-otel = [{ name = "opentelemetry-exporter-otlp" }] dev-sentry = [{ name = "sentry-sdk", specifier = "~=2.51" }] dev-types = [ From 7dbfdc739d092aee826ea8d843d33c71770553f9 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 04:48:12 +0000 Subject: [PATCH 193/286] format --- localpost/threadtools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/localpost/threadtools.py b/localpost/threadtools.py index 7ee1c3f..9743896 100644 --- a/localpost/threadtools.py +++ b/localpost/threadtools.py @@ -26,6 +26,7 @@ CHECK_TIMEOUT: float = 1.0 """Timeout (seconds) for cancellation checks (e.g. in the server loop).""" + def _noop_check() -> None: """Default cancellation probe — a no-op. @@ -33,6 +34,7 @@ def _noop_check() -> None: primitive's ``check_cancelled`` argument to opt in to cancellation. """ + current_time = time.monotonic @@ -249,9 +251,7 @@ class ChannelState[T]: not_empty: threading.Condition not_full: threading.Condition - def __init__( - self, capacity: int | None = None, *, check_cancelled: Callable[[], None] = _noop_check - ): + def __init__(self, capacity: int | None = None, *, check_cancelled: Callable[[], None] = _noop_check): if capacity is not None and capacity < 0: raise ValueError("capacity must be >= 0 or None") self.buffer = deque() From dbf3b960f4e84d1809ca6fc6e267ef3b0cb21a1b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 09:02:16 +0400 Subject: [PATCH 194/286] refactor(benchmarks): extract shared core for macro suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist the runner, types, filter language, CLI, and report writers out of benchmarks/http/ into benchmarks/_core/ so a second macro suite can sit alongside without duplication. Stack/Cell switch from hardcoded http fields (app/backend/selectors/pool/acceptor/tags) to a generic dims dict plus tags; the HTML reporter now generates filter dropdowns and result columns from report.dim_keys. http suite is unchanged from a user's perspective — same CLI flags, same markdown layout, same selection semantics. results.json now also carries title and dim_keys for the openapi suite to reuse the same writers. Plan: plans/openapi-benchmarks.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/_core/__init__.py | 6 + benchmarks/_core/cli.py | 260 +++++++++++ benchmarks/_core/filters.py | 121 +++++ .../{http/_pythons.py => _core/pythons.py} | 6 +- .../{http/_render_html.py => _core/render.py} | 182 ++++---- benchmarks/_core/runner.py | 158 +++++++ benchmarks/_core/types.py | 87 ++++ benchmarks/http/_setup.py | 12 +- benchmarks/http/runner.py | 413 +----------------- benchmarks/http/scenarios.py | 29 +- benchmarks/http/stacks.py | 232 +++------- plans/openapi-benchmarks.md | 133 ++++++ 12 files changed, 965 insertions(+), 674 deletions(-) create mode 100644 benchmarks/_core/__init__.py create mode 100644 benchmarks/_core/cli.py create mode 100644 benchmarks/_core/filters.py rename benchmarks/{http/_pythons.py => _core/pythons.py} (73%) rename benchmarks/{http/_render_html.py => _core/render.py} (52%) create mode 100644 benchmarks/_core/runner.py create mode 100644 benchmarks/_core/types.py create mode 100644 plans/openapi-benchmarks.md diff --git a/benchmarks/_core/__init__.py b/benchmarks/_core/__init__.py new file mode 100644 index 0000000..f4d44ce --- /dev/null +++ b/benchmarks/_core/__init__.py @@ -0,0 +1,6 @@ +"""Shared infrastructure for the macro benchmark suites. + +Each suite (``benchmarks/http/``, ``benchmarks/openapi/``) defines its own +scenarios, stacks, and ``apps/`` modules but reuses this package for the +runner, types, CLI, and report rendering. +""" diff --git a/benchmarks/_core/cli.py b/benchmarks/_core/cli.py new file mode 100644 index 0000000..a9f1908 --- /dev/null +++ b/benchmarks/_core/cli.py @@ -0,0 +1,260 @@ +"""Shared argparse + main loop for macro bench runners. + +Each suite's ``runner.py`` is a thin entry point that calls :func:`run` +with its own scenarios, stacks, groups, and apps package. + +Run from the repo root:: + + just bench-http # full matrix + just bench-http --duration 5 # quick sanity + just bench-http --group quick # ~4 stacks, fast PR check + just bench-http --filter app=flask # all Flask servers + just bench-http --filter 'backend=lp-*' --filter selectors=1 + just bench-http --stacks localpost_h11,flask_gunicorn # exact list (escape hatch) + just bench-http --pythons 3.13=.venv-bench/3.13/bin/python +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import subprocess +import sys +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path + +from benchmarks._core.filters import select_stacks +from benchmarks._core.pythons import PYTHONS +from benchmarks._core.render import render_html, render_markdown +from benchmarks._core.runner import pick_port, probe_python, run_cell +from benchmarks._core.types import Cell, RunReport, Scenario, Stack + + +@dataclass(frozen=True, slots=True) +class PythonInterp: + name: str + bin: str + + +def _write_results(report: RunReport, target_dir: Path) -> None: + target_dir.mkdir(parents=True, exist_ok=True) + payload = asdict(report) + (target_dir / "results.json").write_text(json.dumps(payload, indent=2) + "\n") + (target_dir / "RESULTS.md").write_text(render_markdown(report)) + (target_dir / "RESULTS.html").write_text(render_html(payload)) + + +def _parse_pythons(arg: str) -> list[PythonInterp]: + """Parse ``name=path,name=path`` (or fall back to the bench matrix).""" + if not arg: + return [PythonInterp(name=p.name, bin=p.bin) for p in PYTHONS] + pythons: list[PythonInterp] = [] + for spec in arg.split(","): + spec = spec.strip() + if not spec: + continue + if "=" not in spec: + raise ValueError(f"--pythons entry must be name=path, got: {spec!r}") + name, _, path = spec.partition("=") + pythons.append(PythonInterp(name=name.strip(), bin=path.strip())) + return pythons + + +def _timestamp_slug() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S") + + +def _describe_selection(args: argparse.Namespace) -> str: + parts: list[str] = [] + if args.stacks: + parts.append(f"stacks={args.stacks}") + if args.group: + parts.append(f"group={args.group}") + if args.filter: + parts.append("filter=" + ", ".join(args.filter)) + return "; ".join(parts) if parts else "all" + + +def run( + *, + scenarios: tuple[Scenario, ...], + stacks: tuple[Stack, ...], + groups: dict[str, Callable[[Stack], bool]], + apps_pkg: str, + results_dir: Path, + title: str, + dim_keys: Iterable[str], + repo_root: Path, + argv: list[str] | None = None, + description: str | None = None, +) -> int: + """Drive a full matrix run for one suite. + + Args: + scenarios: Suite's scenario list. + stacks: Suite's stack list. + groups: Named stack-selection presets. + apps_pkg: Python package prefix used to spawn each stack as + ``python -m .``. + results_dir: Where to write per-run output directories. + title: Used in markdown / HTML headers. + dim_keys: Ordered dim names — drives HTML column order and filters. + Cells dynamically carry their own dim values; this controls + display ordering only. + repo_root: Working directory for spawned subprocesses. + argv: Override sys.argv[1:] (for testing). + description: argparse description. + """ + p = argparse.ArgumentParser(description=description, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--duration", type=int, default=20, help="seconds per cell (default: 20)") + p.add_argument( + "--stacks", + default="", + help="comma-separated stack name(s) — verbatim escape hatch (bypasses --group/--filter)", + ) + p.add_argument( + "--group", + default="", + help=f"named preset; one of: {', '.join(sorted(groups))}" if groups else "(no groups defined)", + ) + p.add_argument( + "--filter", + action="append", + default=[], + metavar="KEY=VAL", + help="dimension filter, e.g. 'app=flask', 'backend=lp-*', 'selectors=1', 'app!=starlette'. " + "Repeatable (filters AND together). Comma-separated values inside one key are OR.", + ) + p.add_argument("--scenarios", default="", help="comma-separated scenario filter (default: all)") + p.add_argument( + "--pythons", + default="", + help="comma-separated name=path pairs to override the bench matrix, e.g. " + "'3.13=.venv-bench/3.13/bin/python' (default: every entry in benchmarks._core.pythons.PYTHONS)", + ) + p.add_argument("--port-base", type=int, default=18800) + args = p.parse_args(argv) + + if shutil.which("oha") is None: + print("error: 'oha' not found on PATH. Install via 'brew install oha'.", file=sys.stderr) + return 2 + + try: + selected_stacks = select_stacks( + stacks, + groups, + names=tuple(s for s in args.stacks.split(",") if s) if args.stacks else None, + group=args.group or None, + filters=args.filter, + ) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + if not selected_stacks: + print("error: no stacks selected by the given group/filter.", file=sys.stderr) + return 2 + + selected_scenarios = tuple(s for s in scenarios if not args.scenarios or s.name in args.scenarios.split(",")) + if not selected_scenarios: + print("error: no scenarios selected.", file=sys.stderr) + return 2 + + try: + pythons = _parse_pythons(args.pythons) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + for py in pythons: + if not Path(py.bin).exists(): + print(f"error: interpreter not found: {py.bin}", file=sys.stderr) + return 2 + + run_dir = results_dir / _timestamp_slug() + started_at = datetime.now(UTC).isoformat(timespec="seconds") + host = f"{platform.system()} {platform.release()} {platform.machine()}" + selection = _describe_selection(args) + dim_keys_list = list(dim_keys) + + print( + f"Running {len(pythons)} python(s) x {len(selected_stacks)} stack(s) " + f"x {len(selected_scenarios)} scenario(s) @ {args.duration}s each." + ) + print(f"Selection: {selection}") + print(f"Results dir: {run_dir}") + + any_cells = False + for py in pythons: + try: + _, full_version = probe_python(py.bin) + except (subprocess.CalledProcessError, OSError) as e: + print(f"error: cannot probe {py.bin}: {e}", file=sys.stderr) + continue + print(f"\n=== python={py.name} ({full_version}) ===") + cells: list[Cell] = [] + for stack_idx, stack in enumerate(selected_stacks): + for scen_idx, scenario in enumerate(selected_scenarios): + # NB: offset uses len(scenarios), not len(selected_scenarios), so partial + # --scenarios runs still pick distinct ports per cell. + port = pick_port(args.port_base, stack_idx * len(scenarios) + scen_idx) + print( + f" [{py.name}/{stack.name}/{scenario.name}] port={port} c={scenario.concurrency} ...", + flush=True, + ) + cell = run_cell(apps_pkg, stack, scenario, port, args.duration, py.bin, repo_root) + if cell is not None: + print( + f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " + f"({cell.status_2xx} 2xx / {cell.status_other} other)" + ) + cells.append(cell) + + report = RunReport( + started_at=started_at, + duration_s=args.duration, + host=host, + python=py.name, + python_version=full_version, + cells=cells, + selection=selection, + title=title, + dim_keys=dim_keys_list, + ) + target_dir = run_dir / py.name + _write_results(report, target_dir) + print(f" wrote {target_dir / 'RESULTS.md'}") + if cells: + any_cells = True + + return 0 if any_cells else 1 + + +def entrypoint( + *, + scenarios: tuple[Scenario, ...], + stacks: tuple[Stack, ...], + groups: dict[str, Callable[[Stack], bool]], + apps_pkg: str, + results_dir: Path, + title: str, + dim_keys: Iterable[str], + repo_root: Path, + description: str | None = None, +) -> int: + """``__main__`` wrapper that sets ``PYTHONUNBUFFERED`` and forwards to :func:`run`.""" + os.environ.setdefault("PYTHONUNBUFFERED", "1") + return run( + scenarios=scenarios, + stacks=stacks, + groups=groups, + apps_pkg=apps_pkg, + results_dir=results_dir, + title=title, + dim_keys=dim_keys, + repo_root=repo_root, + description=description, + ) diff --git a/benchmarks/_core/filters.py b/benchmarks/_core/filters.py new file mode 100644 index 0000000..9f2569e --- /dev/null +++ b/benchmarks/_core/filters.py @@ -0,0 +1,121 @@ +"""Filter language + stack selection for macro benchmarks. + +Filter language (parsed by :func:`parse_filters`): + +* ``key=value`` — exact match. Multiple ``--filter`` flags AND together. +* ``key=a,b`` — comma list inside a key is OR. +* ``key=lp-*`` — glob via ``*`` / ``?`` (fnmatch). +* ``key!=value`` — negation (combines with comma + glob). +* Special keys: ``name`` (stack name), ``tags`` (multi-valued). + Other keys are looked up in ``Stack.dims``. + +Each suite passes its own ``STACKS`` tuple and ``GROUPS`` mapping into +:func:`select_stacks` — the language and resolution logic are shared. +""" + +from __future__ import annotations + +import fnmatch +from collections.abc import Callable, Iterable +from dataclasses import dataclass + +from benchmarks._core.types import Stack + + +@dataclass(frozen=True, slots=True) +class _Filter: + key: str + values: tuple[str, ...] + """Each entry is an fnmatch pattern.""" + negate: bool + + +def parse_filters(specs: Iterable[str], valid_keys: frozenset[str]) -> tuple[_Filter, ...]: + """Parse ``key=v[,v]`` / ``key!=v[,v]`` filter strings. + + Raises ``ValueError`` with a helpful message on bad input. + """ + out: list[_Filter] = [] + for raw in specs: + spec = raw.strip() + if not spec: + continue + negate = False + if "!=" in spec: + key, _, values = spec.partition("!=") + negate = True + elif "=" in spec: + key, _, values = spec.partition("=") + else: + raise ValueError(f"--filter must be 'key=value' or 'key!=value', got: {spec!r}") + key = key.strip() + if key not in valid_keys: + raise ValueError(f"unknown filter key {key!r}; valid keys: {sorted(valid_keys)}") + items = tuple(v.strip() for v in values.split(",") if v.strip()) + if not items: + raise ValueError(f"filter {spec!r} has no values") + out.append(_Filter(key=key, values=items, negate=negate)) + return tuple(out) + + +def _stack_field(stack: Stack, key: str) -> tuple[str, ...]: + if key == "name": + return (stack.name,) + if key == "tags": + return tuple(sorted(stack.tags)) + if key in stack.dims: + return (stack.dims[key],) + return () + + +def _match_filter(stack: Stack, f: _Filter) -> bool: + haystack = _stack_field(stack, f.key) + matched = any(fnmatch.fnmatchcase(h, pat) for h in haystack for pat in f.values) + return not matched if f.negate else matched + + +def collect_dim_keys(stacks: Iterable[Stack]) -> frozenset[str]: + keys: set[str] = set() + for s in stacks: + keys.update(s.dims) + return frozenset(keys) + + +def select_stacks( + all_stacks: tuple[Stack, ...], + groups: dict[str, Callable[[Stack], bool]], + *, + names: Iterable[str] | None = None, + group: str | None = None, + filters: Iterable[str] = (), +) -> tuple[Stack, ...]: + """Resolve which stacks to run. + + Resolution order: + + 1. If ``names`` is given, return those stacks verbatim (escape hatch; + ``group`` and ``filters`` are ignored). + 2. Start from ``groups[group]`` if set, else all of ``all_stacks``. + 3. AND every parsed filter on top. + + Raises ``ValueError`` for unknown names / groups / filter keys. + """ + by_name = {s.name: s for s in all_stacks} + if names: + unknown = [n for n in names if n not in by_name] + if unknown: + raise ValueError(f"unknown stack name(s): {unknown}. Known: {sorted(by_name)}") + return tuple(by_name[n] for n in names) + + pool: tuple[Stack, ...] = all_stacks + if group is not None: + if group not in groups: + raise ValueError(f"unknown group {group!r}; known: {sorted(groups)}") + pred = groups[group] + pool = tuple(s for s in pool if pred(s)) + + valid_keys = frozenset({"name", "tags"} | collect_dim_keys(all_stacks)) + parsed = parse_filters(filters, valid_keys) + if parsed: + pool = tuple(s for s in pool if all(_match_filter(s, f) for f in parsed)) + return pool diff --git a/benchmarks/http/_pythons.py b/benchmarks/_core/pythons.py similarity index 73% rename from benchmarks/http/_pythons.py rename to benchmarks/_core/pythons.py index 33cdcd0..583892f 100644 --- a/benchmarks/http/_pythons.py +++ b/benchmarks/_core/pythons.py @@ -1,8 +1,8 @@ """Bench-matrix Python interpreters. -Single source of truth for which Python interpreters the HTTP benchmark -runs against. Read by both ``_setup.py`` (to provision each venv) and -``runner.py`` (as the default value for ``--pythons``). +Single source of truth for which Python interpreters macro benchmarks run +against. Read by suite ``_setup.py`` modules (to provision each venv) and +by the shared CLI (as the default value for ``--pythons``). Bench venvs live in ``.venv-bench//`` — fully separate from the project's primary ``.venv``. diff --git a/benchmarks/http/_render_html.py b/benchmarks/_core/render.py similarity index 52% rename from benchmarks/http/_render_html.py rename to benchmarks/_core/render.py index 83ce966..ad4a35b 100644 --- a/benchmarks/http/_render_html.py +++ b/benchmarks/_core/render.py @@ -1,13 +1,12 @@ -"""HTML report renderer for the HTTP benchmark. +"""Markdown + HTML report renderers, dim-driven. -Self-contained ``RESULTS.html`` next to ``RESULTS.md``. Renders one section -per scenario, with sortable tables and per-dimension dropdown filters. The -raw report is embedded as a JSON `` diff --git a/benchmarks/_core/runner.py b/benchmarks/_core/runner.py new file mode 100644 index 0000000..545b87f --- /dev/null +++ b/benchmarks/_core/runner.py @@ -0,0 +1,158 @@ +"""Cell-level orchestration: spawn → readiness probe → oha → parse → kill. + +Suite-agnostic. The CLI passes in the suite's apps package prefix (e.g. +``benchmarks.http.apps``) and the runner spawns ``python -m .`` +for each cell. +""" + +from __future__ import annotations + +import json +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path + +from benchmarks._core.types import Cell, Scenario, Stack + + +def pick_port(base: int, offset: int) -> int: + # Naive: a fixed offset per stack invocation. Conflicts are rare in practice + # and surface clearly via the readiness probe. + return base + offset + + +def wait_ready(port: int, deadline_s: float = 10.0) -> bool: + end = time.monotonic() + deadline_s + while time.monotonic() < end: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return True + except OSError: + time.sleep(0.05) + return False + + +def spawn_stack(apps_pkg: str, stack: str, port: int, python_bin: str, cwd: Path) -> subprocess.Popen: + return subprocess.Popen( # noqa: S603 + [python_bin, "-m", f"{apps_pkg}.{stack}", "--port", str(port)], + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +def kill(proc: subprocess.Popen) -> None: + if proc.poll() is not None: + return + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +def run_oha(scenario: Scenario, port: int, duration_s: int) -> dict: + base = f"http://127.0.0.1:{port}" + cmd = [ + "oha", + "--no-tui", + "--output-format", + "json", + "-z", + f"{duration_s}s", + "-c", + str(scenario.concurrency), + "-m", + scenario.method, + ] + if scenario.body is not None: + cmd += ["-d", scenario.body.decode("utf-8")] + if scenario.content_type is not None: + cmd += ["-T", scenario.content_type] + cmd.append(scenario.url(base)) + + result = subprocess.run( # noqa: S603 + cmd, + check=False, + capture_output=True, + text=True, + timeout=duration_s + 30, + ) + if result.returncode != 0: + raise RuntimeError(f"oha exited {result.returncode}: {result.stderr.strip()}") + return json.loads(result.stdout) + + +def _as_float(v: float | int | str | None, default: float = 0.0) -> float: + # oha emits explicit JSON null for percentiles when no responses fit the + # bucket (e.g. flooded server, near-zero successful requests). dict.get's + # default kicks in only for missing keys, not present-but-null, so coerce. + return default if v is None else float(v) + + +def parse_oha(raw: dict) -> dict: + summary = raw.get("summary", {}) + pct = raw.get("latencyPercentiles", {}) + status = raw.get("statusCodeDistribution", {}) + s2xx = sum(v for k, v in status.items() if k.startswith("2")) + sother = sum(v for k, v in status.items() if not k.startswith("2")) + total = s2xx + sother + return { + "rps": _as_float(summary.get("requestsPerSec")), + "p50_ms": _as_float(pct.get("p50")) * 1000, + "p90_ms": _as_float(pct.get("p90")) * 1000, + "p99_ms": _as_float(pct.get("p99")) * 1000, + "total_requests": total, + "success_rate": _as_float(summary.get("successRate")), + "status_2xx": s2xx, + "status_other": sother, + } + + +def run_cell( + apps_pkg: str, + stack: Stack, + scenario: Scenario, + port: int, + duration_s: int, + python_bin: str, + cwd: Path, +) -> Cell | None: + proc = spawn_stack(apps_pkg, stack.name, port, python_bin, cwd) + try: + if not wait_ready(port): + stderr = proc.stderr.read().decode() if proc.stderr else "" + print(f" [{stack.name}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) + return None + raw = run_oha(scenario, port, duration_s) + parsed = parse_oha(raw) + return Cell( + stack=stack.name, + scenario=scenario.name, + dims=dict(stack.dims), + tags=sorted(stack.tags), + **parsed, + ) + except Exception as e: # noqa: BLE001 + print(f" [{stack.name}/{scenario.name}] ERROR: {e}", file=sys.stderr) + return None + finally: + kill(proc) + + +_PROBE_CODE = ( + "import sys;" + "gil=getattr(sys,'_is_gil_enabled',lambda:True)();" + 'print(f\'{sys.version_info.major}.{sys.version_info.minor}{"" if gil else "t"}\');' + "print(sys.version.split()[0])" +) + + +def probe_python(python_bin: str) -> tuple[str, str]: + """Return (short label like ``3.14t``, full ``X.Y.Z`` version).""" + out = subprocess.check_output([python_bin, "-c", _PROBE_CODE], text=True).strip().splitlines() # noqa: S603 + return out[0], out[1] diff --git a/benchmarks/_core/types.py b/benchmarks/_core/types.py new file mode 100644 index 0000000..85014d8 --- /dev/null +++ b/benchmarks/_core/types.py @@ -0,0 +1,87 @@ +"""Shared dataclasses for macro bench runners. + +Each suite (http, openapi) builds its own ``SCENARIOS`` and ``STACKS`` from +these types. The runner, filter language, and report writers operate on the +:class:`Stack`'s ``dims`` mapping uniformly — what dim keys each suite +chooses (``app``/``backend`` for http, ``framework``/``server`` for openapi) +is up to the suite. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class Scenario: + """One wire contract every stack must implement.""" + + name: str + method: str + path: str + body: bytes | None + content_type: str | None + expected_status: int + concurrency: int + """oha -c value.""" + + def url(self, base: str) -> str: + return f"{base}{self.path}" + + +@dataclass(frozen=True, slots=True) +class Stack: + """One row of the bench matrix. + + ``name`` doubles as the module name under the suite's ``apps/`` package + — the runner spawns it as ``python -m .``. + + ``dims`` carries suite-specific dimensions used by ``--filter`` and the + HTML reporter. Values are stored as strings so the filter language and + HTML dropdowns can share one code path. + + ``tags`` is a free-form multi-valued set with its own filter semantics + (matched element-wise against ``--filter tags=...``). + """ + + name: str + dims: dict[str, str] = field(default_factory=dict) + tags: frozenset[str] = field(default_factory=frozenset) + + +@dataclass(slots=True) +class Cell: + """One (stack, scenario) result.""" + + stack: str + scenario: str + rps: float + p50_ms: float + p90_ms: float + p99_ms: float + total_requests: int + success_rate: float + status_2xx: int + status_other: int + dims: dict[str, str] = field(default_factory=dict) + tags: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class RunReport: + """One run's worth of cells, plus metadata for the report writers.""" + + started_at: str + duration_s: int + host: str + python: str + python_version: str + cells: list[Cell] + selection: str = "" + """Human-readable description of the stack selection (group/filter/stacks).""" + + title: str = "Benchmark" + """Report title (e.g. 'HTTP benchmark', 'OpenAPI benchmark').""" + + dim_keys: list[str] = field(default_factory=list) + """Ordered dim names — drives HTML filter dropdowns and column ordering.""" diff --git a/benchmarks/http/_setup.py b/benchmarks/http/_setup.py index 8207c2f..63560d0 100644 --- a/benchmarks/http/_setup.py +++ b/benchmarks/http/_setup.py @@ -1,8 +1,8 @@ """Sync every venv in the bench matrix. -Iterates ``benchmarks.http._pythons.PYTHONS`` and runs ``uv sync`` for each, -redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on -failure; exits non-zero if any sync failed. +Iterates ``benchmarks._core.pythons.PYTHONS`` and runs ``uv sync`` for +each, redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. +Continues on failure; exits non-zero if any sync failed. Only the groups + extras the HTTP benchmark stacks actually import are installed — ``--all-groups --all-extras`` drags in native packages @@ -18,16 +18,16 @@ import subprocess import sys -from benchmarks.http._pythons import PYTHONS +from benchmarks._core.pythons import PYTHONS # Just what the HTTP bench stacks need at runtime. See `benchmarks/http/apps/` # for the actual imports. GROUPS: tuple[str, ...] = ( - "bench", # starlette, uvicorn, a2wsgi, cheroot, gunicorn, granian, pytest-benchmark + "bench", # starlette, uvicorn, a2wsgi, cheroot, gunicorn, granian, pytest-benchmark "dev-http", # flask, pydantic, httpx ) EXTRAS: tuple[str, ...] = ( - "http", # h11 + "http", # h11 "http-fast", # httptools ) diff --git a/benchmarks/http/runner.py b/benchmarks/http/runner.py index 0d9b65b..6a7f8d4 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/http/runner.py @@ -1,4 +1,4 @@ -"""Macro HTTP benchmark runner. +"""Macro HTTP benchmark runner — thin entry point. For each (python, stack, scenario) cell: 1. Boot the stack as a subprocess (`` -m benchmarks.http.apps.``). @@ -9,416 +9,39 @@ Output: results///results.json — raw cells. - results///RESULTS.md — markdown summary, one - table per scenario. + results///RESULTS.md — markdown summary, + one table per scenario. + results///RESULTS.html — interactive view. -Each Python interpreter gets its own subdirectory; cross-interpreter numbers -are intentionally not merged (different runtimes are not directly comparable). - -By default the runner targets every interpreter declared in -``benchmarks.http._pythons.PYTHONS``. Override with ``--pythons`` for ad-hoc -runs against a custom interpreter. - -Run from the repo root:: - - just bench-http # full matrix - just bench-http --duration 5 # quick sanity - just bench-http --group quick # ~4 stacks, fast PR check - just bench-http --filter app=flask # all Flask servers - just bench-http --filter 'backend=lp-*' --filter selectors=1 - just bench-http --stacks localpost_h11,flask_gunicorn # exact list (escape hatch) - just bench-http --pythons 3.13=.venv-bench/3.13/bin/python +See :mod:`benchmarks._core.cli` for the CLI flag surface. """ from __future__ import annotations -import argparse -import json -import os -import platform -import shutil -import signal -import socket -import subprocess import sys -import time -from dataclasses import asdict, dataclass, field -from datetime import UTC, datetime from pathlib import Path -from benchmarks.http._pythons import PYTHONS -from benchmarks.http._render_html import render_html -from benchmarks.http.scenarios import SCENARIOS, Scenario -from benchmarks.http.stacks import GROUPS, Stack, select_stacks +from benchmarks._core.cli import entrypoint +from benchmarks.http.scenarios import SCENARIOS +from benchmarks.http.stacks import DIM_KEYS, GROUPS, STACKS REPO_ROOT = Path(__file__).resolve().parents[2] RESULTS_DIR = Path(__file__).parent / "results" -@dataclass(frozen=True, slots=True) -class PythonInterp: - name: str - bin: str - - -@dataclass(slots=True) -class Cell: - stack: str - scenario: str - rps: float - p50_ms: float - p90_ms: float - p99_ms: float - total_requests: int - success_rate: float - status_2xx: int - status_other: int - # Dimensions copied from `Stack`, so results.json is self-describing - # for the HTML reporter. - app: str = "" - backend: str = "" - selectors: int = 1 - pool: bool = True - acceptor: bool = False - tags: list[str] = field(default_factory=list) - - -@dataclass(slots=True) -class RunReport: - started_at: str - duration_s: int - host: str - python: str - python_version: str - cells: list[Cell] - selection: str = "" - """Human-readable description of the stack selection (group/filter/stacks).""" - - -def _pick_port(base: int, offset: int) -> int: - # Naive: a fixed offset per stack invocation. Conflicts are rare in practice - # and surface clearly via the readiness probe. - return base + offset - - -def _wait_ready(port: int, deadline_s: float = 10.0) -> bool: - end = time.monotonic() + deadline_s - while time.monotonic() < end: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.2): - return True - except OSError: - time.sleep(0.05) - return False - - -def _spawn_stack(stack: str, port: int, python_bin: str) -> subprocess.Popen: - return subprocess.Popen( # noqa: S603 - [python_bin, "-m", f"benchmarks.http.apps.{stack}", "--port", str(port)], - cwd=REPO_ROOT, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - - -def _kill(proc: subprocess.Popen) -> None: - if proc.poll() is not None: - return - proc.send_signal(signal.SIGTERM) - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - - -def _run_oha(scenario: Scenario, port: int, duration_s: int) -> dict: - base = f"http://127.0.0.1:{port}" - cmd = [ - "oha", - "--no-tui", - "--output-format", - "json", - "-z", - f"{duration_s}s", - "-c", - str(scenario.concurrency), - "-m", - scenario.method, - ] - if scenario.body is not None: - cmd += ["-d", scenario.body.decode("utf-8")] - if scenario.content_type is not None: - cmd += ["-T", scenario.content_type] - cmd.append(scenario.url(base)) - - result = subprocess.run( # noqa: S603 - cmd, - check=False, - capture_output=True, - text=True, - timeout=duration_s + 30, - ) - if result.returncode != 0: - raise RuntimeError(f"oha exited {result.returncode}: {result.stderr.strip()}") - return json.loads(result.stdout) - - -def _as_float(v: float | int | str | None, default: float = 0.0) -> float: - # oha emits explicit JSON null for percentiles when no responses fit the - # bucket (e.g. flooded server, near-zero successful requests). dict.get's - # default kicks in only for missing keys, not present-but-null, so coerce. - return default if v is None else float(v) - - -def _parse_oha(raw: dict) -> dict: - summary = raw.get("summary", {}) - pct = raw.get("latencyPercentiles", {}) - status = raw.get("statusCodeDistribution", {}) - s2xx = sum(v for k, v in status.items() if k.startswith("2")) - sother = sum(v for k, v in status.items() if not k.startswith("2")) - total = s2xx + sother - return { - "rps": _as_float(summary.get("requestsPerSec")), - "p50_ms": _as_float(pct.get("p50")) * 1000, - "p90_ms": _as_float(pct.get("p90")) * 1000, - "p99_ms": _as_float(pct.get("p99")) * 1000, - "total_requests": total, - "success_rate": _as_float(summary.get("successRate")), - "status_2xx": s2xx, - "status_other": sother, - } - - -def _run_cell(stack: Stack, scenario: Scenario, port: int, duration_s: int, python_bin: str) -> Cell | None: - proc = _spawn_stack(stack.name, port, python_bin) - try: - if not _wait_ready(port): - stderr = proc.stderr.read().decode() if proc.stderr else "" - print(f" [{stack.name}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) - return None - raw = _run_oha(scenario, port, duration_s) - parsed = _parse_oha(raw) - return Cell( - stack=stack.name, - scenario=scenario.name, - app=stack.app, - backend=stack.backend, - selectors=stack.selectors, - pool=stack.pool, - acceptor=stack.acceptor, - tags=sorted(stack.tags), - **parsed, - ) - except Exception as e: # noqa: BLE001 - print(f" [{stack.name}/{scenario.name}] ERROR: {e}", file=sys.stderr) - return None - finally: - _kill(proc) - - -def _write_results(report: RunReport, target_dir: Path) -> None: - target_dir.mkdir(parents=True, exist_ok=True) - payload = asdict(report) - (target_dir / "results.json").write_text(json.dumps(payload, indent=2) + "\n") - (target_dir / "RESULTS.md").write_text(_render_markdown(report)) - (target_dir / "RESULTS.html").write_text(render_html(payload)) - - -def _render_markdown(report: RunReport) -> str: - out: list[str] = [] - out.append(f"# HTTP benchmark results — Python {report.python}\n") - out.append(f"- Run at: `{report.started_at}`") - out.append(f"- Host: `{report.host}`") - out.append(f"- Python: `{report.python}` (`{report.python_version}`)") - out.append(f"- Duration per cell: `{report.duration_s}s`") - if report.selection: - out.append(f"- Selection: {report.selection}") - out.append("") - out.append("> Numbers are single-process, single-host. Don't read absolute RPS as gospel —") - out.append("> what matters is the relative ordering on the same machine in one run.") - out.append("") - - cells_by_scenario: dict[str, list[Cell]] = {} - for c in report.cells: - cells_by_scenario.setdefault(c.scenario, []).append(c) - - for scenario_name, cells in cells_by_scenario.items(): - cells.sort(key=lambda c: c.rps, reverse=True) - out.append(f"## {scenario_name}\n") - out.append("| Stack | RPS | p50 (ms) | p90 (ms) | p99 (ms) | 2xx | non-2xx |") - out.append("|---|---:|---:|---:|---:|---:|---:|") - out.extend( - f"| `{c.stack}` | {c.rps:,.0f} | {c.p50_ms:.2f} | {c.p90_ms:.2f} " - f"| {c.p99_ms:.2f} | {c.status_2xx:,} | {c.status_other:,} |" - for c in cells - ) - out.append("") - return "\n".join(out) - - -_PROBE_CODE = ( - "import sys;" - "gil=getattr(sys,'_is_gil_enabled',lambda:True)();" - "print(f'{sys.version_info.major}.{sys.version_info.minor}{\"\" if gil else \"t\"}');" - "print(sys.version.split()[0])" -) - - -def _probe_python(python_bin: str) -> tuple[str, str]: - """Return (short label like ``3.14t``, full ``X.Y.Z`` version).""" - out = subprocess.check_output([python_bin, "-c", _PROBE_CODE], text=True).strip().splitlines() # noqa: S603 - return out[0], out[1] - - -def _parse_pythons(arg: str) -> list[PythonInterp]: - """Parse ``name=path,name=path`` (or fall back to the bench matrix).""" - if not arg: - return [PythonInterp(name=p.name, bin=p.bin) for p in PYTHONS] - pythons: list[PythonInterp] = [] - for spec in arg.split(","): - spec = spec.strip() - if not spec: - continue - if "=" not in spec: - raise ValueError(f"--pythons entry must be name=path, got: {spec!r}") - name, _, path = spec.partition("=") - pythons.append(PythonInterp(name=name.strip(), bin=path.strip())) - return pythons - - -def _timestamp_slug() -> str: - return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%S") - - -def _describe_selection(args: argparse.Namespace) -> str: - parts: list[str] = [] - if args.stacks: - parts.append(f"stacks={args.stacks}") - if args.group: - parts.append(f"group={args.group}") - if args.filter: - parts.append("filter=" + ", ".join(args.filter)) - return "; ".join(parts) if parts else "all" - - def main() -> int: - p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument("--duration", type=int, default=20, help="seconds per cell (default: 20)") - p.add_argument( - "--stacks", - default="", - help="comma-separated stack name(s) — verbatim escape hatch (bypasses --group/--filter)", - ) - p.add_argument( - "--group", - default="", - help=f"named preset; one of: {', '.join(sorted(GROUPS))}", - ) - p.add_argument( - "--filter", - action="append", - default=[], - metavar="KEY=VAL", - help="dimension filter, e.g. 'app=flask', 'backend=lp-*', 'selectors=1', 'app!=starlette'. " - "Repeatable (filters AND together). Comma-separated values inside one key are OR.", - ) - p.add_argument("--scenarios", default="", help="comma-separated scenario filter (default: all)") - p.add_argument( - "--pythons", - default="", - help="comma-separated name=path pairs to override the bench matrix, e.g. " - "'3.13=.venv-bench/3.13/bin/python' (default: every entry in benchmarks.http._pythons.PYTHONS)", - ) - p.add_argument("--port-base", type=int, default=18800) - args = p.parse_args() - - if shutil.which("oha") is None: - print("error: 'oha' not found on PATH. Install via 'brew install oha'.", file=sys.stderr) - return 2 - - try: - stacks = select_stacks( - names=tuple(s for s in args.stacks.split(",") if s) if args.stacks else None, - group=args.group or None, - filters=args.filter, - ) - except ValueError as e: - print(f"error: {e}", file=sys.stderr) - return 2 - if not stacks: - print("error: no stacks selected by the given group/filter.", file=sys.stderr) - return 2 - - scenarios = tuple(s for s in SCENARIOS if not args.scenarios or s.name in args.scenarios.split(",")) - if not scenarios: - print("error: no scenarios selected.", file=sys.stderr) - return 2 - - try: - pythons = _parse_pythons(args.pythons) - except ValueError as e: - print(f"error: {e}", file=sys.stderr) - return 2 - for py in pythons: - if not Path(py.bin).exists(): - print(f"error: interpreter not found: {py.bin}", file=sys.stderr) - return 2 - - run_dir = RESULTS_DIR / _timestamp_slug() - started_at = datetime.now(UTC).isoformat(timespec="seconds") - host = f"{platform.system()} {platform.release()} {platform.machine()}" - selection = _describe_selection(args) - - print( - f"Running {len(pythons)} python(s) x {len(stacks)} stack(s) " - f"x {len(scenarios)} scenario(s) @ {args.duration}s each." + return entrypoint( + scenarios=SCENARIOS, + stacks=STACKS, + groups=GROUPS, + apps_pkg="benchmarks.http.apps", + results_dir=RESULTS_DIR, + title="HTTP benchmark", + dim_keys=DIM_KEYS, + repo_root=REPO_ROOT, + description=__doc__, ) - print(f"Selection: {selection}") - print(f"Results dir: {run_dir}") - - any_cells = False - for py in pythons: - try: - _, full_version = _probe_python(py.bin) - except (subprocess.CalledProcessError, OSError) as e: - print(f"error: cannot probe {py.bin}: {e}", file=sys.stderr) - continue - print(f"\n=== python={py.name} ({full_version}) ===") - cells: list[Cell] = [] - for stack_idx, stack in enumerate(stacks): - for scen_idx, scenario in enumerate(scenarios): - port = _pick_port(args.port_base, stack_idx * len(SCENARIOS) + scen_idx) - print( - f" [{py.name}/{stack.name}/{scenario.name}] port={port} c={scenario.concurrency} ...", - flush=True, - ) - cell = _run_cell(stack, scenario, port, args.duration, py.bin) - if cell is not None: - print( - f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " - f"({cell.status_2xx} 2xx / {cell.status_other} other)" - ) - cells.append(cell) - - report = RunReport( - started_at=started_at, - duration_s=args.duration, - host=host, - python=py.name, - python_version=full_version, - cells=cells, - selection=selection, - ) - target_dir = run_dir / py.name - _write_results(report, target_dir) - print(f" wrote {target_dir / 'RESULTS.md'}") - if cells: - any_cells = True - - return 0 if any_cells else 1 if __name__ == "__main__": - os.environ.setdefault("PYTHONUNBUFFERED", "1") sys.exit(main()) diff --git a/benchmarks/http/scenarios.py b/benchmarks/http/scenarios.py index c6b4ab2..b695b35 100644 --- a/benchmarks/http/scenarios.py +++ b/benchmarks/http/scenarios.py @@ -16,23 +16,22 @@ from __future__ import annotations import json -from dataclasses import dataclass from typing import Any, Final - -@dataclass(frozen=True, slots=True) -class Scenario: - name: str - method: str - path: str - body: bytes | None - content_type: str | None - expected_status: int - concurrency: int - """oha -c value.""" - - def url(self, base: str) -> str: - return f"{base}{self.path}" +from benchmarks._core.types import Scenario + +__all__ = [ + "HELLO_PREFIX", + "JSON_ECHO_BODY", + "PING_BODY", + "PROFILE_WORK_DELAYS_S", + "SCENARIOS", + "Scenario", + "build_profile_update", + "hello_body", + "profile_update_body", + "profile_update_payload", +] _JSON_BODY: Final = b'{"name":"world","numbers":[1,2,3,4,5,6,7,8,9,10]}' diff --git a/benchmarks/http/stacks.py b/benchmarks/http/stacks.py index 8cbdbb5..b992c37 100644 --- a/benchmarks/http/stacks.py +++ b/benchmarks/http/stacks.py @@ -1,83 +1,76 @@ -"""Bench stack registry — typed dimensions over the flat stack list. +"""HTTP bench stack registry — typed dimensions over the flat stack list. -Each stack is one row of the matrix. Rather than a flat tuple of names, each -stack carries explicit dimension fields (``app``, ``backend``, ``selectors``, -``pool``, ``acceptor``, ``tags``). The runner uses these to: +Each stack is one row of the matrix. Rather than a flat tuple of names, +each stack carries explicit ``dims`` — ``app``, ``backend``, ``selectors``, +``pool``, ``acceptor`` — and an optional ``tags`` set. The runner uses +these to: * select a subset for a run via ``--filter`` / ``--group`` / ``--stacks``; * annotate each cell in ``results.json`` so the HTML reporter can pivot. -The ``name`` field doubles as the module name under ``benchmarks/http/apps/``, -so spawning a stack stays a one-liner — no app-module changes needed. +The ``name`` field doubles as the module name under +``benchmarks/http/apps/``, so spawning a stack stays a one-liner — no +app-module changes needed. -Filter language (parsed by :func:`parse_filters`): - -* ``key=value`` — exact match. Multiple ``--filter`` flags AND together. -* ``key=a,b`` — comma list inside a key is OR. -* ``key=lp-*`` — glob via ``*`` / ``?`` (fnmatch). -* ``key!=value`` — negation (combines with comma + glob). -* Keys: ``app``, ``backend``, ``selectors``, ``pool``, ``acceptor``, ``tags``, ``name``. +See :mod:`benchmarks._core.filters` for the filter language. """ from __future__ import annotations -import fnmatch -from collections.abc import Callable, Iterable -from dataclasses import dataclass, field +from collections.abc import Callable from typing import Final +from benchmarks._core.types import Stack -@dataclass(frozen=True, slots=True) -class Stack: - name: str - """Display id; matches ``benchmarks/http/apps/.py``.""" - - app: str - """``native`` | ``wsgi`` | ``flask`` | ``starlette``.""" - - backend: str - """``lp-h11`` | ``lp-httptools`` | ``cheroot`` | ``gunicorn`` | ``granian`` | ``uvicorn``.""" - - selectors: int - """1 (single thread) | 4 (multi). Always 1 for non-LocalPost backends.""" - - pool: bool - """``False`` = handlers run inline on the selector thread.""" +# Ordered dim keys — drives HTML column ordering and filter dropdown order. +DIM_KEYS: Final[tuple[str, ...]] = ("app", "backend", "selectors", "pool", "acceptor") - acceptor: bool = False - """``True`` = LocalPost acceptor topology (1 acceptor thread + N worker - selectors via cross-thread op queue). Default ``False`` mirrors the - ``selectors=N`` ``SO_REUSEPORT`` shape and applies to non-LocalPost - backends as well.""" - tags: frozenset[str] = field(default_factory=frozenset) - """Free-form labels, e.g. ``reference``.""" +def _stack( + name: str, + *, + app: str, + backend: str, + selectors: int = 1, + pool: bool = True, + acceptor: bool = False, + tags: frozenset[str] = frozenset(), +) -> Stack: + return Stack( + name=name, + dims={ + "app": app, + "backend": backend, + "selectors": str(selectors), + "pool": "true" if pool else "false", + "acceptor": "true" if acceptor else "false", + }, + tags=tags, + ) STACKS: Final[tuple[Stack, ...]] = ( - Stack("localpost_h11", app="native", backend="lp-h11", selectors=1, pool=True), - Stack("localpost_h11_s4", app="native", backend="lp-h11", selectors=4, pool=True), - Stack( - "localpost_h11_acceptor_s4", + _stack("localpost_h11", app="native", backend="lp-h11"), + _stack("localpost_h11_s4", app="native", backend="lp-h11", selectors=4), + _stack("localpost_h11_acceptor_s4", app="native", backend="lp-h11", selectors=4, acceptor=True), + _stack("localpost_httptools", app="native", backend="lp-httptools"), + _stack("localpost_httptools_s4", app="native", backend="lp-httptools", selectors=4), + _stack( + "localpost_httptools_acceptor_s4", app="native", - backend="lp-h11", + backend="lp-httptools", selectors=4, - pool=True, acceptor=True, ), - Stack("localpost_httptools", app="native", backend="lp-httptools", selectors=1, pool=True), - Stack("localpost_httptools_s4", app="native", backend="lp-httptools", selectors=4, pool=True), - Stack( - "localpost_httptools_acceptor_s4", + _stack("localpost_httptools_inline", app="native", backend="lp-httptools", pool=False), + _stack( + "localpost_httptools_inline_s4", app="native", backend="lp-httptools", selectors=4, - pool=True, - acceptor=True, + pool=False, ), - Stack("localpost_httptools_inline", app="native", backend="lp-httptools", selectors=1, pool=False), - Stack("localpost_httptools_inline_s4", app="native", backend="lp-httptools", selectors=4, pool=False), - Stack( + _stack( "localpost_httptools_inline_acceptor_s4", app="native", backend="lp-httptools", @@ -85,133 +78,22 @@ class Stack: pool=False, acceptor=True, ), - Stack("localpost_wsgi", app="wsgi", backend="lp-h11", selectors=1, pool=True), - Stack("localpost_flask", app="flask", backend="lp-h11", selectors=1, pool=True), - Stack("flask_cheroot", app="flask", backend="cheroot", selectors=1, pool=True), - Stack("flask_gunicorn", app="flask", backend="gunicorn", selectors=1, pool=True), - Stack("flask_granian", app="flask", backend="granian", selectors=1, pool=True), - Stack( - "starlette_uvicorn", - app="starlette", - backend="uvicorn", - selectors=1, - pool=True, - tags=frozenset({"reference"}), - ), - Stack( - "starlette_granian", - app="starlette", - backend="granian", - selectors=1, - pool=True, - tags=frozenset({"reference"}), - ), + _stack("localpost_wsgi", app="wsgi", backend="lp-h11"), + _stack("localpost_flask", app="flask", backend="lp-h11"), + _stack("flask_cheroot", app="flask", backend="cheroot"), + _stack("flask_gunicorn", app="flask", backend="gunicorn"), + _stack("flask_granian", app="flask", backend="granian"), + _stack("starlette_uvicorn", app="starlette", backend="uvicorn", tags=frozenset({"reference"})), + _stack("starlette_granian", app="starlette", backend="granian", tags=frozenset({"reference"})), ) -_STACKS_BY_NAME: Final[dict[str, Stack]] = {s.name: s for s in STACKS} - GROUPS: Final[dict[str, Callable[[Stack], bool]]] = { # Smallest sensible matrix — one representative per backend family. "quick": lambda s: s.name in {"localpost_httptools", "flask_granian", "flask_cheroot", "starlette_uvicorn"}, - "localpost": lambda s: s.backend.startswith("lp-"), - "flask": lambda s: s.app == "flask", + "localpost": lambda s: s.dims["backend"].startswith("lp-"), + "flask": lambda s: s.dims["app"] == "flask", "reference": lambda s: "reference" in s.tags, "no-reference": lambda s: "reference" not in s.tags, - "single-sel": lambda s: s.selectors == 1, + "single-sel": lambda s: s.dims["selectors"] == "1", } - - -_VALID_KEYS: Final[frozenset[str]] = frozenset( - {"app", "backend", "selectors", "pool", "acceptor", "tags", "name"} -) - - -@dataclass(frozen=True, slots=True) -class _Filter: - key: str - values: tuple[str, ...] - """Each entry is an fnmatch pattern.""" - negate: bool - - -def parse_filters(specs: Iterable[str]) -> tuple[_Filter, ...]: - """Parse ``key=v[,v]`` / ``key!=v[,v]`` filter strings. - - Raises ``ValueError`` with a helpful message on bad input. - """ - out: list[_Filter] = [] - for raw in specs: - spec = raw.strip() - if not spec: - continue - negate = False - if "!=" in spec: - key, _, values = spec.partition("!=") - negate = True - elif "=" in spec: - key, _, values = spec.partition("=") - else: - raise ValueError(f"--filter must be 'key=value' or 'key!=value', got: {spec!r}") - key = key.strip() - if key not in _VALID_KEYS: - raise ValueError(f"unknown filter key {key!r}; valid keys: {sorted(_VALID_KEYS)}") - items = tuple(v.strip() for v in values.split(",") if v.strip()) - if not items: - raise ValueError(f"filter {spec!r} has no values") - out.append(_Filter(key=key, values=items, negate=negate)) - return tuple(out) - - -def _stack_field(stack: Stack, key: str) -> tuple[str, ...]: - if key == "tags": - return tuple(sorted(stack.tags)) - if key == "selectors": - return (str(stack.selectors),) - if key == "pool": - return ("true" if stack.pool else "false",) - if key == "acceptor": - return ("true" if stack.acceptor else "false",) - return (str(getattr(stack, key)),) - - -def _match_filter(stack: Stack, f: _Filter) -> bool: - haystack = _stack_field(stack, f.key) - matched = any(fnmatch.fnmatchcase(h, pat) for h in haystack for pat in f.values) - return not matched if f.negate else matched - - -def select_stacks( - *, - names: Iterable[str] | None = None, - group: str | None = None, - filters: Iterable[str] = (), -) -> tuple[Stack, ...]: - """Resolve which stacks to run. - - Resolution order: - - 1. If ``names`` is given, return those stacks verbatim (escape hatch; - ``group`` and ``filters`` are ignored). - 2. Start from ``GROUPS[group]`` if set, else all of :data:`STACKS`. - 3. AND every parsed filter on top. - - Raises ``ValueError`` for unknown names / groups / filter keys. - """ - if names: - unknown = [n for n in names if n not in _STACKS_BY_NAME] - if unknown: - raise ValueError(f"unknown stack name(s): {unknown}. Known: {sorted(_STACKS_BY_NAME)}") - return tuple(_STACKS_BY_NAME[n] for n in names) - - pool: tuple[Stack, ...] = STACKS - if group is not None: - if group not in GROUPS: - raise ValueError(f"unknown group {group!r}; known: {sorted(GROUPS)}") - pred = GROUPS[group] - pool = tuple(s for s in pool if pred(s)) - - parsed = parse_filters(filters) - if parsed: - pool = tuple(s for s in pool if all(_match_filter(s, f) for f in parsed)) - return pool diff --git a/plans/openapi-benchmarks.md b/plans/openapi-benchmarks.md new file mode 100644 index 0000000..bdfc0e8 --- /dev/null +++ b/plans/openapi-benchmarks.md @@ -0,0 +1,133 @@ +# OpenAPI framework benchmarks + +Add a second macro-bench suite that compares `localpost.openapi` against +peer typed/OpenAPI Python web frameworks. Today's `benchmarks/http/` +measures **HTTP server overhead** with thin Flask/Starlette apps; +the new `benchmarks/openapi/` measures **framework overhead** — +typed signatures, schema validation, response serialization, +OpenAPI metadata cost — with the server held fixed per framework. + +## 1. Layout — extract shared core + +Refactor first, then add the new suite. Three siblings under `benchmarks/`: + +``` +benchmarks/ + _core/ # shared runner infra + runner.py # spawn → readiness probe → oha → parse → kill + types.py # Scenario, Stack, Cell, RunReport + pythons.py # PYTHONS matrix (moved from http/_pythons.py) + render.py # markdown + HTML reports + cli.py # --duration / --filter / --scenarios / --pythons / --stacks / --group + http/ # existing suite, slimmed + scenarios.py + stacks.py + runner.py # thin entry point → benchmarks._core.cli.main(...) + apps/ + results/ + openapi/ # new + scenarios.py + stacks.py + runner.py + apps/ + results/ +``` + +`_core.cli.main(scenarios, stacks, apps_pkg, results_dir, ...)` parameterizes +the suite-specific bits. `apps_pkg` is the dotted prefix used by the runner +to spawn each stack as `python -m .`. + +`Cell` keeps `stack`, `scenario`, `rps`, `p50/p90/p99`, etc., and replaces +today's hard-coded `app`/`backend`/`selectors`/`pool`/`acceptor`/`tags` with +a generic `dims: dict[str, str]`. Suite-specific dimensions: + +- http suite dims: `{app, backend, selectors, pool, acceptor}` +- openapi suite dims: `{framework, server, pydantic}` + +The HTML renderer reads `dims` keys and exposes them as filters generically — +the http suite's existing UI is preserved with no behavior change. + +`just bench-http` stays as-is. `just bench-openapi` is added with the same +flag surface. + +## 2. Stacks (v1) + +| Stack ID | Framework | Server | +|---------------------|-------------------------------------|---------------------------------| +| `localpost_openapi` | `localpost.openapi.HttpApp` | `localpost.http` (h11) | +| `flask_openapi` | `flask-openapi3 ~=5.0` | gunicorn sync, `--threads 32` | +| `fastapi` | FastAPI current | uvicorn, 1 worker | + +`flask-openapi3` was renamed to `flask-openapi` upstream; we pin the v5 line. + +Pinned in a new `[dependency-groups.bench-openapi]` group so the main install +isn't polluted. Pydantic v2 is implicit via FastAPI + flask-openapi3. + +Future variants (out of scope for v1): granian, httptools, multi-selector +for `localpost_openapi`. Stack-variant slots are already supported by the +shared runner. + +## 3. Scenarios (v1) + +Each scenario defines one wire contract; every stack implements it +idiomatically per framework. + +| Scenario | Method · Path | Expected | What it exercises | +|----------------------|----------------------------------------------|----------|-------------------------------------------------------| +| `plaintext` | `GET /ping` | 200 | Pure dispatch — calibration anchor. | +| `path_param_typed` | `GET /items/{item_id}` (int) | 200 | Path coercion. | +| `query_validation` | `GET /search?q=…&limit=…&offset=…` | 200 | Query parse + constrained validation. | +| `body_roundtrip` | `POST /users/{id}/profile` JSON in/out | 200 | Schema-driven decode → normalize → encode (headline). | +| `validation_failure` | `POST /users/{id}/profile` with bad JSON | 422 | Error-path throughput. | + +The `body_roundtrip` payload mirrors +`benchmarks/http/scenarios.py::_PROFILE_UPDATE_BODY` so wire contracts +align across both suites for cross-checking. + +For `validation_failure`, the runner needs a small tweak: today success is +`status_2xx`. Move success classification to a per-`Scenario` +`expected_status` (already on the dataclass) and rename +`status_2xx`/`status_other` on `Cell` to `status_expected`/`status_other`. +The http suite is unaffected — every existing scenario expects 200. + +## 4. Workload definitions + +One model schema, defined three times in idiomatic style — not shared via a +common library: + +- **LocalPost**: `@dataclass` model + return-type union + (`Profile | BadRequest[ProblemDetails]`). +- **FastAPI**: Pydantic v2 model + `response_model=Profile` + + `HTTPException(422)` for the failure case. +- **flask-openapi3**: Pydantic v2 model + decorator-attached schema classes + per its v5 API. + +Same fields, same types, same constraints across all three — the +validation-failure body is rejected by all three for the same field reason, +so we're comparing equivalent work. + +We test the **full typed cycle**, including framework-default response +serialization. No "raw FastAPI without `response_model`" variant — that +isn't a realistic deployment shape and isn't what we're measuring. + +## 5. Implementation order + +1. Refactor `benchmarks/http/` into `benchmarks/_core/` + thin + `benchmarks/http/`. Verify `just bench-http --duration 5 + --stacks localpost_h11,flask_gunicorn` produces equivalent output. +2. Scaffold `benchmarks/openapi/` with `plaintext` only across all three + stacks. Confirm runner spawns/probes/kills each. +3. Add `path_param_typed` and `body_roundtrip`. +4. Add `query_validation` and `validation_failure` (the latter requires the + `expected_status` plumbing). +5. README under `benchmarks/openapi/` mirroring + `benchmarks/http/README.md`. Top-level `benchmarks/README.md` updated to + describe the split. + +## 6. Caveats (carry over from http suite README) + +- Single-host noise: relative ordering on the same run is what matters. +- Single-process by design: real deployments multiply by N workers; the + relative ordering still holds. +- Not for CI gates: GitHub Actions runners are too noisy for HTTP throughput + regression. From 0e4aed11af1bca8585d4cbf720cb8f0288220b38 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 09:07:54 +0400 Subject: [PATCH 195/286] feat(benchmarks): scaffold openapi suite with plaintext scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stacks — localpost_openapi (HttpApp on h11), flask_openapi (flask-openapi v5 on Gunicorn), fastapi (FastAPI on Uvicorn) — all serving GET /ping and driven by the shared runner via 'just bench-openapi'. flask-openapi3 was renamed to flask-openapi for the v5 line; v5 is currently rc1 on PyPI, but the explicit lower bound (>=5.0.0rc1) lets uv resolve it under the default if-necessary-or-explicit pre-release mode. The two suites share .venv-bench// venvs, so the per-suite _setup.py is consolidated into benchmarks/_setup.py that installs the union of bench groups and extras (uv sync overwrites otherwise). Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/{http => }/_setup.py | 27 ++++++---- benchmarks/openapi/__init__.py | 7 +++ benchmarks/openapi/apps/__init__.py | 0 benchmarks/openapi/apps/_cli.py | 14 +++++ benchmarks/openapi/apps/fastapi.py | 38 ++++++++++++++ benchmarks/openapi/apps/flask_openapi.py | 55 ++++++++++++++++++++ benchmarks/openapi/apps/localpost_openapi.py | 32 ++++++++++++ benchmarks/openapi/runner.py | 38 ++++++++++++++ benchmarks/openapi/scenarios.py | 38 ++++++++++++++ benchmarks/openapi/stacks.py | 43 +++++++++++++++ justfile | 9 +++- pyproject.toml | 10 ++++ uv.lock | 23 ++++++++ 13 files changed, 322 insertions(+), 12 deletions(-) rename benchmarks/{http => }/_setup.py (61%) create mode 100644 benchmarks/openapi/__init__.py create mode 100644 benchmarks/openapi/apps/__init__.py create mode 100644 benchmarks/openapi/apps/_cli.py create mode 100644 benchmarks/openapi/apps/fastapi.py create mode 100644 benchmarks/openapi/apps/flask_openapi.py create mode 100644 benchmarks/openapi/apps/localpost_openapi.py create mode 100644 benchmarks/openapi/runner.py create mode 100644 benchmarks/openapi/scenarios.py create mode 100644 benchmarks/openapi/stacks.py diff --git a/benchmarks/http/_setup.py b/benchmarks/_setup.py similarity index 61% rename from benchmarks/http/_setup.py rename to benchmarks/_setup.py index 63560d0..4651597 100644 --- a/benchmarks/http/_setup.py +++ b/benchmarks/_setup.py @@ -1,13 +1,18 @@ """Sync every venv in the bench matrix. -Iterates ``benchmarks._core.pythons.PYTHONS`` and runs ``uv sync`` for -each, redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. -Continues on failure; exits non-zero if any sync failed. +Iterates ``benchmarks._core.pythons.PYTHONS`` and runs ``uv sync`` for each, +redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on +failure; exits non-zero if any sync failed. -Only the groups + extras the HTTP benchmark stacks actually import are -installed — ``--all-groups --all-extras`` drags in native packages -(``grpcio``, ``opentelemetry-*``, …) that have spotty wheel coverage on -free-threaded Python (e.g. 3.14t). +The two macro bench suites (``benchmarks/http/``, ``benchmarks/openapi/``) +share the same venvs, so this installs the union of their groups and +extras in one pass — ``uv sync`` would otherwise wipe groups not named on +the current invocation. + +Only the groups + extras the bench stacks actually import are installed — +``--all-groups --all-extras`` drags in native packages (``grpcio``, +``opentelemetry-*``, …) that have spotty wheel coverage on free-threaded +Python (e.g. 3.14t). Invoked by ``just bench-deps`` and ``just bench-deps-upgrade``. """ @@ -20,15 +25,17 @@ from benchmarks._core.pythons import PYTHONS -# Just what the HTTP bench stacks need at runtime. See `benchmarks/http/apps/` -# for the actual imports. +# Union across all bench suites. See per-suite ``apps/`` for the actual +# imports. GROUPS: tuple[str, ...] = ( "bench", # starlette, uvicorn, a2wsgi, cheroot, gunicorn, granian, pytest-benchmark - "dev-http", # flask, pydantic, httpx + "bench-openapi", # fastapi, flask-openapi3, pydantic + "dev-http", # flask, httpx ) EXTRAS: tuple[str, ...] = ( "http", # h11 "http-fast", # httptools + "openapi", # msgspec (used by localpost.openapi) ) diff --git a/benchmarks/openapi/__init__.py b/benchmarks/openapi/__init__.py new file mode 100644 index 0000000..3b66b87 --- /dev/null +++ b/benchmarks/openapi/__init__.py @@ -0,0 +1,7 @@ +"""OpenAPI framework macro benchmark suite. + +Compares ``localpost.openapi`` against peer typed/OpenAPI Python web +frameworks (flask-openapi3, FastAPI). Sibling of ``benchmarks/http/``, +which measures HTTP server overhead. This suite measures *framework* +overhead — typed signatures, schema validation, response serialization. +""" diff --git a/benchmarks/openapi/apps/__init__.py b/benchmarks/openapi/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/openapi/apps/_cli.py b/benchmarks/openapi/apps/_cli.py new file mode 100644 index 0000000..b330221 --- /dev/null +++ b/benchmarks/openapi/apps/_cli.py @@ -0,0 +1,14 @@ +"""Tiny shared CLI helper for openapi-suite app modules. + +Each app reads ``--port`` (default 8000) and binds 127.0.0.1. +""" + +from __future__ import annotations + +import argparse + + +def parse_port(default: int = 8000) -> int: + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=default) + return p.parse_args().port diff --git a/benchmarks/openapi/apps/fastapi.py b/benchmarks/openapi/apps/fastapi.py new file mode 100644 index 0000000..7565e20 --- /dev/null +++ b/benchmarks/openapi/apps/fastapi.py @@ -0,0 +1,38 @@ +"""FastAPI served by Uvicorn (1 worker).""" + +from __future__ import annotations + +import sys + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import PlainTextResponse + +from benchmarks.openapi.apps._cli import parse_port + + +def build_app() -> FastAPI: + app = FastAPI(docs_url=None, redoc_url=None) + + @app.get("/ping", response_class=PlainTextResponse) + def ping() -> str: + return "pong" + + _ = ping + return app + + +def main() -> int: + port = parse_port() + uvicorn.run( + build_app(), + host="127.0.0.1", + port=port, + log_level="warning", + access_log=False, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/openapi/apps/flask_openapi.py b/benchmarks/openapi/apps/flask_openapi.py new file mode 100644 index 0000000..4edc44d --- /dev/null +++ b/benchmarks/openapi/apps/flask_openapi.py @@ -0,0 +1,55 @@ +"""flask-openapi3 served by Gunicorn (1 worker, gthread, 32 threads).""" + +from __future__ import annotations + +import sys + +from flask import Response +from flask_openapi import OpenAPI +from gunicorn.app.base import BaseApplication + +from benchmarks.openapi.apps._cli import parse_port + + +def build_app() -> OpenAPI: + app = OpenAPI(__name__) + + @app.get("/ping") + def ping() -> Response: + return Response(b"pong", mimetype="text/plain") + + _ = ping + return app + + +class _GunicornApp(BaseApplication): + def __init__(self, app, options: dict): + self._app = app + self._options = options + super().__init__() + + def load_config(self) -> None: + for k, v in self._options.items(): + self.cfg.set(k, v) + + def load(self): + return self._app + + +def main() -> int: + port = parse_port() + options = { + "bind": f"127.0.0.1:{port}", + "workers": 1, + "threads": 32, + "worker_class": "gthread", + "accesslog": None, + "errorlog": "-", + "loglevel": "warning", + } + _GunicornApp(build_app(), options).run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/openapi/apps/localpost_openapi.py b/benchmarks/openapi/apps/localpost_openapi.py new file mode 100644 index 0000000..646a077 --- /dev/null +++ b/benchmarks/openapi/apps/localpost_openapi.py @@ -0,0 +1,32 @@ +"""LocalPost — ``localpost.openapi.HttpApp`` on ``localpost.http`` (h11).""" + +from __future__ import annotations + +import sys + +from benchmarks.openapi.apps._cli import parse_port +from localpost import hosting, threadtools +from localpost.http import ServerConfig +from localpost.openapi import HttpApp + + +def build_app() -> HttpApp: + app = HttpApp() + + @app.get("/ping") + def ping() -> str: + return "pong" + + _ = ping + return app + + +def main() -> int: + threadtools.warmup(32) + app = build_app() + port = parse_port() + return hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=port, backend="h11"))) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/openapi/runner.py b/benchmarks/openapi/runner.py new file mode 100644 index 0000000..d3b388f --- /dev/null +++ b/benchmarks/openapi/runner.py @@ -0,0 +1,38 @@ +"""Macro OpenAPI-framework benchmark runner — thin entry point. + +Drives the same oha pipeline as ``benchmarks/http/runner.py`` but against +typed/OpenAPI frameworks (LocalPost, flask-openapi3, FastAPI) instead of +bare HTTP servers. + +See :mod:`benchmarks._core.cli` for the CLI flag surface. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from benchmarks._core.cli import entrypoint +from benchmarks.openapi.scenarios import SCENARIOS +from benchmarks.openapi.stacks import DIM_KEYS, GROUPS, STACKS + +REPO_ROOT = Path(__file__).resolve().parents[2] +RESULTS_DIR = Path(__file__).parent / "results" + + +def main() -> int: + return entrypoint( + scenarios=SCENARIOS, + stacks=STACKS, + groups=GROUPS, + apps_pkg="benchmarks.openapi.apps", + results_dir=RESULTS_DIR, + title="OpenAPI framework benchmark", + dim_keys=DIM_KEYS, + repo_root=REPO_ROOT, + description=__doc__, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/openapi/scenarios.py b/benchmarks/openapi/scenarios.py new file mode 100644 index 0000000..62daee6 --- /dev/null +++ b/benchmarks/openapi/scenarios.py @@ -0,0 +1,38 @@ +"""Shared scenario definitions for the OpenAPI framework bench. + +v1 starts with one scenario: ``plaintext`` (``GET /ping`` → ``"pong"``). +This is the calibration anchor — pure dispatch overhead, no schema, no +body. Subsequent steps add typed scenarios. + +Every app module under ``benchmarks/openapi/apps/`` implements these in +its framework's idiomatic style. The runner uses ``SCENARIOS`` to know +what to fire at each stack and how to verify the response shape. +""" + +from __future__ import annotations + +from typing import Final + +from benchmarks._core.types import Scenario + +__all__ = [ + "PING_BODY", + "SCENARIOS", + "Scenario", +] + + +PING_BODY: Final = b"pong" + + +SCENARIOS: Final[tuple[Scenario, ...]] = ( + Scenario( + name="plaintext", + method="GET", + path="/ping", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), +) diff --git a/benchmarks/openapi/stacks.py b/benchmarks/openapi/stacks.py new file mode 100644 index 0000000..376041d --- /dev/null +++ b/benchmarks/openapi/stacks.py @@ -0,0 +1,43 @@ +"""OpenAPI bench stack registry. + +Three stacks for v1 — one per peer framework, each on its naturally-fitting +server. Single-process by design so we measure framework overhead, not +worker multiplexing. + +* ``localpost_openapi`` — ``localpost.openapi.HttpApp`` on ``localpost.http`` (h11). +* ``flask_openapi`` — ``flask-openapi3`` on Gunicorn (1 worker, 32 threads). +* ``fastapi`` — FastAPI on Uvicorn (1 worker). + +Dim keys: ``framework``, ``server``, ``schema`` (the validation library +each framework drives — ``msgspec`` for LocalPost, ``pydantic`` for the +other two). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from benchmarks._core.types import Stack + +DIM_KEYS: Final[tuple[str, ...]] = ("framework", "server", "schema") + + +def _stack(name: str, *, framework: str, server: str, schema: str) -> Stack: + return Stack( + name=name, + dims={"framework": framework, "server": server, "schema": schema}, + ) + + +STACKS: Final[tuple[Stack, ...]] = ( + _stack("localpost_openapi", framework="localpost", server="lp-h11", schema="msgspec"), + _stack("flask_openapi", framework="flask-openapi3", server="gunicorn", schema="pydantic"), + _stack("fastapi", framework="fastapi", server="uvicorn", schema="pydantic"), +) + + +GROUPS: Final[dict[str, Callable[[Stack], bool]]] = { + "localpost": lambda s: s.dims["framework"] == "localpost", + "peers": lambda s: s.dims["framework"] != "localpost", +} diff --git a/justfile b/justfile index b24dc1a..aaff876 100755 --- a/justfile +++ b/justfile @@ -49,12 +49,12 @@ integration-tests: [doc("Set up all venvs in the bench matrix (.venv-bench/)")] bench-deps: - uv run python -m benchmarks.http._setup + uv run python -m benchmarks._setup [doc("Refresh lock and re-sync all bench-matrix venvs")] bench-deps-upgrade: uv lock --upgrade - uv run python -m benchmarks.http._setup + uv run python -m benchmarks._setup [doc("Run macro HTTP benchmarks (oha-driven, requires `brew install oha`)")] bench-http *args: @@ -73,6 +73,11 @@ bench-http-flask *args: bench-http-localpost *args: just bench-http --group localpost {{ args }} +[doc("Run macro OpenAPI-framework benchmarks (LocalPost vs FastAPI vs flask-openapi3)")] +bench-openapi *args: + uv run --group bench --group bench-openapi \ + python -m benchmarks.openapi.runner {{ args }} + [doc("Run micro-benchmarks (router, URI template) via pytest-benchmark")] bench-micro *args: uv run --group bench pytest benchmarks/micro/ \ diff --git a/pyproject.toml b/pyproject.toml index cc12260..416f1ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,16 @@ bench = [ # Micro (pytest-benchmark): regression smoke for router / URI template. "pytest-benchmark ~=5.2", ] +bench-openapi = [ + # Macro (OpenAPI framework comparison): peer typed-API frameworks. + "fastapi ~=0.136", + # flask-openapi3 was renamed to flask-openapi for the v5 line. v5 is + # currently rc; we accept pre-releases here (uv tool flag `--prerelease=allow` + # at install time, see benchmarks/_setup.py). + "flask-openapi >=5.0.0rc1,<6", + # Pydantic v2 is what both peers drive; LocalPost tests its msgspec path. + "pydantic ~=2.7", +] [tool.coverage.run] omit = ["tests/*"] diff --git a/uv.lock b/uv.lock index 7c2174c..4f7e8c3 100644 --- a/uv.lock +++ b/uv.lock @@ -424,6 +424,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "flask-openapi" +version = "5.0.0rc1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/e4/035bd95dc5652391e61a2a04d9e34ba022479c4c9919c946e3312580319a/flask_openapi-5.0.0rc1.tar.gz", hash = "sha256:003a8df8232d56a718f47cba1687f02f658e0835aa64a17d2773adbf385de173", size = 574445, upload-time = "2026-02-09T07:36:15.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/11/66f0d176f578c512a917b925eab152d518b9981f7face129773463af780a/flask_openapi-5.0.0rc1-py3-none-any.whl", hash = "sha256:5cd00c6c26435a9e981c67b9580d5e30e1ee7557386752340289b11774691c65", size = 42894, upload-time = "2026-02-09T07:36:16.363Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.74.0" @@ -780,6 +793,11 @@ bench = [ { name = "starlette" }, { name = "uvicorn" }, ] +bench-openapi = [ + { name = "fastapi" }, + { name = "flask-openapi" }, + { name = "pydantic" }, +] dev = [ { name = "icecream" }, { name = "structlog" }, @@ -851,6 +869,11 @@ bench = [ { name = "starlette", specifier = "~=1.0" }, { name = "uvicorn", specifier = "~=0.30" }, ] +bench-openapi = [ + { name = "fastapi", specifier = "~=0.136" }, + { name = "flask-openapi", specifier = ">=5.0.0rc1,<6" }, + { name = "pydantic", specifier = "~=2.7" }, +] dev = [ { name = "icecream", specifier = "~=2.1" }, { name = "structlog", specifier = "~=25.0" }, From 7d8108c39070bfc92f4e2d250be7a49c665855fe Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 09:10:57 +0400 Subject: [PATCH 196/286] feat(benchmarks): add path_param_typed and body_roundtrip scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scenarios now run across all openapi stacks: plaintext, typed path-int coercion, and the headline body-roundtrip — schema-driven decode, deterministic profile normalization, schema-driven encode. Each app implements the contract idiomatically: dataclass + return-type union for LocalPost, Pydantic model + response_model= for FastAPI, per-source Pydantic models (path/body) for flask-openapi v5. The body_roundtrip payload mirrors the http suite's profile_update so the two suites can be cross-referenced. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/openapi/apps/fastapi.py | 48 +++++++++++++++- benchmarks/openapi/apps/flask_openapi.py | 59 +++++++++++++++++++- benchmarks/openapi/apps/localpost_openapi.py | 51 ++++++++++++++++- benchmarks/openapi/scenarios.py | 42 ++++++++++++-- 4 files changed, 187 insertions(+), 13 deletions(-) diff --git a/benchmarks/openapi/apps/fastapi.py b/benchmarks/openapi/apps/fastapi.py index 7565e20..673a45c 100644 --- a/benchmarks/openapi/apps/fastapi.py +++ b/benchmarks/openapi/apps/fastapi.py @@ -1,16 +1,44 @@ -"""FastAPI served by Uvicorn (1 worker).""" +"""FastAPI served by Uvicorn (1 worker). + +Idiomatic FastAPI: Pydantic v2 models + ``response_model=...`` so the +typed response goes through Pydantic serialization (the realistic +deployment shape). +""" from __future__ import annotations import sys +from typing import Any import uvicorn from fastapi import FastAPI from fastapi.responses import PlainTextResponse +from pydantic import BaseModel from benchmarks.openapi.apps._cli import parse_port +class Item(BaseModel): + id: int + + +class ProfileUpdate(BaseModel): + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +class Profile(BaseModel): + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + def build_app() -> FastAPI: app = FastAPI(docs_url=None, redoc_url=None) @@ -18,7 +46,23 @@ def build_app() -> FastAPI: def ping() -> str: return "pong" - _ = ping + @app.get("/items/{item_id}", response_model=Item) + def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.post("/users/{user_id}/profile", response_model=Profile) + def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, update_profile) return app diff --git a/benchmarks/openapi/apps/flask_openapi.py b/benchmarks/openapi/apps/flask_openapi.py index 4edc44d..2821a0d 100644 --- a/benchmarks/openapi/apps/flask_openapi.py +++ b/benchmarks/openapi/apps/flask_openapi.py @@ -1,16 +1,52 @@ -"""flask-openapi3 served by Gunicorn (1 worker, gthread, 32 threads).""" +"""flask-openapi (v5) served by Gunicorn (1 worker, gthread, 32 threads). + +Idiomatic flask-openapi v5: declare per-source Pydantic models for path, +query, body — handler signatures take them as named parameters +(``path``, ``query``, ``body``). +""" from __future__ import annotations import sys +from typing import Any -from flask import Response +from flask import Response, jsonify from flask_openapi import OpenAPI from gunicorn.app.base import BaseApplication +from pydantic import BaseModel from benchmarks.openapi.apps._cli import parse_port +class ItemPath(BaseModel): + item_id: int + + +class ProfilePath(BaseModel): + user_id: str + + +class Item(BaseModel): + id: int + + +class ProfileUpdate(BaseModel): + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +class Profile(BaseModel): + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + def build_app() -> OpenAPI: app = OpenAPI(__name__) @@ -18,7 +54,24 @@ def build_app() -> OpenAPI: def ping() -> Response: return Response(b"pong", mimetype="text/plain") - _ = ping + @app.get("/items/") + def get_item(path: ItemPath) -> Response: + return jsonify(Item(id=path.item_id).model_dump()) + + @app.post("/users//profile") + def update_profile(path: ProfilePath, body: ProfileUpdate) -> Response: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + result = Profile( + user_id=path.user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + return jsonify(result.model_dump()) + + _ = (ping, get_item, update_profile) return app diff --git a/benchmarks/openapi/apps/localpost_openapi.py b/benchmarks/openapi/apps/localpost_openapi.py index 646a077..fbeb6a3 100644 --- a/benchmarks/openapi/apps/localpost_openapi.py +++ b/benchmarks/openapi/apps/localpost_openapi.py @@ -1,8 +1,15 @@ -"""LocalPost — ``localpost.openapi.HttpApp`` on ``localpost.http`` (h11).""" +"""LocalPost — ``localpost.openapi.HttpApp`` on ``localpost.http`` (h11). + +Idiomatic LocalPost: dataclass models + return-type annotations. Body +inputs auto-resolve via ``FromBody`` because the parameter type is a +dataclass. Path ``int`` coercion happens in ``FromPath._cast_str``. +""" from __future__ import annotations import sys +from dataclasses import dataclass +from typing import Any from benchmarks.openapi.apps._cli import parse_port from localpost import hosting, threadtools @@ -10,6 +17,30 @@ from localpost.openapi import HttpApp +@dataclass +class Item: + id: int + + +@dataclass +class ProfileUpdate: + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@dataclass +class Profile: + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + def build_app() -> HttpApp: app = HttpApp() @@ -17,7 +48,23 @@ def build_app() -> HttpApp: def ping() -> str: return "pong" - _ = ping + @app.get("/items/{item_id}") + def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.post("/users/{user_id}/profile") + def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, update_profile) return app diff --git a/benchmarks/openapi/scenarios.py b/benchmarks/openapi/scenarios.py index 62daee6..66ab7f7 100644 --- a/benchmarks/openapi/scenarios.py +++ b/benchmarks/openapi/scenarios.py @@ -1,12 +1,13 @@ """Shared scenario definitions for the OpenAPI framework bench. -v1 starts with one scenario: ``plaintext`` (``GET /ping`` → ``"pong"``). -This is the calibration anchor — pure dispatch overhead, no schema, no -body. Subsequent steps add typed scenarios. +Each scenario defines one wire contract every stack must implement — +identical request shape, identical response shape — so the comparison +measures the framework's typed-handler overhead, not differences in +business logic. -Every app module under ``benchmarks/openapi/apps/`` implements these in -its framework's idiomatic style. The runner uses ``SCENARIOS`` to know -what to fire at each stack and how to verify the response shape. +The body for ``body_roundtrip`` mirrors +``benchmarks/http/scenarios.py::_PROFILE_UPDATE_BODY`` so the http and +openapi suites can be cross-referenced. """ from __future__ import annotations @@ -17,6 +18,7 @@ __all__ = [ "PING_BODY", + "PROFILE_UPDATE_BODY", "SCENARIOS", "Scenario", ] @@ -24,6 +26,16 @@ PING_BODY: Final = b"pong" +# Same payload as ``benchmarks/http/scenarios.py``: each app must accept +# untrimmed strings, mixed-case email, duplicated/whitespaced tags, and +# return a normalized profile (trimmed, lower-cased, deduped, sorted, +# version+1). +PROFILE_UPDATE_BODY: Final = ( + b'{"display_name":" Alex Example ","email":"ALEX@example.COM","version":7,' + b'"tags":["Python","localpost","Python"," benchmarks "],' + b'"settings":{"theme":"dark","newsletter":true}}' +) + SCENARIOS: Final[tuple[Scenario, ...]] = ( Scenario( @@ -35,4 +47,22 @@ expected_status=200, concurrency=64, ), + Scenario( + name="path_param_typed", + method="GET", + path="/items/42", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), + Scenario( + name="body_roundtrip", + method="POST", + path="/users/42/profile", + body=PROFILE_UPDATE_BODY, + content_type="application/json", + expected_status=200, + concurrency=32, + ), ) From bc055eb0d48cc36528da88077b2f66196f97464a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 09:16:53 +0400 Subject: [PATCH 197/286] feat(benchmarks): add query_validation and validation_failure scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The openapi suite now runs five scenarios. validation_failure exercises the error path: a deliberately malformed body that every framework rejects with 422. To support non-2xx expected codes, Cell.status_2xx is renamed to status_expected and accounting now buckets by exact match against the scenario's expected_status. Markdown and HTML renderers updated; the http suite is unaffected because all its scenarios still expect 200. LocalPost's body resolver returns BadRequest (400) on schema failure while FastAPI and flask-openapi return 422 — the apples-to-apples comparison would otherwise be skewed. The localpost_openapi bench app attaches a small middleware that remaps 400 → 422 on the profile endpoint; framework defaults are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/_core/cli.py | 2 +- benchmarks/_core/render.py | 10 ++--- benchmarks/_core/runner.py | 15 ++++--- benchmarks/_core/types.py | 6 ++- benchmarks/openapi/apps/fastapi.py | 17 ++++++-- benchmarks/openapi/apps/flask_openapi.py | 21 ++++++++- benchmarks/openapi/apps/localpost_openapi.py | 46 +++++++++++++++++--- benchmarks/openapi/scenarios.py | 23 ++++++++++ 8 files changed, 116 insertions(+), 24 deletions(-) diff --git a/benchmarks/_core/cli.py b/benchmarks/_core/cli.py index a9f1908..c9c5614 100644 --- a/benchmarks/_core/cli.py +++ b/benchmarks/_core/cli.py @@ -209,7 +209,7 @@ def run( if cell is not None: print( f" rps={cell.rps:,.0f} p50={cell.p50_ms:.2f}ms p99={cell.p99_ms:.2f}ms " - f"({cell.status_2xx} 2xx / {cell.status_other} other)" + f"({cell.status_expected} expected / {cell.status_other} other)" ) cells.append(cell) diff --git a/benchmarks/_core/render.py b/benchmarks/_core/render.py index ad4a35b..88eb573 100644 --- a/benchmarks/_core/render.py +++ b/benchmarks/_core/render.py @@ -44,11 +44,11 @@ def render_markdown(report: RunReport) -> str: for scenario_name, cells in cells_by_scenario.items(): cells.sort(key=lambda c: c.rps, reverse=True) out.append(f"## {scenario_name}\n") - out.append("| Stack | RPS | p50 (ms) | p90 (ms) | p99 (ms) | 2xx | non-2xx |") + out.append("| Stack | RPS | p50 (ms) | p90 (ms) | p99 (ms) | expected | other |") out.append("|---|---:|---:|---:|---:|---:|---:|") out.extend( f"| `{c.stack}` | {c.rps:,.0f} | {c.p50_ms:.2f} | {c.p90_ms:.2f} " - f"| {c.p99_ms:.2f} | {c.status_2xx:,} | {c.status_other:,} |" + f"| {c.p99_ms:.2f} | {c.status_expected:,} | {c.status_other:,} |" for c in cells ) out.append("") @@ -180,7 +180,7 @@ def render_html(report: dict[str, Any]) -> str: c.stack, ...dimKeys.map(k => dimVal(c, k)), Math.round(c.rps), +c.p50_ms.toFixed(2), +c.p90_ms.toFixed(2), +c.p99_ms.toFixed(2), - c.status_2xx, c.status_other, + c.status_expected, c.status_other, ]); }} @@ -191,8 +191,8 @@ def render_html(report: dict[str, Any]) -> str: {{ name: 'p50 (ms)' }}, {{ name: 'p90 (ms)' }}, {{ name: 'p99 (ms)' }}, - {{ name: '2xx' }}, - {{ name: 'non-2xx' }}, + {{ name: 'expected' }}, + {{ name: 'other' }}, ]; for (const scenario of scenarios) {{ diff --git a/benchmarks/_core/runner.py b/benchmarks/_core/runner.py index 545b87f..ffab1b1 100644 --- a/benchmarks/_core/runner.py +++ b/benchmarks/_core/runner.py @@ -94,13 +94,14 @@ def _as_float(v: float | int | str | None, default: float = 0.0) -> float: return default if v is None else float(v) -def parse_oha(raw: dict) -> dict: +def parse_oha(raw: dict, expected_status: int) -> dict: summary = raw.get("summary", {}) pct = raw.get("latencyPercentiles", {}) status = raw.get("statusCodeDistribution", {}) - s2xx = sum(v for k, v in status.items() if k.startswith("2")) - sother = sum(v for k, v in status.items() if not k.startswith("2")) - total = s2xx + sother + expected_key = str(expected_status) + s_expected = sum(v for k, v in status.items() if k == expected_key) + s_other = sum(v for k, v in status.items() if k != expected_key) + total = s_expected + s_other return { "rps": _as_float(summary.get("requestsPerSec")), "p50_ms": _as_float(pct.get("p50")) * 1000, @@ -108,8 +109,8 @@ def parse_oha(raw: dict) -> dict: "p99_ms": _as_float(pct.get("p99")) * 1000, "total_requests": total, "success_rate": _as_float(summary.get("successRate")), - "status_2xx": s2xx, - "status_other": sother, + "status_expected": s_expected, + "status_other": s_other, } @@ -129,7 +130,7 @@ def run_cell( print(f" [{stack.name}/{scenario.name}] FAILED: not ready. stderr={stderr[-400:]!r}", file=sys.stderr) return None raw = run_oha(scenario, port, duration_s) - parsed = parse_oha(raw) + parsed = parse_oha(raw, scenario.expected_status) return Cell( stack=stack.name, scenario=scenario.name, diff --git a/benchmarks/_core/types.py b/benchmarks/_core/types.py index 85014d8..d5a30e3 100644 --- a/benchmarks/_core/types.py +++ b/benchmarks/_core/types.py @@ -61,8 +61,12 @@ class Cell: p99_ms: float total_requests: int success_rate: float - status_2xx: int + status_expected: int + """Responses matching :attr:`Scenario.expected_status` exactly.""" + status_other: int + """Everything else (wrong status code, no response, etc.).""" + dims: dict[str, str] = field(default_factory=dict) tags: list[str] = field(default_factory=list) diff --git a/benchmarks/openapi/apps/fastapi.py b/benchmarks/openapi/apps/fastapi.py index 673a45c..27d2426 100644 --- a/benchmarks/openapi/apps/fastapi.py +++ b/benchmarks/openapi/apps/fastapi.py @@ -1,8 +1,9 @@ """FastAPI served by Uvicorn (1 worker). Idiomatic FastAPI: Pydantic v2 models + ``response_model=...`` so the -typed response goes through Pydantic serialization (the realistic -deployment shape). +typed response goes through Pydantic serialization. Validation failures +return 422 by default — what the bench's ``validation_failure`` +scenario expects. """ from __future__ import annotations @@ -22,6 +23,12 @@ class Item(BaseModel): id: int +class SearchResult(BaseModel): + q: str + limit: int + offset: int + + class ProfileUpdate(BaseModel): display_name: str email: str @@ -50,6 +57,10 @@ def ping() -> str: def get_item(item_id: int) -> Item: return Item(id=item_id) + @app.get("/search", response_model=SearchResult) + def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + @app.post("/users/{user_id}/profile", response_model=Profile) def update_profile(user_id: str, body: ProfileUpdate) -> Profile: tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) @@ -62,7 +73,7 @@ def update_profile(user_id: str, body: ProfileUpdate) -> Profile: settings=body.settings, ) - _ = (ping, get_item, update_profile) + _ = (ping, get_item, search, update_profile) return app diff --git a/benchmarks/openapi/apps/flask_openapi.py b/benchmarks/openapi/apps/flask_openapi.py index 2821a0d..545cde5 100644 --- a/benchmarks/openapi/apps/flask_openapi.py +++ b/benchmarks/openapi/apps/flask_openapi.py @@ -2,7 +2,8 @@ Idiomatic flask-openapi v5: declare per-source Pydantic models for path, query, body — handler signatures take them as named parameters -(``path``, ``query``, ``body``). +(``path``, ``query``, ``body``). Validation failures return 422 by +default (configured via ``validation_error_status``). """ from __future__ import annotations @@ -26,10 +27,22 @@ class ProfilePath(BaseModel): user_id: str +class SearchQuery(BaseModel): + q: str + limit: int = 20 + offset: int = 0 + + class Item(BaseModel): id: int +class SearchResult(BaseModel): + q: str + limit: int + offset: int + + class ProfileUpdate(BaseModel): display_name: str email: str @@ -58,6 +71,10 @@ def ping() -> Response: def get_item(path: ItemPath) -> Response: return jsonify(Item(id=path.item_id).model_dump()) + @app.get("/search") + def search(query: SearchQuery) -> Response: + return jsonify(SearchResult(q=query.q, limit=query.limit, offset=query.offset).model_dump()) + @app.post("/users//profile") def update_profile(path: ProfilePath, body: ProfileUpdate) -> Response: tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) @@ -71,7 +88,7 @@ def update_profile(path: ProfilePath, body: ProfileUpdate) -> Response: ) return jsonify(result.model_dump()) - _ = (ping, get_item, update_profile) + _ = (ping, get_item, search, update_profile) return app diff --git a/benchmarks/openapi/apps/localpost_openapi.py b/benchmarks/openapi/apps/localpost_openapi.py index fbeb6a3..4dfd1b0 100644 --- a/benchmarks/openapi/apps/localpost_openapi.py +++ b/benchmarks/openapi/apps/localpost_openapi.py @@ -2,7 +2,13 @@ Idiomatic LocalPost: dataclass models + return-type annotations. Body inputs auto-resolve via ``FromBody`` because the parameter type is a -dataclass. Path ``int`` coercion happens in ``FromPath._cast_str``. +dataclass. Path/query ``int`` coercion happens in the resolvers' +``_cast_str``. + +LocalPost's body resolver returns ``BadRequest`` (400) on schema +failure; FastAPI and flask-openapi return 422. To keep the bench's +``validation_failure`` scenario apples-to-apples we attach a tiny +middleware that remaps 400 → 422 on the profile endpoint. """ from __future__ import annotations @@ -13,8 +19,15 @@ from benchmarks.openapi.apps._cli import parse_port from localpost import hosting, threadtools -from localpost.http import ServerConfig -from localpost.openapi import HttpApp +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.openapi import ( + ApiOperation, + BadRequest, + HttpApp, + OpResult, + UnprocessableEntity, + op_middleware, +) @dataclass @@ -22,6 +35,13 @@ class Item: id: int +@dataclass +class SearchResult: + q: str + limit: int + offset: int + + @dataclass class ProfileUpdate: display_name: str @@ -41,6 +61,18 @@ class Profile: settings: dict[str, Any] +@op_middleware +def remap_validation_status( + ctx: HTTPReqCtx, + call_next: ApiOperation, +) -> UnprocessableEntity[str] | OpResult: + result = call_next(ctx) + if isinstance(result, BadRequest): + body = result.body if isinstance(result.body, str) else str(result.body) + return UnprocessableEntity(body) + return result + + def build_app() -> HttpApp: app = HttpApp() @@ -52,7 +84,11 @@ def ping() -> str: def get_item(item_id: int) -> Item: return Item(id=item_id) - @app.post("/users/{user_id}/profile") + @app.get("/search") + def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", middlewares=[remap_validation_status]) def update_profile(user_id: str, body: ProfileUpdate) -> Profile: tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) return Profile( @@ -64,7 +100,7 @@ def update_profile(user_id: str, body: ProfileUpdate) -> Profile: settings=body.settings, ) - _ = (ping, get_item, update_profile) + _ = (ping, get_item, search, update_profile) return app diff --git a/benchmarks/openapi/scenarios.py b/benchmarks/openapi/scenarios.py index 66ab7f7..1cafbed 100644 --- a/benchmarks/openapi/scenarios.py +++ b/benchmarks/openapi/scenarios.py @@ -17,6 +17,7 @@ from benchmarks._core.types import Scenario __all__ = [ + "INVALID_PROFILE_BODY", "PING_BODY", "PROFILE_UPDATE_BODY", "SCENARIOS", @@ -36,6 +37,10 @@ b'"settings":{"theme":"dark","newsletter":true}}' ) +# Missing required fields — every framework rejects with a 422 (LocalPost's +# default 400 is remapped to 422 by a small middleware in the bench app). +INVALID_PROFILE_BODY: Final = b'{"display_name":"only this field"}' + SCENARIOS: Final[tuple[Scenario, ...]] = ( Scenario( @@ -56,6 +61,15 @@ expected_status=200, concurrency=64, ), + Scenario( + name="query_validation", + method="GET", + path="/search?q=hello&limit=10&offset=0", + body=None, + content_type=None, + expected_status=200, + concurrency=64, + ), Scenario( name="body_roundtrip", method="POST", @@ -65,4 +79,13 @@ expected_status=200, concurrency=32, ), + Scenario( + name="validation_failure", + method="POST", + path="/users/42/profile", + body=INVALID_PROFILE_BODY, + content_type="application/json", + expected_status=422, + concurrency=32, + ), ) From 28a43dd943d239629b876dd0f7301c1aab667431 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 09:20:43 +0400 Subject: [PATCH 198/286] docs(benchmarks): add per-suite READMEs, slim top-level to an index http/ and openapi/ each get their own README focused on what they measure and how to drive them. The top-level benchmarks/README.md is now a real index that lists the three suites, documents the shared CLI surface from _core/cli.py, and keeps the cross-cutting caveats and the micro-suite section. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/README.md | 155 ++++++++++++++--------------------- benchmarks/http/README.md | 98 ++++++++++++++++++++++ benchmarks/openapi/README.md | 100 ++++++++++++++++++++++ 3 files changed, 260 insertions(+), 93 deletions(-) create mode 100644 benchmarks/http/README.md create mode 100644 benchmarks/openapi/README.md diff --git a/benchmarks/README.md b/benchmarks/README.md index 2e2f53e..252c745 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,96 +1,72 @@ # LocalPost benchmarks -Two independent suites: - -- **`http/`** — macro HTTP load benchmarks. Compare LocalPost against peer - servers (Gunicorn, Cheroot, Granian, Uvicorn) on a fixed Flask / Starlette - workload using [`oha`](https://github.com/hatoo/oha) as the load generator. - Goal: publishable comparison numbers. -- **`micro/`** — `pytest-benchmark` micro-benchmarks for `URITemplate` and - `Router`. Goal: catch perf regressions in the deterministic core. - -Both are kept out of the default test run (`just tests`). +Three independent suites: + +- **[`http/`](http/README.md)** — macro HTTP load benchmarks. Compare + LocalPost's HTTP server against peer servers (Gunicorn, Cheroot, + Granian, Uvicorn) on a fixed Flask / Starlette workload using + [`oha`](https://github.com/hatoo/oha) as the load generator. Measures + **HTTP server** overhead. +- **[`openapi/`](openapi/README.md)** — macro framework benchmarks. + Compare `localpost.openapi` against peer typed/OpenAPI frameworks + (FastAPI, flask-openapi v5) on a shared workload — typed handlers, + schema validation, response serialization. Measures **framework** + overhead. +- **[`micro/`](micro/)** — `pytest-benchmark` micro-benchmarks for + `URITemplate` and `Router`. Catches perf regressions in the + deterministic core. + +The two macro suites share the runner, types, filter language, and +report writers via [`_core/`](_core/). Each defines its own scenarios, +stacks, and `apps/`. + +All three are kept out of the default test run (`just tests`). ## Quick start ```bash -# macro (HTTP load) — 8 stacks × 4 scenarios at 20s each = ~11 minutes -brew install oha # one-time prereq -just bench-http +brew install oha # one-time prereq for macro suites +just bench-deps # provision .venv-bench// for every interpreter -# faster sanity sweep +# Macro HTTP server bench +just bench-http just bench-http --duration 5 --stacks localpost_h11,flask_gunicorn -# micro (pytest-benchmark) +# Macro OpenAPI framework bench +just bench-openapi +just bench-openapi --duration 5 --stacks fastapi,localpost_openapi + +# Micro (pytest-benchmark) just bench-micro ``` -## What's compared (macro) - -Three "app types", each implementing the same four scenarios: - -| App type | Stacks | -|------------|-------------------------------------------------------------------------------------------| -| Native | `localpost_h11`, `localpost_httptools`, plus their `_s4` (`SO_REUSEPORT`) and `_acceptor_s4` variants; `localpost_httptools_inline` (no pool) and its multi-selector variants | -| WSGI/Flask | `localpost_wsgi`, `localpost_flask`, `flask_cheroot`, `flask_gunicorn`, `flask_granian` | -| ASGI | `starlette_uvicorn`, `starlette_granian` | - -Scenarios — defined in [`http/scenarios.py`](http/scenarios.py): - -| Scenario | Method | Path | Concurrency | -|------------------|--------|-----------------------------|-------------| -| `plaintext` | GET | `/ping` | 64 | -| `path_param` | GET | `/hello/{name}` | 64 | -| `json_post` | POST | `/echo` | 32 | -| `profile_update` | POST | `/users/{user_id}/profile` | 32 | - -`profile_update` is the more application-shaped case: route parameter, JSON -request parsing, deterministic profile normalization, three short simulated I/O -waits, and JSON response serialization. - -All servers are configured to be roughly comparable — single process, sized -worker pool (`max_concurrency=32` for LocalPost, `--threads 32` for -Gunicorn/Granian-WSGI, etc.). We're measuring server overhead, not the -multiplicative effect of more processes. +Per-suite docs: [`http/README.md`](http/README.md), +[`openapi/README.md`](openapi/README.md). -### How a run works +## Shared CLI surface -`benchmarks/http/runner.py`, for each (stack, scenario): -1. `subprocess.Popen` the stack as `python -m benchmarks.http.apps.`. -2. Poll `127.0.0.1:` with TCP connect until ready (≤ 10 s). -3. Fire `oha --no-tui -j -z s -c ...`. -4. Parse JSON → RPS, p50/p90/p99, status histogram. -5. SIGTERM the stack, wait, move on. +Both macro runners share these flags via +[`benchmarks/_core/cli.py`](_core/cli.py): -Output: -- `http/results/latest.json` — raw cells (re-parseable). -- `http/results/RESULTS.md` — markdown summary, one table per scenario. +- `--duration N` — seconds per cell (default 20). +- `--stacks a,b` — verbatim stack name list (escape hatch). +- `--group ` — named preset (e.g. `quick`, `localpost`). +- `--filter key=val` — dim filter (repeatable, AND together). + Supports glob (`backend=lp-*`), comma (`app=flask,starlette`), + negation (`app!=starlette`). +- `--scenarios a,b` — restrict to specific scenarios. +- `--pythons name=path,...` — override the bench Python matrix + (default: every entry in + [`benchmarks._core.pythons.PYTHONS`](_core/pythons.py)). -### Caveats - -- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel - scheduling, other processes. Re-run if a cell looks anomalous; consider - running with everything else closed. The relative ordering on the *same* - run is what matters; absolute RPS will shift run-to-run. -- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP - throughput regression gates. Run macro benchmarks locally / on a - dedicated machine. -- **Single process by design.** All servers configured `workers=1` to - isolate the server-layer overhead. Real deployments multiply by N - workers; the relative ordering still holds. - -## What's compared (micro) +## Micro suite `pytest-benchmark`-driven, runs in-process: - `bench_uri_template.py` — `URITemplate.parse`, `.match` (hit / miss / multi-var). -- `bench_router.py` — `Routes.build`, `Router.wsgi` dispatch (literal hit, - parameterised hit, 404, 405). - -Use `--benchmark-compare` / `--benchmark-autosave` to compare against a -saved baseline. See [pytest-benchmark -docs](https://pytest-benchmark.readthedocs.io/en/latest/comparing.html). +- `bench_router.py` — `Routes.build`, `Router.wsgi` dispatch (literal + hit, parameterised hit, 404, 405). ```bash # Save a baseline before changing code @@ -102,24 +78,17 @@ just bench-micro --benchmark-compare --benchmark-compare-fail=mean:10% Micro-benchmarks are **not** wired to a CI check. If you want CI-stable regression gates later, swap `pytest-benchmark` for -[`pytest-codspeed`](https://docs.codspeed.io/) — it runs the same fixtures -under deterministic instrumentation on Codspeed's infra. No code changes -beyond the dependency. - -## Adding a new stack / scenario - -- **Stack:** drop a `python -m`-runnable module under `http/apps/` that - takes `--port`, binds 127.0.0.1, and serves the routes from - `scenarios.py`. Add the module name to `STACKS` in `http/runner.py`. -- **Scenario:** add a `Scenario(...)` to `SCENARIOS` in `scenarios.py`, - then implement the route in every app (typically by extending - `_flask_app.py` and `_starlette_app.py`). - -## Roadmap (not committed) - -- A FastAPI app variant — same engine as Starlette, but pulls in Pydantic - overhead. Useful as a third app type. -- Streaming + keep-alive scenarios. -- Plotly-rendered HTML chart from `latest.json`. -- A scheduler micro-bench suite (trigger composition, `ScheduledTask` - setup/teardown). +[`pytest-codspeed`](https://docs.codspeed.io/) — it runs the same +fixtures under deterministic instrumentation on Codspeed's infra. No +code changes beyond the dependency. + +## Caveats (macro) + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous. The + relative ordering on the *same* run is what matters; absolute RPS + will shift run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally. +- **Single process by design.** Real deployments multiply by N + workers; the relative ordering still holds. diff --git a/benchmarks/http/README.md b/benchmarks/http/README.md new file mode 100644 index 0000000..d4f11a0 --- /dev/null +++ b/benchmarks/http/README.md @@ -0,0 +1,98 @@ +# LocalPost HTTP server benchmark + +Macro HTTP load benchmark — compare LocalPost's HTTP server against peer +servers (Gunicorn, Cheroot, Granian, Uvicorn) on a fixed Flask / +Starlette workload using [`oha`](https://github.com/hatoo/oha) as the +load generator. + +This is the **server** bench. The sibling +[`benchmarks/openapi/`](../openapi/README.md) measures **framework** +overhead (typed handlers, schema validation) — different question, +different suite. + +## Quick start + +```bash +brew install oha # one-time prereq +just bench-deps # provision .venv-bench// for every interpreter + +# 8 stacks × 4 scenarios at 20s each = ~11 minutes +just bench-http + +# faster sanity sweep +just bench-http --duration 5 --stacks localpost_h11,flask_gunicorn + +# named subsets +just bench-http --group quick # ~4 stacks, fast PR check +just bench-http --filter app=flask # all Flask servers +just bench-http --filter 'backend=lp-*' --filter selectors=1 +``` + +Output lands in `benchmarks/http/results///`: + +- `results.json` — raw cells (re-parseable). +- `RESULTS.md` — markdown summary, one table per scenario. +- `RESULTS.html` — interactive view with sortable tables and dim filters. + +## What's compared + +Three "app types", each implementing the same four scenarios: + +| App type | Stacks | +|------------|-------------------------------------------------------------------------------------------| +| Native | `localpost_h11`, `localpost_httptools`, plus their `_s4` (`SO_REUSEPORT`) and `_acceptor_s4` variants; `localpost_httptools_inline` (no pool) and its multi-selector variants | +| WSGI/Flask | `localpost_wsgi`, `localpost_flask`, `flask_cheroot`, `flask_gunicorn`, `flask_granian` | +| ASGI | `starlette_uvicorn`, `starlette_granian` | + +Scenarios — defined in [`scenarios.py`](scenarios.py): + +| Scenario | Method | Path | Concurrency | +|------------------|--------|-----------------------------|-------------| +| `plaintext` | GET | `/ping` | 64 | +| `path_param` | GET | `/hello/{name}` | 64 | +| `json_post` | POST | `/echo` | 32 | +| `profile_update` | POST | `/users/{user_id}/profile` | 32 | + +`profile_update` is the more application-shaped case: route parameter, +JSON request parsing, deterministic profile normalization, three short +simulated I/O waits, and JSON response serialization. + +All servers are configured to be roughly comparable — single process, +sized worker pool (`max_concurrency=32` for LocalPost, `--threads 32` +for Gunicorn/Granian-WSGI, etc.). We're measuring server overhead, not +the multiplicative effect of more processes. + +## How a run works + +For each (python, stack, scenario) cell, the shared runner +([`benchmarks/_core/cli.py`](../_core/cli.py)) does: + +1. `subprocess.Popen` the stack as `python -m benchmarks.http.apps.`. +2. Poll `127.0.0.1:` with TCP connect until ready (≤ 10 s). +3. Fire `oha --no-tui -j -z s -c ...`. +4. Parse JSON → RPS, p50/p90/p99, status histogram. +5. SIGTERM the stack, wait, move on. + +## Adding a new stack / scenario + +- **Stack:** drop a `python -m`-runnable module under [`apps/`](apps/) + that takes `--port`, binds 127.0.0.1, and serves the routes from + `scenarios.py`. Add a `Stack(...)` to `STACKS` in `stacks.py`. +- **Scenario:** add a `Scenario(...)` to `SCENARIOS` in `scenarios.py`, + then implement the route in every app (typically by extending + [`apps/_flask_app.py`](apps/_flask_app.py) and + [`apps/_starlette_app.py`](apps/_starlette_app.py)). + +## Caveats + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous; + consider running with everything else closed. The relative ordering + on the *same* run is what matters; absolute RPS will shift + run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally / on a + dedicated machine. +- **Single process by design.** All servers configured `workers=1` to + isolate the server-layer overhead. Real deployments multiply by N + workers; the relative ordering still holds. diff --git a/benchmarks/openapi/README.md b/benchmarks/openapi/README.md new file mode 100644 index 0000000..bb8f3d7 --- /dev/null +++ b/benchmarks/openapi/README.md @@ -0,0 +1,100 @@ +# LocalPost OpenAPI framework benchmark + +Compares `localpost.openapi` against peer typed/OpenAPI Python web +frameworks under one fixed workload, single-process, single-host. + +This is the **framework** bench. The sibling +[`benchmarks/http/`](../http/README.md) measures **server** overhead +with a thin Flask/Starlette app on top — different question, different +suite. + +## Quick start + +```bash +brew install oha # one-time prereq +just bench-deps # provision .venv-bench// for every interpreter +just bench-openapi +``` + +```bash +# Faster sanity sweep +just bench-openapi --duration 5 + +# Restrict to one stack +just bench-openapi --stacks fastapi + +# Restrict to one scenario +just bench-openapi --scenarios body_roundtrip + +# Filter by dim +just bench-openapi --filter framework=localpost +just bench-openapi --filter 'framework=fastapi,localpost' --filter schema=pydantic +``` + +Output lands in `benchmarks/openapi/results///`: + +- `results.json` — raw cells (re-parseable). +- `RESULTS.md` — markdown summary, one table per scenario. +- `RESULTS.html` — interactive view with sortable tables and dim filters. + +## What's compared + +Three stacks, one peer framework each: + +| Stack ID | Framework | Server | Schema | +|---------------------|------------------------|------------------------------|----------| +| `localpost_openapi` | `localpost.openapi` | `localpost.http` (h11) | msgspec | +| `flask_openapi` | `flask-openapi` (v5) | gunicorn sync (32 threads) | pydantic | +| `fastapi` | FastAPI | uvicorn (1 worker) | pydantic | + +Single-process by design — we measure framework-layer overhead, not the +multiplicative effect of more workers. All servers configured to be +roughly comparable (`max_concurrency=32` for LocalPost, +`--threads 32` for Gunicorn, etc.). + +`flask-openapi3` was renamed to `flask-openapi` for the v5 line; the +`bench-openapi` dependency group pins `flask-openapi >=5.0.0rc1`. + +## Scenarios + +Defined in [`scenarios.py`](scenarios.py). Each app implements identical +wire contracts: + +| Scenario | Method · Path | Expected | What it exercises | +|----------------------|--------------------------------------------|---------:|---------------------------------------------------| +| `plaintext` | `GET /ping` | 200 | Pure dispatch — calibration anchor. | +| `path_param_typed` | `GET /items/42` (`item_id: int`) | 200 | Path coercion. | +| `query_validation` | `GET /search?q=hello&limit=10&offset=0` | 200 | Query parse + type coercion. | +| `body_roundtrip` | `POST /users/42/profile` JSON in/out | 200 | Schema-driven decode → normalize → encode (headline). | +| `validation_failure` | `POST /users/42/profile` (missing fields) | 422 | Error-path throughput. | + +Each app defines its own model classes idiomatically — dataclasses for +LocalPost, Pydantic v2 for FastAPI and flask-openapi. Same fields, same +types; we're comparing framework overhead on equivalent work, not +business logic. + +For `validation_failure`: LocalPost's body resolver returns +`BadRequest` (400) by default, while FastAPI and flask-openapi v5 +return 422. To keep the comparison apples-to-apples, the +`localpost_openapi` bench app attaches a small `op_middleware` that +remaps 400 → 422 on the profile endpoint. The framework default is +unchanged. + +## Adding a new stack / scenario + +- **Stack:** drop a `python -m`-runnable module under `apps/` that + takes `--port`, binds 127.0.0.1, and serves the routes from + `scenarios.py`. Add a `Stack(...)` to `STACKS` in `stacks.py`. +- **Scenario:** add a `Scenario(...)` to `SCENARIOS` in `scenarios.py`, + then implement the route in every app. + +## Caveats + +- **Single-host noise.** Numbers are sensitive to CPU thermals, kernel + scheduling, other processes. Re-run if a cell looks anomalous. The + relative ordering on the *same* run is what matters; absolute RPS + will shift run-to-run. +- **Not for CI gates.** GitHub Actions runners are too noisy for HTTP + throughput regression gates. Run macro benchmarks locally. +- **Single process by design.** Real deployments multiply by N + workers; the relative ordering still holds. From fe65b97b6d3a8b4524f196d13dde7323ee979a9c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 10:38:03 +0400 Subject: [PATCH 199/286] refactor(openapi): extract type-level core into _operation_core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoists the runtime-agnostic pieces of operation.py — signature parsing, return-type → response-shape extraction, OpenAPI doc helpers, response encoding, SSE detection — into a private _operation_core module. The sync runtime closure (Operation._run / _run_core / _write_response / _stream_sse) stays in operation.py. Prep work for HttpAsyncApp: the async runtime will reuse all of this type-level wiring without duplication. _pick_factory now takes the ctx-passthrough types as a parameter (sync passes (HTTPReqCtx,), async will pass (AsyncHTTPReqCtx,)). SSE-payload detection is split into sync (is_sse_payload) and async (is_async_sse_payload) variants. No behaviour change; all 147 openapi tests pass. --- localpost/openapi/_operation_core.py | 443 +++++++++++++++++++++++++++ localpost/openapi/middleware.py | 13 +- localpost/openapi/operation.py | 412 +++---------------------- 3 files changed, 490 insertions(+), 378 deletions(-) create mode 100644 localpost/openapi/_operation_core.py diff --git a/localpost/openapi/_operation_core.py b/localpost/openapi/_operation_core.py new file mode 100644 index 0000000..9ca4575 --- /dev/null +++ b/localpost/openapi/_operation_core.py @@ -0,0 +1,443 @@ +"""Type-level core shared by sync :class:`Operation` and async :class:`AsyncOperation`. + +Everything here is independent of the runtime invocation style — signature +parsing, return-type inference, OpenAPI doc contribution helpers, response +encoding. The runtime closures (``_run`` / ``_run_core`` / ``_write_response`` +/ ``_stream_sse``) live in the per-flavour ``operation.py`` files. +""" + +from __future__ import annotations + +import inspect +from collections.abc import ( + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Callable, + Generator, + Iterable, + Iterator, + Mapping, +) +from dataclasses import dataclass, replace +from http import HTTPMethod +from types import UnionType +from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints + +from localpost.http._types import Response as _Response +from localpost.openapi import spec as openapi_spec +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.resolvers import ( + ArgResolver, + ArgResolverFactory, + FromBody, + FromPath, + FromQuery, + is_body_type, +) +from localpost.openapi.results import EventStreamResult, NoContent, OpResult +from localpost.openapi.schemas import SchemaRegistry +from localpost.openapi.sse import EventStream + +__all__ = [ + "ResponseShape", + "build_arg_resolvers", + "extract_response_shapes", + "build_responses", + "build_http_response", + "encode_body", + "iter_response_headers", + "qualname", + "operation_id", + "is_sse_payload", + "is_async_sse_payload", + "is_sync_iterable", + "SSE_RESPONSE_HEADERS", + "resolve_ctx", +] + + +# Sentinel — handed back as an :class:`ArgResolver` for parameters explicitly +# typed as :class:`HTTPReqCtx` (or its async sibling). The actual ctx object +# is passed in at request time, sync and async runtimes share the closure. +def resolve_ctx(ctx: Any) -> Any: + return ctx + + +@dataclass(frozen=True, slots=True) +class ResponseShape: + """One branch of the return type — a (status, body type, content type).""" + + status_code: int + description: str + body_type: Any | None + content_type: str = "application/json" + + +# --- Signature parsing --------------------------------------------------- + + +def qualname(fn: Callable[..., Any]) -> str: + return getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + + +def build_arg_resolvers( + fn: Callable[..., Any], + *, + path_var_names: set[str] | None = None, + adapters: AdapterRegistry | None = None, + exclude: set[str] | None = None, + ctx_types: tuple[type, ...] = (), +) -> tuple[ + inspect.Signature, + list[tuple[str, ArgResolver]], + list[tuple[str, inspect.Parameter, ArgResolverFactory | None]], +]: + """Inspect ``fn`` and return ``(resolved_signature, runtime_resolvers, + spec_factories)`` for use by sync :class:`Operation`, async + :class:`AsyncOperation`, or the ``op_middleware`` decorators. + + Path-template binding is *not* validated here — pass ``path_var_names`` + so :class:`FromPath` is auto-picked for matching parameter names; the + caller is responsible for any "unbound var" error. + + ``adapters`` flows into auto-picked / un-bound :class:`FromBody` + factories so body decoding hits the per-app type adapter registry. If + ``None``, falls back to :func:`default_registry`. + + ``exclude`` skips parameters by name — used by ``op_middleware`` to drop + the ``call_next`` parameter from resolver inspection. + + ``ctx_types`` lists the request-context types that should be treated + as the framework pass-through (no factory, no doc contribution). Sync + callers pass ``(HTTPReqCtx,)``; async callers pass + ``(AsyncHTTPReqCtx,)``. Defaults to ``()`` so a parameter must opt in + to the pass-through via the caller-supplied tuple. + """ + path_vars = path_var_names or set() + registry = adapters or default_registry() + skip = exclude or set() + + # Resolve PEP 563 string annotations (``from __future__ import + # annotations`` is in effect for most callers) into the real types + # before feeding them to the resolver factories. We build a localns + # from the function's closure cells so types defined in an enclosing + # scope (common in tests, factories) resolve too. + localns = _closure_locals(fn) + try: + sig = inspect.signature(fn, eval_str=True, locals=localns) + except Exception: # noqa: BLE001 + sig = inspect.signature(fn) + try: + hints = get_type_hints(fn, localns=localns, include_extras=True) + except Exception: # noqa: BLE001 + hints = {} + sig = _signature_with_hints(sig, hints) + + runtime: list[tuple[str, ArgResolver]] = [] + factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] + for name, param in sig.parameters.items(): + if name in skip: + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + raise ValueError(f"handler {qualname(fn)!r}: *args / **kwargs not supported") + factory = _pick_factory(name, param, path_vars, registry, ctx_types) + if factory is None: + # Request ctx pass-through. + runtime.append((name, resolve_ctx)) + factories.append((name, param, None)) + else: + # Inject the registry into FromBody so its closure binds the + # right adapter at build time. User-supplied factories are left + # alone — they're trusted to handle their own decoding. + if isinstance(factory, FromBody) and factory.adapters is None: + factory = replace(factory, adapters=registry) + runtime.append((name, factory(param))) + factories.append((name, param, factory)) + return sig, runtime, factories + + +def _closure_locals(fn: Callable[..., Any]) -> dict[str, Any]: + """Return a name → value dict from ``fn``'s closure cells. + + Used as ``localns`` for type-annotation resolution so types defined in + an enclosing function scope are visible (common in tests, model + factories). + """ + closure = getattr(fn, "__closure__", None) or () + code = getattr(fn, "__code__", None) + if not closure or code is None: + return {} + free_vars = code.co_freevars + locals_dict: dict[str, Any] = {} + for name, cell in zip(free_vars, closure, strict=False): + try: + locals_dict[name] = cell.cell_contents + except ValueError: + continue + return locals_dict + + +def _signature_with_hints(sig: inspect.Signature, hints: dict[str, Any]) -> inspect.Signature: + """Return ``sig`` with each parameter's ``annotation`` replaced by the + resolved type from ``hints``. Same for the return annotation.""" + new_params = [ + param.replace(annotation=hints[name]) if name in hints else param for name, param in sig.parameters.items() + ] + return_annotation = hints.get("return", sig.return_annotation) + return sig.replace(parameters=new_params, return_annotation=return_annotation) + + +def _path_to_id(path: str) -> str: + cleaned = path.replace("/", "_").replace("{", "").replace("}", "").strip("_") + return cleaned or "root" + + +def operation_id(method: HTTPMethod, path: str, fn: Callable[..., Any]) -> str: + """Pick an operationId. + + Prefer ``fn.__name__`` when it's a real, non-anonymous identifier — it + matches what users see in their codebase (and what FastAPI emits, modulo + the path suffix). Fall back to a path-mangled id for lambdas / wrapped + callables / anything without a usable name. + """ + name = getattr(fn, "__name__", "") or "" + if name and name not in {"", "_", ""}: + return name + return f"{method.value.lower()}_{_path_to_id(path)}" + + +def _pick_factory( + name: str, + param: inspect.Parameter, + path_var_names: set[str], + adapters: AdapterRegistry, + ctx_types: tuple[type, ...], +) -> ArgResolverFactory | None: + """Return the resolver factory for one parameter, or ``None`` for the + request-ctx pass-through.""" + annotation = param.annotation + + # Explicit ``Annotated[T, FromX(...)]`` wins. + if get_origin(annotation) is Annotated: + for arg in get_args(annotation)[1:]: + if _is_resolver_factory(arg): + return arg + + target = annotation + if get_origin(target) is Annotated: + target = get_args(target)[0] + + if name in path_var_names: + return FromPath(name=name) + if ctx_types and target in ctx_types: + return None + if is_body_type(target, adapters): + return FromBody() + return FromQuery(name=name) + + +def _is_resolver_factory(arg: Any) -> ArgResolverFactory | None: + """Duck-typing for our :class:`ArgResolverFactory` ``Protocol``. + + We can't ``isinstance``-check it because the protocol is structural and + not ``@runtime_checkable``; resolver factories are tagged by carrying an + ``update_doc`` method on top of being callable. + """ + if callable(arg) and hasattr(arg, "update_doc"): + return cast(ArgResolverFactory, arg) + return None + + +# --- Return-type → response-shape extraction ---------------------------- + + +def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape], bool]: + """Return ``(shapes, null_is_not_found)`` from a function's return annotation.""" + if return_annotation is inspect.Signature.empty: + return [ResponseShape(200, "Successful response", None)], False + + origin = get_origin(return_annotation) + members = list(get_args(return_annotation)) if origin is Union or origin is UnionType else [return_annotation] + + shapes: list[ResponseShape] = [] + seen_codes: set[int] = set() + has_success = False + has_none = False + for member in members: + if member is type(None): + has_none = True + continue + member_origin = get_origin(member) + cls = member_origin if member_origin is not None else member + sse_payload = _sse_payload_type(member) + if sse_payload is not _NOT_SSE: + code = 200 + description = "Successful response" + body_type = sse_payload + content_type = "text/event-stream" + has_success = True + elif isinstance(cls, type) and issubclass(cls, EventStreamResult): + code = cls._status_code + description = cls._description + generic_args = get_args(member) if member_origin is not None else () + body_type = generic_args[0] if generic_args else None + content_type = "text/event-stream" + has_success = True + elif isinstance(cls, type) and issubclass(cls, OpResult): + code = cls._status_code + description = cls._description + body_type = None + content_type = "application/json" + if cls is not NoContent: + generic_args = get_args(member) if member_origin is not None else () + body_type = generic_args[0] if generic_args else None + if code < 400: + has_success = True + else: + code = 200 + description = "Successful response" + body_type = member + content_type = "application/json" + has_success = True + if code in seen_codes: + continue + seen_codes.add(code) + shapes.append(ResponseShape(code, description, body_type, content_type)) + + # ``T | None`` is the implicit "T or 404 NotFound" — only when paired + # with at least one non-None success type (a bare ``-> None`` annotation + # still means "204 / empty success", not 404), and only when the user + # didn't already declare 404 explicitly via ``NotFound[X]``. + from localpost.openapi.results import NotFound as _NotFound # noqa: PLC0415 + + null_is_not_found = has_none and has_success and 404 not in seen_codes + if null_is_not_found: + shapes.append(ResponseShape(_NotFound._status_code, _NotFound._description, None)) + seen_codes.add(404) + + if not has_success and not shapes: + shapes.append(ResponseShape(200, "Successful response", None)) + return shapes, null_is_not_found + + +# Sentinel: unique object so callers can distinguish "not an SSE return" +# from "SSE return with no payload type" (e.g. a bare Iterator). +_NOT_SSE: Any = object() + + +def _sse_payload_type(annotation: Any) -> Any: + """If ``annotation`` is one of the SSE-shaped iterables / generators + (sync or async) or :class:`EventStream`, return the element type + (or :data:`_NOT_SSE`).""" + origin = get_origin(annotation) + if origin is None: + return _NOT_SSE + # ``EventStream[T]`` — origin is ``EventStream`` itself. + if origin is EventStream: + args = get_args(annotation) + return args[0] if args else None + # ``Generator[T, send, return]`` / ``Iterator[T]`` / ``Iterable[T]`` + # plus their async counterparts. All live in ``collections.abc``. + if origin not in (Generator, Iterator, Iterable, AsyncGenerator, AsyncIterator, AsyncIterable): + return _NOT_SSE + args = get_args(annotation) + return args[0] if args else None + + +def build_responses(shapes: tuple[ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: + responses: dict[str, openapi_spec.Response] = {} + for shape in shapes: + if shape.body_type is None: + responses[str(shape.status_code)] = openapi_spec.Response(description=shape.description) + continue + schema = registry.schema_for(shape.body_type) + responses[str(shape.status_code)] = openapi_spec.Response( + description=shape.description, + content={shape.content_type: openapi_spec.MediaType(schema=schema)}, + ) + return responses + + +# --- Response building --------------------------------------------------- + +_TEXT_CONTENT_TYPE = b"text/plain; charset=utf-8" +_OCTET_CONTENT_TYPE = b"application/octet-stream" + + +def encode_body(value: object, adapters: AdapterRegistry) -> tuple[bytes, bytes]: + """Return ``(body_bytes, content_type)`` for ``value``. + + Pass-through for ``bytes`` / ``str``; structured values go through the + :class:`TypeAdapter` that claims ``type(value)``. + """ + if value is None: + return b"", b"" + if isinstance(value, bytes): + return value, _OCTET_CONTENT_TYPE + if isinstance(value, bytearray): + return bytes(value), _OCTET_CONTENT_TYPE + if isinstance(value, str): + return value.encode("utf-8"), _TEXT_CONTENT_TYPE + body, content_type = adapters.for_value(value).encode(value) + return body, content_type.encode("ascii") + + +def build_http_response(result: OpResult, adapters: AdapterRegistry) -> tuple[_Response, bytes]: + body_bytes, default_ct = encode_body(result.body, adapters) + headers: list[tuple[bytes, bytes]] = [] + headers_seen: set[bytes] = set() + for name, value in iter_response_headers(result.headers): + headers.append((name, value)) + headers_seen.add(name.lower()) + if body_bytes and b"content-type" not in headers_seen and default_ct: + headers.append((b"content-type", default_ct)) + if b"content-length" not in headers_seen: + headers.append((b"content-length", str(len(body_bytes)).encode("ascii"))) + return _Response(status_code=result.status_code, headers=headers), body_bytes + + +def iter_response_headers(headers: Mapping[str, str]): + for name, value in headers.items(): + yield name.encode("ascii"), value.encode("iso-8859-1") + + +# --- SSE detection ------------------------------------------------------- + +SSE_RESPONSE_HEADERS: list[tuple[bytes, bytes]] = [ + (b"content-type", b"text/event-stream; charset=utf-8"), + (b"cache-control", b"no-cache"), + (b"x-accel-buffering", b"no"), # Disable proxy buffering (nginx). +] + + +def is_sse_payload(value: object) -> bool: + """True if ``value`` should be streamed as SSE (sync side). + + Recognises explicit :class:`EventStream` and any ``Iterator`` (the + result of calling a generator function or building one explicitly). + Bare ``Iterable`` is too broad — ``list`` / ``tuple`` / ``dict`` are + all iterable but should land in JSON. + """ + return isinstance(value, (EventStream, Iterator)) + + +def is_async_sse_payload(value: object) -> bool: + """True if ``value`` should be streamed as SSE (async side). + + Recognises explicit :class:`EventStream` and any ``AsyncIterator`` + (the result of calling an ``async def`` generator function). Plain + sync iterators are *not* recognised here — the async runtime rejects + them with a clear error so users don't accidentally block the event + loop with a sync generator. + """ + return isinstance(value, (EventStream, AsyncIterator)) + + +def is_sync_iterable(value: object) -> bool: + """True if ``value`` is a sync iterator/generator (and not an + :class:`EventStream`). Used by the async runtime to detect — and + reject — sync generators in async handlers.""" + if isinstance(value, EventStream): + return False + return isinstance(value, Iterator) diff --git a/localpost/openapi/middleware.py b/localpost/openapi/middleware.py index 2f39c3a..cbc7467 100644 --- a/localpost/openapi/middleware.py +++ b/localpost/openapi/middleware.py @@ -160,7 +160,7 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return self.root_contribution(doc, registry) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - from localpost.openapi.operation import _build_responses # noqa: PLC0415 + from localpost.openapi._operation_core import build_responses # noqa: PLC0415 # Parameters / requestBody come from the resolver factories. for _name, param, factory in self.arg_factories: @@ -171,7 +171,7 @@ def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) # ``OpResult`` passthrough sentinel). Don't overwrite codes the # operation already declared (the op's own response is more # specific than the middleware's generic one). - for code, response in _build_responses(self.return_shapes, registry).items(): + for code, response in build_responses(self.return_shapes, registry).items(): if code in op.responses: continue op = replace(op, responses={**op.responses, code: response}) @@ -210,10 +210,15 @@ def op_middleware(fn: Callable[..., Any]) -> _FunctionMiddleware: the operation already declared). A bare ``OpResult`` member of the return union is the passthrough sentinel and contributes nothing. """ - from localpost.openapi.operation import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + from localpost.http import HTTPReqCtx # noqa: PLC0415 + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 call_next_param = _identify_call_next_param(fn) - sig, runtime, factories = build_arg_resolvers(fn, exclude={call_next_param}) + sig, runtime, factories = build_arg_resolvers( + fn, + exclude={call_next_param}, + ctx_types=(HTTPReqCtx,), + ) shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) return _FunctionMiddleware( target=fn, diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index a374d5f..f49f804 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -10,52 +10,48 @@ from __future__ import annotations import inspect -from collections.abc import ( - Callable, - Generator, - Iterable, - Iterator, - Mapping, -) +from collections.abc import Callable from dataclasses import dataclass, replace from http import HTTPMethod -from types import UnionType -from typing import Annotated, Any, Self, Union, get_args, get_origin, get_type_hints +from typing import Any, Self from localpost.http import BodyHandler, HTTPReqCtx, RequestHandler from localpost.http._types import Response as _Response from localpost.http.router import URITemplate from localpost.openapi import spec as openapi_spec +from localpost.openapi._operation_core import SSE_RESPONSE_HEADERS as _SSE_RESPONSE_HEADERS +from localpost.openapi._operation_core import ( + ResponseShape, + build_arg_resolvers, + build_http_response, + build_responses, + extract_response_shapes, + is_sse_payload, + iter_response_headers, +) +from localpost.openapi._operation_core import ( + operation_id as _make_operation_id, +) +from localpost.openapi._operation_core import ( + qualname as _qualname, +) from localpost.openapi.adapters import AdapterRegistry, default_registry from localpost.openapi.middleware import ApiOperation, OpMiddleware from localpost.openapi.resolvers import ( ArgResolver, ArgResolverFactory, - FromBody, FromPath, - FromQuery, - is_body_type, ) -from localpost.openapi.results import EventStreamResult, NoContent, NotFound, Ok, OpResult +from localpost.openapi.results import EventStreamResult, NotFound, Ok, OpResult from localpost.openapi.schemas import SchemaRegistry -from localpost.openapi.sse import EventStream, iter_events - -__all__ = ["Operation", "ResponseShape", "build_arg_resolvers", "extract_response_shapes"] +from localpost.openapi.sse import iter_events - -# Sentinel — we expose the request ctx for params explicitly typed as HTTPReqCtx. -def _resolve_ctx(ctx: HTTPReqCtx) -> HTTPReqCtx: - return ctx - - -@dataclass(frozen=True, slots=True) -class ResponseShape: - """One branch of the return type — a (status, body type, content type).""" - - status_code: int - description: str - body_type: Any | None - content_type: str = "application/json" +__all__ = [ + "Operation", + "ResponseShape", + "build_arg_resolvers", + "extract_response_shapes", +] @dataclass(frozen=True, slots=True) @@ -104,7 +100,12 @@ def create( path_var_names = set(template.variable_names) registry = adapters or default_registry() - sig, arg_resolvers, arg_factories = build_arg_resolvers(fn, path_var_names=path_var_names, adapters=registry) + sig, arg_resolvers, arg_factories = build_arg_resolvers( + fn, + path_var_names=path_var_names, + adapters=registry, + ctx_types=(HTTPReqCtx,), + ) # Validate path bindings: every {var} in the template must be # claimed by a parameter (whether implicitly via name match or @@ -133,7 +134,7 @@ def create( doc = inspect.getdoc(fn) or "" summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" description = doc[len(summary) :].lstrip("\n") if doc else "" - operation_id = _operation_id(method, path, fn) + op_id = _make_operation_id(method, path, fn) return cls( method=method, @@ -146,7 +147,7 @@ def create( return_shapes=return_shapes, null_is_not_found=null_is_not_found, summary=summary, - operation_id=operation_id, + operation_id=op_id, description=description, adapters=registry, ) @@ -190,7 +191,7 @@ def _run_core(self, ctx: HTTPReqCtx) -> OpResult: result = self.target(**kwargs) if isinstance(result, OpResult): return result - if _is_sse_payload(result): + if is_sse_payload(result): return EventStreamResult(result) if result is None and self.null_is_not_found: # ``T | None`` returning None → implicit 404. @@ -201,7 +202,7 @@ def _write_response(self, ctx: HTTPReqCtx, result: OpResult) -> None: if isinstance(result, EventStreamResult): _stream_sse(ctx, result, self.adapters) return - response, body = _build_http_response(result, self.adapters) + response, body = build_http_response(result, self.adapters) ctx.complete(response, body) # ----- spec build ----- @@ -216,352 +217,15 @@ def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: if factory is None: continue op = factory.update_doc(param, op, registry) - responses = _build_responses(self.return_shapes, registry) + responses = build_responses(self.return_shapes, registry) op = replace(op, responses=responses) for mw in self.middlewares: op = mw.contribute_operation(op, registry) return op -# --- Helpers ------------------------------------------------------------- - - -def _qualname(fn: Callable[..., Any]) -> str: - return getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) - - -def build_arg_resolvers( - fn: Callable[..., Any], - *, - path_var_names: set[str] | None = None, - adapters: AdapterRegistry | None = None, - exclude: set[str] | None = None, -) -> tuple[ - inspect.Signature, - list[tuple[str, ArgResolver]], - list[tuple[str, inspect.Parameter, ArgResolverFactory | None]], -]: - """Inspect ``fn`` and return ``(resolved_signature, runtime_resolvers, - spec_factories)`` for use by :class:`Operation` or :func:`op_middleware`. - - Path-template binding is *not* validated here — pass ``path_var_names`` - so :class:`FromPath` is auto-picked for matching parameter names; the - caller is responsible for any "unbound var" error. - - ``adapters`` flows into auto-picked / un-bound :class:`FromBody` - factories so body decoding hits the per-app type adapter registry. If - ``None``, falls back to :func:`default_registry` (used by - :func:`op_middleware`, which is built outside any app). - - ``exclude`` skips parameters by name — used by :func:`op_middleware` - to drop the ``call_next`` parameter from resolver inspection. - """ - path_vars = path_var_names or set() - registry = adapters or default_registry() - skip = exclude or set() - - # Resolve PEP 563 string annotations (``from __future__ import - # annotations`` is in effect for most callers) into the real types - # before feeding them to the resolver factories. We build a localns - # from the function's closure cells so types defined in an enclosing - # scope (common in tests, factories) resolve too. - localns = _closure_locals(fn) - try: - sig = inspect.signature(fn, eval_str=True, locals=localns) - except Exception: # noqa: BLE001 - sig = inspect.signature(fn) - try: - hints = get_type_hints(fn, localns=localns, include_extras=True) - except Exception: # noqa: BLE001 - hints = {} - sig = _signature_with_hints(sig, hints) - - runtime: list[tuple[str, ArgResolver]] = [] - factories: list[tuple[str, inspect.Parameter, ArgResolverFactory | None]] = [] - for name, param in sig.parameters.items(): - if name in skip: - continue - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - raise ValueError(f"handler {_qualname(fn)!r}: *args / **kwargs not supported") - factory = _pick_factory(name, param, path_vars, registry) - if factory is None: - # HTTPReqCtx pass-through. - runtime.append((name, _resolve_ctx)) - factories.append((name, param, None)) - else: - # Inject the registry into FromBody so its closure binds the - # right adapter at build time. User-supplied factories are left - # alone — they're trusted to handle their own decoding. - if isinstance(factory, FromBody) and factory.adapters is None: - factory = replace(factory, adapters=registry) - runtime.append((name, factory(param))) - factories.append((name, param, factory)) - return sig, runtime, factories - - -def _closure_locals(fn: Callable[..., Any]) -> dict[str, Any]: - """Return a name → value dict from ``fn``'s closure cells. - - Used as ``localns`` for type-annotation resolution so types defined in - an enclosing function scope are visible (common in tests, model - factories). - """ - closure = getattr(fn, "__closure__", None) or () - code = getattr(fn, "__code__", None) - if not closure or code is None: - return {} - free_vars = code.co_freevars - locals_dict: dict[str, Any] = {} - for name, cell in zip(free_vars, closure, strict=False): - try: - locals_dict[name] = cell.cell_contents - except ValueError: - continue - return locals_dict - - -def _signature_with_hints(sig: inspect.Signature, hints: dict[str, Any]) -> inspect.Signature: - """Return ``sig`` with each parameter's ``annotation`` replaced by the - resolved type from ``hints``. Same for the return annotation. - - With ``from __future__ import annotations`` in effect, ``param.annotation`` - is a ``str``; we need the real type for runtime introspection. - """ - new_params = [ - param.replace(annotation=hints[name]) if name in hints else param for name, param in sig.parameters.items() - ] - return_annotation = hints.get("return", sig.return_annotation) - return sig.replace(parameters=new_params, return_annotation=return_annotation) - - -def _path_to_id(path: str) -> str: - cleaned = path.replace("/", "_").replace("{", "").replace("}", "").strip("_") - return cleaned or "root" - - -def _operation_id(method: HTTPMethod, path: str, fn: Callable[..., Any]) -> str: - """Pick an operationId. - - Prefer ``fn.__name__`` when it's a real, non-anonymous identifier — it - matches what users see in their codebase (and what FastAPI emits, modulo - the path suffix). Fall back to a path-mangled id for lambdas / wrapped - callables / anything without a usable name. - """ - name = getattr(fn, "__name__", "") or "" - if name and name not in {"", "_", ""}: - return name - return f"{method.value.lower()}_{_path_to_id(path)}" - - -def _pick_factory( - name: str, - param: inspect.Parameter, - path_var_names: set[str], - adapters: AdapterRegistry, -) -> ArgResolverFactory | None: - """Return the resolver factory for one parameter, or ``None`` for the - ``HTTPReqCtx`` pass-through.""" - annotation = param.annotation - - # Explicit ``Annotated[T, FromX(...)]`` wins. - if get_origin(annotation) is Annotated: - for arg in get_args(annotation)[1:]: - if _is_resolver_factory(arg): - return arg - - target = annotation - if get_origin(target) is Annotated: - target = get_args(target)[0] - - if name in path_var_names: - return FromPath(name=name) - if target is HTTPReqCtx: - return None - if is_body_type(target, adapters): - return FromBody() - return FromQuery(name=name) - - -def _is_resolver_factory(arg: Any) -> ArgResolverFactory | None: - """Duck-typing for our :class:`ArgResolverFactory` ``Protocol``. - - We can't ``isinstance``-check it because the protocol is structural and - not ``@runtime_checkable``; resolver factories are tagged by carrying an - ``update_doc`` method on top of being callable. - """ - if callable(arg) and hasattr(arg, "update_doc"): - return arg - return None - - -def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape], bool]: - """Return ``(shapes, null_is_not_found)`` from a function's return annotation.""" - if return_annotation is inspect.Signature.empty: - return [ResponseShape(200, "Successful response", None)], False - - origin = get_origin(return_annotation) - members = list(get_args(return_annotation)) if origin is Union or origin is UnionType else [return_annotation] - - shapes: list[ResponseShape] = [] - seen_codes: set[int] = set() - has_success = False - has_none = False - for member in members: - if member is type(None): - has_none = True - continue - member_origin = get_origin(member) - cls = member_origin if member_origin is not None else member - sse_payload = _sse_payload_type(member) - if sse_payload is not _NOT_SSE: - code = 200 - description = "Successful response" - body_type = sse_payload - content_type = "text/event-stream" - has_success = True - elif isinstance(cls, type) and issubclass(cls, EventStreamResult): - code = cls._status_code - description = cls._description - generic_args = get_args(member) if member_origin is not None else () - body_type = generic_args[0] if generic_args else None - content_type = "text/event-stream" - has_success = True - elif isinstance(cls, type) and issubclass(cls, OpResult): - code = cls._status_code - description = cls._description - body_type = None - content_type = "application/json" - if cls is not NoContent: - generic_args = get_args(member) if member_origin is not None else () - body_type = generic_args[0] if generic_args else None - if code < 400: - has_success = True - else: - code = 200 - description = "Successful response" - body_type = member - content_type = "application/json" - has_success = True - if code in seen_codes: - continue - seen_codes.add(code) - shapes.append(ResponseShape(code, description, body_type, content_type)) - - # ``T | None`` is the implicit "T or 404 NotFound" — only when paired - # with at least one non-None success type (a bare ``-> None`` annotation - # still means "204 / empty success", not 404), and only when the user - # didn't already declare 404 explicitly via ``NotFound[X]``. - null_is_not_found = has_none and has_success and 404 not in seen_codes - if null_is_not_found: - shapes.append(ResponseShape(404, "Not Found", None)) - seen_codes.add(404) - - if not has_success and not shapes: - shapes.append(ResponseShape(200, "Successful response", None)) - return shapes, null_is_not_found - - -# Sentinel: unique object so callers can distinguish "not an SSE return" -# from "SSE return with no payload type" (e.g. a bare Iterator). -_NOT_SSE: Any = object() - - -def _sse_payload_type(annotation: Any) -> Any: - """If ``annotation`` is a ``Generator[T, ...]`` / ``Iterator[T, ...]`` / - ``Iterable[T]`` / ``EventStream[T]``, return ``T`` (or ``_NOT_SSE``). - """ - origin = get_origin(annotation) - if origin is None: - return _NOT_SSE - # ``EventStream[T]`` — origin is ``EventStream`` itself. - if origin is EventStream: - args = get_args(annotation) - return args[0] if args else None - # ``Generator[T, send, return]`` / ``Iterator[T]`` / ``Iterable[T]`` - # all live in ``collections.abc``; their generic origins are the abc - # classes themselves. - if origin not in (Generator, Iterator, Iterable): - return _NOT_SSE - args = get_args(annotation) - return args[0] if args else None - - -def _build_responses(shapes: tuple[ResponseShape, ...], registry: SchemaRegistry) -> dict[str, openapi_spec.Response]: - responses: dict[str, openapi_spec.Response] = {} - for shape in shapes: - if shape.body_type is None: - responses[str(shape.status_code)] = openapi_spec.Response(description=shape.description) - continue - schema = registry.schema_for(shape.body_type) - responses[str(shape.status_code)] = openapi_spec.Response( - description=shape.description, - content={shape.content_type: openapi_spec.MediaType(schema=schema)}, - ) - return responses - - -# --- Response building --------------------------------------------------- - -_TEXT_CONTENT_TYPE = b"text/plain; charset=utf-8" -_OCTET_CONTENT_TYPE = b"application/octet-stream" - - -def _encode_body(value: object, adapters: AdapterRegistry) -> tuple[bytes, bytes]: - """Return ``(body_bytes, content_type)`` for ``value``. - - Pass-through for ``bytes`` / ``str``; structured values go through the - :class:`TypeAdapter` that claims ``type(value)``. - """ - if value is None: - return b"", b"" - if isinstance(value, bytes): - return value, _OCTET_CONTENT_TYPE - if isinstance(value, bytearray): - return bytes(value), _OCTET_CONTENT_TYPE - if isinstance(value, str): - return value.encode("utf-8"), _TEXT_CONTENT_TYPE - body, content_type = adapters.for_value(value).encode(value) - return body, content_type.encode("ascii") - - -def _build_http_response(result: OpResult, adapters: AdapterRegistry) -> tuple[_Response, bytes]: - body_bytes, default_ct = _encode_body(result.body, adapters) - headers: list[tuple[bytes, bytes]] = [] - headers_seen: set[bytes] = set() - for name, value in _iter_headers(result.headers): - headers.append((name, value)) - headers_seen.add(name.lower()) - if body_bytes and b"content-type" not in headers_seen and default_ct: - headers.append((b"content-type", default_ct)) - if b"content-length" not in headers_seen: - headers.append((b"content-length", str(len(body_bytes)).encode("ascii"))) - return _Response(status_code=result.status_code, headers=headers), body_bytes - - -def _iter_headers(headers: Mapping[str, str]): - for name, value in headers.items(): - yield name.encode("ascii"), value.encode("iso-8859-1") - - # --- SSE streaming ------------------------------------------------------- -_SSE_RESPONSE_HEADERS: list[tuple[bytes, bytes]] = [ - (b"content-type", b"text/event-stream; charset=utf-8"), - (b"cache-control", b"no-cache"), - (b"x-accel-buffering", b"no"), # Disable proxy buffering (nginx). -] - - -def _is_sse_payload(value: object) -> bool: - """True if ``value`` should be streamed as SSE. - - Recognises explicit :class:`EventStream` and any ``Iterator`` (the - result of calling a generator function or building one explicitly). - Bare ``Iterable`` is too broad — ``list`` / ``tuple`` / ``dict`` are - all iterable but should land in JSON. - """ - return isinstance(value, (EventStream, Iterator)) - def _wrap_middleware(mw: OpMiddleware, call_next: ApiOperation) -> ApiOperation: def wrapped(ctx: HTTPReqCtx) -> OpResult: @@ -584,7 +248,7 @@ def _stream_sse(ctx: HTTPReqCtx, result: EventStreamResult[Any], adapters: Adapt """ headers = list(_SSE_RESPONSE_HEADERS) seen = {name for name, _ in headers} - for name, value in _iter_headers(result.headers): + for name, value in iter_response_headers(result.headers): if name.lower() in seen: continue headers.append((name, value)) From baaeedd96f411969ba473ef58a6cdc155b131d63 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 10:47:49 +0400 Subject: [PATCH 200/286] feat(openapi): add HttpAsyncApp + ASGI deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async sibling of HttpApp under localpost.openapi.aio. Same decorator API, same OpenAPI 3.2 emission, same OpResult hierarchy. Built on the shared type-level core in _operation_core.py. What's new: - HttpAsyncApp: parallel registration API; .asgi() returns an ASGI 3 callable; .service(config, server="uvicorn"|"hypercorn") wires up hosting via the existing localpost.hosting.services adapters. - AsyncOperation: async runtime closure (await user fn, await middleware chain, await ctx.complete / ctx.stream). Build-spec path is identical to the sync flavour and shares the type-level helpers. - AsyncOpMiddleware Protocol + @async_op_middleware decorator. Sync middlewares are rejected loudly at app-construction time — the split is meant to be hard. - AsyncHttpBearerAuth / AsyncHttpBasicAuth. Validators may be sync or async (awaited as needed). - _ASGIReqCtx: ASGI 3 bridge. Body is pre-buffered (max_body_size, default 1 MiB) so the same sync resolvers (FromPath / FromQuery / FromHeader / FromBody) work for both flavours. http.disconnect flips a flag the SSE drive checks between events. - async_iter_events: SSE drive that rejects sync generators with a clear error — async apps want async generators. Refactor: ArgResolver now takes a minimal ResolverCtx Protocol (request / body / attrs) instead of the full HTTPReqCtx — both ctx flavours satisfy it structurally, so resolver factories are shared. Out of scope (follow-ups): streaming uploads via ctx.receive, RSGI native deployment, granian hosting service. --- localpost/openapi/__init__.py | 26 +- localpost/openapi/aio/__init__.py | 34 ++ localpost/openapi/aio/_ctx.py | 207 ++++++++++++ localpost/openapi/aio/app.py | 480 ++++++++++++++++++++++++++++ localpost/openapi/aio/auth.py | 170 ++++++++++ localpost/openapi/aio/middleware.py | 188 +++++++++++ localpost/openapi/aio/operation.py | 237 ++++++++++++++ localpost/openapi/aio/sse.py | 42 +++ localpost/openapi/resolvers.py | 35 +- 9 files changed, 1408 insertions(+), 11 deletions(-) create mode 100644 localpost/openapi/aio/__init__.py create mode 100644 localpost/openapi/aio/_ctx.py create mode 100644 localpost/openapi/aio/app.py create mode 100644 localpost/openapi/aio/auth.py create mode 100644 localpost/openapi/aio/middleware.py create mode 100644 localpost/openapi/aio/operation.py create mode 100644 localpost/openapi/aio/sse.py diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index 16f77f0..9b6c8d2 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -38,6 +38,16 @@ def get_book(book_id: str) -> Book | NotFound[str]: from localpost.openapi import spec from localpost.openapi.adapters import AdapterRegistry, TypeAdapter, default_registry +from localpost.openapi.aio import ( + AsyncApiOperation, + AsyncHttpBasicAuth, + AsyncHttpBearerAuth, + AsyncHTTPReqCtx, + AsyncOperation, + AsyncOpMiddleware, + HttpAsyncApp, + async_op_middleware, +) from localpost.openapi.app import HttpApp from localpost.openapi.auth import HttpBasicAuth, HttpBearerAuth from localpost.openapi.middleware import ApiOperation, OpMiddleware, op_middleware @@ -49,6 +59,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: FromHeader, FromPath, FromQuery, + ResolverCtx, ) from localpost.openapi.results import ( Accepted, @@ -70,20 +81,31 @@ def get_book(book_id: str) -> Book | NotFound[str]: from localpost.openapi.sse import Event, EventStream __all__ = [ - # core + # core (sync) "HttpApp", "Operation", + # core (async) + "HttpAsyncApp", + "AsyncOperation", + "AsyncHTTPReqCtx", # spec sub-module (advanced; not all symbols flattened) "spec", - # middleware + # middleware (sync) "ApiOperation", "OpMiddleware", "op_middleware", "HttpBearerAuth", "HttpBasicAuth", + # middleware (async) + "AsyncApiOperation", + "AsyncOpMiddleware", + "async_op_middleware", + "AsyncHttpBearerAuth", + "AsyncHttpBasicAuth", # arg resolvers "ArgResolver", "ArgResolverFactory", + "ResolverCtx", "FromPath", "FromQuery", "FromHeader", diff --git a/localpost/openapi/aio/__init__.py b/localpost/openapi/aio/__init__.py new file mode 100644 index 0000000..1285ab2 --- /dev/null +++ b/localpost/openapi/aio/__init__.py @@ -0,0 +1,34 @@ +"""Async-flavour public API for ``localpost.openapi``. + +Symbols here mirror the sync API at ``localpost.openapi`` — :class:`HttpApp` +becomes :class:`HttpAsyncApp`, :class:`OpMiddleware` becomes +:class:`AsyncOpMiddleware`, etc. The two flavours share the type-level +machinery (signature parsing, OpenAPI doc emission, ``OpResult`` hierarchy, +SSE encoding) and only diverge at the request-time invocation: async apps +``await`` user functions, async middleware, and async ASGI send/receive. + +The async app is deployed to an ASGI server by default — +:meth:`HttpAsyncApp.asgi` returns the ASGI 3 callable; :meth:`HttpAsyncApp.service` +wires it up under uvicorn for ``localpost.hosting``. +""" + +from localpost.openapi.aio._ctx import AsyncHTTPReqCtx +from localpost.openapi.aio.app import HttpAsyncApp +from localpost.openapi.aio.auth import AsyncHttpBasicAuth, AsyncHttpBearerAuth +from localpost.openapi.aio.middleware import ( + AsyncApiOperation, + AsyncOpMiddleware, + async_op_middleware, +) +from localpost.openapi.aio.operation import AsyncOperation + +__all__ = [ + "HttpAsyncApp", + "AsyncOperation", + "AsyncHTTPReqCtx", + "AsyncOpMiddleware", + "AsyncApiOperation", + "async_op_middleware", + "AsyncHttpBearerAuth", + "AsyncHttpBasicAuth", +] diff --git a/localpost/openapi/aio/_ctx.py b/localpost/openapi/aio/_ctx.py new file mode 100644 index 0000000..60ce2c0 --- /dev/null +++ b/localpost/openapi/aio/_ctx.py @@ -0,0 +1,207 @@ +"""Async per-request context — the ASGI 3 bridge. + +:class:`AsyncHTTPReqCtx` mirrors the sync :class:`localpost.http.HTTPReqCtx` +Protocol but with async ``complete`` / ``stream`` / ``receive`` methods. +The concrete :class:`_ASGIReqCtx` implementation builds a :class:`Request` +from an ASGI ``scope``, pre-buffers the request body (matches the JSON-API +common case), and translates ``ctx.complete(...)`` / ``ctx.stream(...)`` +into ASGI ``http.response.start`` + ``http.response.body`` events. + +Resolvers (:class:`FromPath` / :class:`FromQuery` / :class:`FromHeader` / +:class:`FromBody`) only read sync attributes (``request``, ``body``, +``attrs``) — that's the entire point of pre-buffering. The same +sync resolvers work in both flavours. +""" + +from __future__ import annotations + +import threading +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from localpost.http._types import Request, Response + +__all__ = [ + "AsyncHTTPReqCtx", + "ASGIScope", + "ASGIReceive", + "ASGISend", +] + + +# ASGI 3 callable types — we don't depend on a particular asgiref TypedDict +# to keep the surface narrow. Scope/event dicts are passed through verbatim. +type ASGIScope = dict[str, Any] +type ASGIReceive = Callable[[], Awaitable[dict[str, Any]]] +type ASGISend = Callable[[dict[str, Any]], Awaitable[None]] + + +@runtime_checkable +class AsyncHTTPReqCtx(Protocol): + """Per-request context handed to an :class:`AsyncOperation`. + + Mirrors :class:`localpost.http.HTTPReqCtx`'s sync attributes + (``request``, ``body``, ``attrs``, ``response_status``, addr/scheme + properties) so the same arg-resolvers work against either flavour. + Methods that touch the wire are async. + """ + + request: Request + body: bytes + response_status: int | None + attrs: dict[Any, Any] + + @property + def remote_addr(self) -> str | None: ... + @property + def local_addr(self) -> str: ... + @property + def scheme(self) -> str: ... + @property + def disconnected(self) -> bool: ... + + async def complete(self, response: Response, body: bytes | None = None) -> None: ... + async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: ... + + +# --- ASGI bridge --------------------------------------------------------- + + +class _ResponseAlreadyStarted(RuntimeError): + """Raised if a handler tries to start two responses on one request.""" + + +@dataclass(slots=True, eq=False) +class _ASGIReqCtx: + """:class:`AsyncHTTPReqCtx` backed by an ASGI 3 ``scope`` + ``send``. + + The body is pre-buffered by :func:`_read_body` before dispatch so + sync resolvers can read ``ctx.body`` directly. ``complete`` and + ``stream`` translate into ``http.response.start`` + + ``http.response.body`` ASGI events. ``disconnected`` flips when an + ``http.disconnect`` event arrives via the watcher task spawned by + :class:`HttpAsyncApp`. + """ + + request: Request + body: bytes + remote_addr: str | None + local_addr: str + scheme: str + _send: ASGISend + _disconnected: threading.Event + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _started: bool = False + + @property + def disconnected(self) -> bool: + return self._disconnected.is_set() + + async def complete(self, response: Response, body: bytes | None = None) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + await self._send(_response_start_event(response)) + await self._send({"type": "http.response.body", "body": body or b"", "more_body": False}) + + async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + await self._send(_response_start_event(response)) + try: + async for chunk in chunks: + if self._disconnected.is_set(): + return + await self._send({"type": "http.response.body", "body": bytes(chunk), "more_body": True}) + finally: + # Always close the response — even if iteration raised, the ASGI + # server expects a final ``more_body=False`` event to release + # the connection. Skip when the peer is already gone (the + # transport will surface the close on its own). + if not self._disconnected.is_set(): + await self._send({"type": "http.response.body", "body": b"", "more_body": False}) + + def _check_not_started(self) -> None: + if self._started: + raise _ResponseAlreadyStarted("Response already started") + + +# --- Scope / event translation ------------------------------------------ + + +def build_request_from_scope(scope: ASGIScope) -> Request: + """Build a localpost :class:`Request` from an ASGI ``http`` scope.""" + method = scope["method"].encode("ascii") + raw_path: bytes = scope.get("raw_path") or scope["path"].encode("utf-8") + query_string: bytes = scope.get("query_string", b"") or b"" + target = raw_path + (b"?" + query_string if query_string else b"") + # ASGI lowercases header names already; it sends bytes pairs. + headers = tuple((bytes(name).lower(), bytes(value)) for name, value in scope.get("headers", ())) + http_version = scope.get("http_version", "1.1").encode("ascii") + return Request( + method=method, + target=target, + path=raw_path, + query_string=query_string, + headers=headers, + http_version=http_version, + ) + + +def addrs_from_scope(scope: ASGIScope) -> tuple[str | None, str]: + """Return ``(remote_addr, local_addr)`` from an ASGI scope. + + ASGI ``client`` / ``server`` are ``[host, port]`` lists or ``None``. + Mirrors the sync ctx ``"host:port"`` formatting. + """ + client = scope.get("client") + server = scope.get("server") or [None, None] + remote = _fmt_addr(client[0], client[1]) if client else None + local = _fmt_addr(server[0], server[1]) or "" + return remote, local + + +def _fmt_addr(host: Any, port: Any) -> str | None: + if host is None: + return None + if port is None or port == "": + return str(host) + return f"{host}:{port}" + + +def _response_start_event(response: Response) -> dict[str, Any]: + return { + "type": "http.response.start", + "status": response.status_code, + "headers": [(bytes(name), bytes(value)) for name, value in response.headers], + } + + +async def read_body(receive: ASGIReceive, max_size: int) -> bytes: + """Buffer the entire request body from successive ``http.request`` events. + + Raises :class:`ValueError` if the accumulated size would exceed + ``max_size``. ``http.disconnect`` while reading aborts cleanly with + whatever bytes have been received so far. + """ + chunks: list[bytes] = [] + total = 0 + while True: + event = await receive() + kind = event.get("type") + if kind == "http.disconnect": + return b"".join(chunks) + if kind != "http.request": + # Spec says we may ignore unknown events; keep looping. + continue + body: bytes = event.get("body", b"") or b"" + if body: + total += len(body) + if max_size >= 0 and total > max_size: + raise ValueError(f"Request body exceeds max_body_size ({max_size} bytes)") + chunks.append(body) + if not event.get("more_body", False): + return b"".join(chunks) diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py new file mode 100644 index 0000000..90dd88f --- /dev/null +++ b/localpost/openapi/aio/app.py @@ -0,0 +1,480 @@ +"""``HttpAsyncApp`` — async sibling of :class:`localpost.openapi.HttpApp`. + +Same decorator API, same OpenAPI doc emission, same ``OpResult`` +hierarchy. The user fns are ``async def``; the app deploys to ASGI by +default — :meth:`asgi` returns an ASGI 3 callable suitable for +``uvicorn``, ``hypercorn``, or ``granian --interface asgi``. +:meth:`service` wires up uvicorn under :mod:`localpost.hosting` for +``hosting.run_app(app.service(config))`` parity with the sync flavour. +""" + +from __future__ import annotations + +import threading +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, replace +from http import HTTPMethod +from typing import Any, Literal + +import anyio + +from localpost.http._types import Response +from localpost.http.router import RouteMatch, URITemplate +from localpost.openapi import spec as openapi_spec +from localpost.openapi._docs import redoc_html, scalar_html, swagger_html +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.aio._ctx import ( + ASGIReceive, + ASGIScope, + ASGISend, + _ASGIReqCtx, + addrs_from_scope, + build_request_from_scope, + read_body, +) +from localpost.openapi.aio.middleware import AsyncOpMiddleware +from localpost.openapi.aio.operation import AsyncOperation +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["HttpAsyncApp"] + + +_FluentDecorator = Callable[[Callable[..., Any]], Callable[..., Any]] +DocsUI = Literal["swagger", "redoc", "scalar", "all"] + +# Per-request callable: builds and writes the response over ``ctx``. +type _AsyncHandler = Callable[[_ASGIReqCtx], Awaitable[None]] + + +class HttpAsyncApp: + """Async type-driven HTTP application that emits an OpenAPI 3.2 spec. + + Mirrors :class:`localpost.openapi.HttpApp` argument-for-argument; the + differences are: + + - Handlers must be ``async def`` (or ``async def`` generators for SSE). + - Middlewares must implement :class:`AsyncOpMiddleware`. + - :meth:`asgi` returns an ASGI 3 callable; :meth:`service` runs it + under uvicorn (or hypercorn) via :mod:`localpost.hosting`. + + Args: + info: Top-level OpenAPI :class:`Info` block. + middlewares: App-level :class:`AsyncOpMiddleware`-s. + openapi_path: URL the generated spec is served on; ``None`` to disable. + docs_path: Base URL for the built-in doc UIs. + docs_ui: Which doc UIs to mount. + adapters: Type adapters used for JSON Schema / decode / encode. + max_body_size: Maximum request-body bytes buffered before + dispatch. ``-1`` disables the limit. Defaults to 1 MiB. + """ + + def __init__( + self, + *, + info: openapi_spec.Info | None = None, + middlewares: Sequence[AsyncOpMiddleware] = (), + openapi_path: str | None = "/openapi.json", + docs_path: str | None = "/docs", + docs_ui: DocsUI = "all", + adapters: AdapterRegistry | None = None, + max_body_size: int = 1 << 20, + ) -> None: + for mw in middlewares: + _ensure_async_middleware(mw) + self._info = info or openapi_spec.Info() + self._middlewares = tuple(middlewares) + self._openapi_path = openapi_path + self._docs_path = docs_path + self._docs_ui = docs_ui + self._adapters = adapters or default_registry() + self._max_body_size = max_body_size + self._operations: list[AsyncOperation] = [] + self._lock = threading.Lock() + self._cached_spec: openapi_spec.OpenAPI | None = None + self._cached_spec_bytes: bytes | None = None + + # ----- Decorators ----- + + def get(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.GET, path, middlewares) + + def post(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.POST, path, middlewares) + + def put(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PUT, path, middlewares) + + def delete(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.DELETE, path, middlewares) + + def patch(self, path: str, *, middlewares: Sequence[AsyncOpMiddleware] = ()) -> _FluentDecorator: + return self._decorator(HTTPMethod.PATCH, path, middlewares) + + def _decorator( + self, + method: HTTPMethod, + path: str, + op_middlewares: Sequence[AsyncOpMiddleware], + ) -> _FluentDecorator: + for mw in op_middlewares: + _ensure_async_middleware(mw) + combined = (*self._middlewares, *op_middlewares) + + def deco(fn: Callable[..., Any]) -> Callable[..., Any]: + op = AsyncOperation.create(method, path, fn, middlewares=combined, adapters=self._adapters) + with self._lock: + self._operations.append(op) + self._cached_spec = None + self._cached_spec_bytes = None + return fn + + return deco + + # ----- Spec ----- + + @property + def operations(self) -> Sequence[AsyncOperation]: + return tuple(self._operations) + + @property + def openapi_doc(self) -> openapi_spec.OpenAPI: + """Return the (cached) OpenAPI 3.2 document.""" + with self._lock: + cached = self._cached_spec + if cached is not None: + return cached + registry = SchemaRegistry(self._adapters) + doc = openapi_spec.OpenAPI(info=self._info) + seen: set[int] = set() + for mw in self._all_middlewares(): + key = id(mw) + if key in seen: + continue + seen.add(key) + doc = mw.contribute_root(doc, registry) + for op in self._operations: + spec_op = op.build_spec(registry) + doc = doc.add_operation(op.path, op.method.value, spec_op) + doc = doc.with_components( + replace(doc.components, schemas={**doc.components.schemas, **registry.components()}) + ) + self._cached_spec = doc + return doc + + def _all_middlewares(self): + yield from self._middlewares + for op in self._operations: + yield from op.middlewares + + def _openapi_bytes(self) -> bytes: + with self._lock: + cached = self._cached_spec_bytes + if cached is not None: + return cached + body = self.openapi_doc.to_json() + with self._lock: + self._cached_spec_bytes = body + return body + + # ----- Hosting ----- + + def asgi(self) -> Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]]: + """Return an ASGI 3 callable that dispatches this app. + + Deploy with e.g. ``uvicorn myapp:asgi_app`` or + ``granian --interface asgi myapp:asgi_app``:: + + app = HttpAsyncApp() + asgi_app = app.asgi() + + The returned callable handles ``lifespan`` (no-op) and ``http`` + scopes. WebSocket scopes are rejected. + """ + return _AsgiDispatch.from_app(self) + + def service( + self, + config: Any, + *, + server: Literal["uvicorn", "hypercorn"] = "uvicorn", + ): + """Return a :func:`localpost.hosting.service` running this app. + + ``config`` is the host server's config object — + :class:`uvicorn.Config` for ``server="uvicorn"`` (the default) or + :class:`hypercorn.Config` for ``server="hypercorn"``. Both + servers are configured with the ASGI app from :meth:`asgi`. + + Pulls the ``localpost.hosting.services.uvicorn`` / + ``hypercorn`` adapters lazily so the import only happens when + the user picks that server. + """ + asgi_app = self.asgi() + if server == "uvicorn": + from localpost.hosting.services.uvicorn import uvicorn_server # noqa: PLC0415 + + config.app = asgi_app + return uvicorn_server(config) + if server == "hypercorn": + from localpost.hosting.services.hypercorn import hypercorn_server # noqa: PLC0415 + + return hypercorn_server(asgi_app, config) + raise ValueError(f"Unknown ASGI server: {server!r}") + + +def _ensure_async_middleware(mw: object) -> None: + """Reject sync :class:`OpMiddleware` instances at registration time. + + The whole point of the split is two parallel pipelines — silently + treating a sync middleware as async would block the event loop. + """ + if isinstance(mw, AsyncOpMiddleware): + return + name = type(mw).__name__ + raise TypeError( + f"HttpAsyncApp middleware {name!r} is not an AsyncOpMiddleware. " + f"Use @async_op_middleware (or AsyncHttpBearerAuth / AsyncHttpBasicAuth) for the async app." + ) + + +# --- ASGI dispatch ------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class _RouteEntry: + template: URITemplate + methods: dict[HTTPMethod, _AsyncHandler] + + +@dataclass(frozen=True, slots=True) +class _MatchOk: + template: URITemplate + path_args: dict[str, str] + handler: _AsyncHandler + + +@dataclass(frozen=True, slots=True) +class _MatchNotFound: + pass + + +@dataclass(frozen=True, slots=True) +class _MatchMethodNotAllowed: + allow: str + + +_MATCH_NOT_FOUND = _MatchNotFound() + + +class _AsgiDispatch: + """Composed ASGI dispatcher for an :class:`HttpAsyncApp`. + + Built once per :meth:`HttpAsyncApp.asgi` call. Stores the route table + and exposes a single ``__call__(scope, receive, send)`` that handles + ``lifespan`` and ``http`` scopes. + """ + + __slots__ = ("_entries", "_max_body_size") + + def __init__(self, entries: tuple[_RouteEntry, ...], max_body_size: int) -> None: + self._entries = entries + self._max_body_size = max_body_size + + @classmethod + def from_app(cls, app: HttpAsyncApp) -> _AsgiDispatch: + bucket: dict[str, _RouteEntry] = {} + + def add(method: HTTPMethod, template: URITemplate, handler: _AsyncHandler) -> None: + entry = bucket.setdefault(template.template, _RouteEntry(template=template, methods={})) + if method in entry.methods: + raise ValueError(f"Duplicate route: {method.value} {template.template}") + entry.methods[method] = handler + + for op in app.operations: + add(op.method, op.template, _async_op_handler(op)) + + if app._openapi_path: + add( + HTTPMethod.GET, + URITemplate.parse(app._openapi_path), + _static_handler(app._openapi_bytes, b"application/json"), + ) + if app._docs_path and app._openapi_path: + ui = app._docs_ui + html_ct = b"text/html; charset=utf-8" + if ui in ("swagger", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(app._docs_path), + _static_handler(_const_bytes(swagger_html(app._openapi_path)), html_ct), + ) + if ui in ("redoc", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(f"{app._docs_path}/redoc"), + _static_handler(_const_bytes(redoc_html(app._openapi_path)), html_ct), + ) + if ui in ("scalar", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(f"{app._docs_path}/scalar"), + _static_handler(_const_bytes(scalar_html(app._openapi_path)), html_ct), + ) + + return cls(entries=tuple(bucket.values()), max_body_size=app._max_body_size) + + async def __call__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: + kind = scope.get("type") + if kind == "lifespan": + await _handle_lifespan(receive, send) + return + if kind != "http": + raise ValueError(f"HttpAsyncApp: unsupported ASGI scope type: {kind!r}") + await self._handle_http(scope, receive, send) + + async def _handle_http(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: + path = scope["path"] + method_str = scope["method"] + try: + method = HTTPMethod(method_str) + except ValueError: + method = None + + match = self._match(path, method) + if isinstance(match, _MatchNotFound): + await _send_canned(send, 404, b"Not Found") + return + if isinstance(match, _MatchMethodNotAllowed): + await _send_canned( + send, + 405, + b"Method Not Allowed", + extra_headers=[(b"allow", match.allow.encode("ascii"))], + ) + return + + try: + body = await read_body(receive, self._max_body_size) + except ValueError: + await _send_canned(send, 413, b"Payload Too Large") + return + + request = build_request_from_scope(scope) + remote, local = addrs_from_scope(scope) + disconnected = threading.Event() + ctx = _ASGIReqCtx( + request=request, + body=body, + remote_addr=remote, + local_addr=local, + scheme=str(scope.get("scheme", "http")), + _send=send, + _disconnected=disconnected, + ) + ctx.attrs[RouteMatch] = RouteMatch( + method=method or HTTPMethod.GET, + matched_template=match.template, + path_args=match.path_args, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(_watch_disconnect, receive, disconnected) + try: + await match.handler(ctx) + finally: + tg.cancel_scope.cancel() + + def _match(self, path: str, method: HTTPMethod | None) -> _MatchOk | _MatchNotFound | _MatchMethodNotAllowed: + any_template_matched = False + allow: set[HTTPMethod] = set() + for entry in self._entries: + args = entry.template.match(path) + if args is None: + continue + any_template_matched = True + allow.update(entry.methods) + if method is None: + continue + handler = entry.methods.get(method) + if handler is None: + continue + return _MatchOk(template=entry.template, path_args=args, handler=handler) + if any_template_matched: + return _MatchMethodNotAllowed(allow=", ".join(sorted(m.value for m in allow))) + return _MATCH_NOT_FOUND + + +def _async_op_handler(op: AsyncOperation) -> _AsyncHandler: + async def run(ctx: _ASGIReqCtx) -> None: + await op.run(ctx) + + return run + + +def _static_handler(body_provider: Callable[[], bytes], content_type: bytes) -> _AsyncHandler: + async def run(ctx: _ASGIReqCtx) -> None: + body = body_provider() + response = Response( + status_code=200, + headers=[ + (b"content-type", content_type), + (b"content-length", str(len(body)).encode("ascii")), + ], + ) + await ctx.complete(response, body) + + return run + + +def _const_bytes(value: bytes) -> Callable[[], bytes]: + def provider() -> bytes: + return value + + return provider + + +async def _watch_disconnect(receive: ASGIReceive, flag: threading.Event) -> None: + """Drain ASGI events while the handler runs; flip ``flag`` on + ``http.disconnect``. Loop ends silently when the task group cancels.""" + try: + while True: + event = await receive() + if event.get("type") == "http.disconnect": + flag.set() + return + except Exception: # noqa: BLE001 + # ``receive`` may raise once the response is fully sent; treat as benign. + return + + +# --- ASGI lifespan / canned responses ----------------------------------- + + +async def _handle_lifespan(receive: ASGIReceive, send: ASGISend) -> None: + """Minimal lifespan loop — accept startup / shutdown events without + plugging into the user's hosting service. (The hosting integration + drives lifecycle through :mod:`localpost.hosting`.)""" + while True: + event = await receive() + kind = event.get("type") + if kind == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif kind == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + +async def _send_canned( + send: ASGISend, + status: int, + body: bytes, + *, + extra_headers: Sequence[tuple[bytes, bytes]] = (), +) -> None: + headers = [ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, + ] + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": body, "more_body": False}) diff --git a/localpost/openapi/aio/auth.py b/localpost/openapi/aio/auth.py new file mode 100644 index 0000000..999b210 --- /dev/null +++ b/localpost/openapi/aio/auth.py @@ -0,0 +1,170 @@ +"""Async sibling of :mod:`localpost.openapi.auth`. + +Same API surface as the sync :class:`HttpBearerAuth` / +:class:`HttpBasicAuth` — the differences: + +- Wraps an ``async def`` middleware (built via :func:`async_op_middleware`). +- The validator may be sync **or** async; async validators are awaited. +- :class:`AsyncOpMiddleware` :meth:`__call__` is async. + +OpenAPI contributions are identical to the sync flavour: the +``Authorization`` header parameter and the ``401`` response come from +the wrapped function's signature; the :class:`SecurityScheme` registration +is hand-written. +""" + +from __future__ import annotations + +import base64 +import binascii +import inspect +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import Annotated, Any + +from localpost.openapi import spec +from localpost.openapi.aio._ctx import AsyncHTTPReqCtx +from localpost.openapi.aio.middleware import AsyncApiOperation, _AsyncFunctionMiddleware, async_op_middleware +from localpost.openapi.resolvers import FromHeader +from localpost.openapi.results import OpResult, Unauthorized +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["AsyncHttpBearerAuth", "AsyncHttpBasicAuth"] + + +def _add_security_scheme(doc: spec.OpenAPI, name: str, scheme: spec.SecurityScheme) -> spec.OpenAPI: + components = replace( + doc.components, + security_schemes={**doc.components.security_schemes, name: scheme}, + ) + return replace(doc, components=components) + + +def _add_security_requirement(op: spec.Operation, scheme_name: str, scopes: tuple[str, ...] = ()) -> spec.Operation: + requirement: dict[str, tuple[str, ...]] = {scheme_name: scopes} + return replace(op, security=(*op.security, requirement)) + + +async def _maybe_await(value: Any) -> Any: + """Await ``value`` if it's awaitable; otherwise return it unchanged. + + Lets the user pick a sync or async validator without two parallel + code paths in the auth middleware. + """ + if inspect.isawaitable(value): + return await value + return value + + +@dataclass(slots=True, eq=False) +class AsyncHttpBearerAuth: + """Async ``Authorization: Bearer `` middleware. + + Args: + validator: ``token_str -> principal | None`` (sync or async). + Async validators are awaited. + scheme_name: Key under ``components.securitySchemes``. + bearer_format: Hint shown in the OpenAPI doc (e.g. ``"JWT"``). + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str], Any | None] | Callable[[str], Awaitable[Any | None]] + scheme_name: str = "bearerAuth" + bearer_format: str = "JWT" + description: str = "" + + _wrapped: _AsyncFunctionMiddleware = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + principal_key = self # stable identity across requests + + @async_op_middleware + async def _bearer( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Bearer "): + return Unauthorized("Missing or malformed Authorization header") + principal = await _maybe_await(validator(authorization[7:])) + if principal is None: + return Unauthorized("Invalid token") + ctx.attrs[principal_key] = principal + return await call_next(ctx) + + self._wrapped = _bearer + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + return await self._wrapped(ctx, call_next) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme( + type="http", + scheme="bearer", + bearer_format=self.bearer_format, + description=self.description, + ) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + op = self._wrapped.contribute_operation(op, registry) + return _add_security_requirement(op, self.scheme_name) + + +@dataclass(slots=True, eq=False) +class AsyncHttpBasicAuth: + """Async ``Authorization: Basic `` middleware. + + Args: + validator: ``(username, password) -> principal | None`` (sync or async). + scheme_name: Key under ``components.securitySchemes``. + realm: Realm sent in the ``WWW-Authenticate`` header on 401. + description: Optional ``description`` on the security scheme. + """ + + validator: Callable[[str, str], Any | None] | Callable[[str, str], Awaitable[Any | None]] + scheme_name: str = "basicAuth" + realm: str = "localpost" + description: str = "" + + _wrapped: _AsyncFunctionMiddleware = field(init=False, repr=False) + + def __post_init__(self) -> None: + validator = self.validator + challenge = {"WWW-Authenticate": f'Basic realm="{self.realm}"'} + principal_key = self + + @async_op_middleware + async def _basic( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + authorization: Annotated[str, FromHeader("Authorization")] = "", + ) -> Unauthorized[str] | OpResult: + if not authorization.startswith("Basic "): + return Unauthorized("Missing or malformed Authorization header", headers=challenge) + try: + decoded = base64.b64decode(authorization[6:], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return Unauthorized("Malformed Basic credentials", headers=challenge) + username, sep, password = decoded.partition(":") + if not sep: + return Unauthorized("Malformed Basic credentials", headers=challenge) + principal = await _maybe_await(validator(username, password)) + if principal is None: + return Unauthorized("Invalid credentials", headers=challenge) + ctx.attrs[principal_key] = principal + return await call_next(ctx) + + self._wrapped = _basic + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + return await self._wrapped(ctx, call_next) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + scheme = spec.SecurityScheme(type="http", scheme="basic", description=self.description) + return _add_security_scheme(doc, self.scheme_name, scheme) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + op = self._wrapped.contribute_operation(op, registry) + return _add_security_requirement(op, self.scheme_name) diff --git a/localpost/openapi/aio/middleware.py b/localpost/openapi/aio/middleware.py new file mode 100644 index 0000000..2728bf2 --- /dev/null +++ b/localpost/openapi/aio/middleware.py @@ -0,0 +1,188 @@ +"""``AsyncOpMiddleware`` — async sibling of :class:`OpMiddleware`. + +Same idea as the sync version: middleware wraps an operation core, +receives the request ctx + a ``call_next`` callable, returns an +:class:`OpResult`. The async flavour awaits ``call_next``, the user +function, and any user-side I/O. + +The OpenAPI doc-contribution surface (:meth:`contribute_root` / +:meth:`contribute_operation`) is identical to the sync protocol — those +methods build static spec documents and don't touch the event loop. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from localpost.openapi.results import OpResult + +if TYPE_CHECKING: + from localpost.openapi import spec + from localpost.openapi.aio._ctx import AsyncHTTPReqCtx + from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["AsyncApiOperation", "AsyncOpMiddleware", "async_op_middleware"] + + +type AsyncApiOperation = Callable[["AsyncHTTPReqCtx"], Awaitable["OpResult"]] +"""The shape of an async operation as seen by middleware. + +A middleware's ``call_next`` is an :class:`AsyncApiOperation` — given the +request context, it returns an awaitable :class:`OpResult` (which may be +an :class:`~localpost.openapi.results.EventStreamResult` for SSE handlers). +""" + + +@runtime_checkable +class AsyncOpMiddleware(Protocol): + """Async operation middleware that knows how to describe itself in OpenAPI. + + Differences from :class:`localpost.openapi.OpMiddleware`: + + - :meth:`__call__` is ``async`` and awaits ``call_next``. + - The ``ctx`` parameter is an :class:`AsyncHTTPReqCtx` rather than the + sync :class:`HTTPReqCtx`. + """ + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + """Run around the operation. Return an :class:`OpResult` — + typically by awaiting ``call_next(ctx)`` and forwarding (or + post-processing) its result, or by short-circuiting with a + middleware-specific result (e.g. ``Unauthorized``).""" + ... + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + """App-level OpenAPI contribution. Default: return ``doc`` unchanged.""" + ... + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + """Per-operation OpenAPI contribution. Default: return ``op`` unchanged.""" + ... + + +@dataclass(eq=False, slots=True) +class _AsyncFunctionMiddleware: + """Concrete :class:`AsyncOpMiddleware` produced by :func:`async_op_middleware`. + + Same shape as the sync :class:`_FunctionMiddleware`, but the wrapped + function is awaited and the OpenAPI contribution closure is identical + (it builds spec data, no I/O). + """ + + target: Callable[..., Awaitable[Any]] + call_next_param: str + arg_resolvers: tuple[tuple[str, Any], ...] + arg_factories: tuple[tuple[str, Any, Any | None], ...] + return_shapes: tuple[Any, ...] + + root_contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] | None = field(default=None) + + async def __call__(self, ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation, /) -> OpResult: + kwargs: dict[str, object] = {self.call_next_param: call_next} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + return value + kwargs[name] = value + result = await self.target(**kwargs) + if isinstance(result, OpResult): + return result + name = getattr(self.target, "__qualname__", None) or repr(self.target) + raise TypeError( + f"@async_op_middleware {name!r} returned {type(result).__name__}; " + f"middlewares must return an OpResult (typically by awaiting call_next)" + ) + + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: + if self.root_contribution is None: + return doc + return self.root_contribution(doc, registry) + + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: + from localpost.openapi._operation_core import build_responses # noqa: PLC0415 + + # Parameters / requestBody come from the resolver factories. + for _name, param, factory in self.arg_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + # Response codes come from the return-type union (sans the bare + # ``OpResult`` passthrough sentinel). Don't overwrite codes the + # operation already declared (the op's own response is more + # specific than the middleware's generic one). + for code, response in build_responses(self.return_shapes, registry).items(): + if code in op.responses: + continue + op = replace(op, responses={**op.responses, code: response}) + return op + + def with_root( + self, contribution: Callable[[spec.OpenAPI, SchemaRegistry], spec.OpenAPI] + ) -> _AsyncFunctionMiddleware: + """Return a copy that also contributes ``contribution`` at the root. + + Used by async auth middlewares to register their ``SecurityScheme`` + while still inheriting the parameter / response contribution from + the wrapped function's signature. + """ + return replace(self, root_contribution=contribution) + + +def async_op_middleware(fn: Callable[..., Awaitable[Any]]) -> _AsyncFunctionMiddleware: + """Wrap an ``async def`` function as an :class:`AsyncOpMiddleware`. + + The wrapped function looks like an async operation handler with one + extra parameter — the ``call_next`` callable — which the framework + passes in to let the middleware invoke the rest of the chain. Other + parameters are resolved from the request (via :class:`FromHeader`, + :class:`FromQuery`, :class:`FromBody`, …); the function must return + an :class:`OpResult` — typically by ``return await call_next(ctx)`` + for the passthrough path, or a short-circuit result like + ``Unauthorized(...)``. + + The OpenAPI contribution mirrors the sync :func:`op_middleware`: the + declared parameters land on every operation that uses this middleware, + and the response codes from the return-type union are added to those + operations' ``responses`` (without overwriting codes the operation + already declared). A bare ``OpResult`` member of the return union is + the passthrough sentinel and contributes nothing. + """ + if not inspect.iscoroutinefunction(fn): + name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + raise TypeError( + f"@async_op_middleware {name!r}: function must be an ``async def`` " + f"(use @op_middleware for sync middlewares)" + ) + + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + from localpost.openapi.aio._ctx import AsyncHTTPReqCtx as _AsyncCtx # noqa: PLC0415 + + call_next_param = _identify_call_next_param(fn) + sig, runtime, factories = build_arg_resolvers( + fn, + exclude={call_next_param}, + ctx_types=(_AsyncCtx,), + ) + shapes_list, _null_is_not_found = extract_response_shapes(sig.return_annotation) + return _AsyncFunctionMiddleware( + target=fn, + call_next_param=call_next_param, + arg_resolvers=tuple(runtime), + arg_factories=tuple(factories), + return_shapes=tuple(shapes_list), + ) + + +def _identify_call_next_param(fn: Callable[..., Any]) -> str: + """Pick the ``call_next`` parameter on ``fn``. Identification is by name.""" + sig = inspect.signature(fn) + if "call_next" in sig.parameters: + return "call_next" + name = getattr(fn, "__qualname__", None) or getattr(fn, "__name__", repr(fn)) + raise ValueError( + f"@async_op_middleware {name!r}: missing required 'call_next' parameter " + f"(of type AsyncApiOperation = Callable[[AsyncHTTPReqCtx], Awaitable[OpResult]])" + ) diff --git a/localpost/openapi/aio/operation.py b/localpost/openapi/aio/operation.py new file mode 100644 index 0000000..805aba3 --- /dev/null +++ b/localpost/openapi/aio/operation.py @@ -0,0 +1,237 @@ +"""Async per-operation wiring. + +:class:`AsyncOperation` is the async sibling of +:class:`localpost.openapi.Operation`. The signature parsing, +return-type inference, and OpenAPI doc emission are shared via +:mod:`localpost.openapi._operation_core`; only the request-time +invocation differs — every step that touches the user fn or the wire +is awaited. +""" + +from __future__ import annotations + +import inspect +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, replace +from http import HTTPMethod +from typing import Any, Self + +from localpost.http._types import Response as _Response +from localpost.http.router import URITemplate +from localpost.openapi import spec as openapi_spec +from localpost.openapi._operation_core import ( + SSE_RESPONSE_HEADERS as _SSE_RESPONSE_HEADERS, +) +from localpost.openapi._operation_core import ( + ResponseShape, + build_arg_resolvers, + build_http_response, + build_responses, + extract_response_shapes, + is_async_sse_payload, + is_sync_iterable, + iter_response_headers, +) +from localpost.openapi._operation_core import ( + operation_id as _make_operation_id, +) +from localpost.openapi._operation_core import ( + qualname as _qualname, +) +from localpost.openapi.adapters import AdapterRegistry, default_registry +from localpost.openapi.aio._ctx import AsyncHTTPReqCtx +from localpost.openapi.aio.middleware import AsyncApiOperation, AsyncOpMiddleware +from localpost.openapi.aio.sse import async_iter_events +from localpost.openapi.resolvers import ( + ArgResolver, + ArgResolverFactory, + FromPath, +) +from localpost.openapi.results import EventStreamResult, NotFound, Ok, OpResult +from localpost.openapi.schemas import SchemaRegistry + +__all__ = ["AsyncOperation"] + + +@dataclass(frozen=True, slots=True) +class AsyncOperation: + """Async sibling of :class:`localpost.openapi.Operation`. + + Same shape (method/path/template/target/resolvers/middlewares/return + shapes) — only the runtime methods are async. The build-spec path is + identical to the sync side and shares the type-level helpers. + """ + + method: HTTPMethod + path: str + template: URITemplate + target: Callable[..., Any] + """Async user fn — either a plain ``async def`` (returns a coroutine) + or an ``async def`` generator (returns an async iterator for SSE). + Both shapes are handled in :meth:`_run_core`.""" + arg_resolvers: tuple[tuple[str, ArgResolver], ...] + arg_resolver_factories: tuple[tuple[str, inspect.Parameter, ArgResolverFactory | None], ...] + middlewares: tuple[AsyncOpMiddleware, ...] + return_shapes: tuple[ResponseShape, ...] + null_is_not_found: bool + summary: str + operation_id: str + description: str + adapters: AdapterRegistry + + @classmethod + def create( + cls, + method: HTTPMethod, + path: str, + fn: Callable[..., Any], + /, + *, + middlewares: tuple[AsyncOpMiddleware, ...] = (), + adapters: AdapterRegistry | None = None, + ) -> Self: + if not (inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn)): + raise TypeError( + f"HttpAsyncApp handler {_qualname(fn)!r} must be ``async def`` (use HttpApp for sync handlers)" + ) + + template = URITemplate.parse(path) + path_var_names = set(template.variable_names) + registry = adapters or default_registry() + + sig, arg_resolvers, arg_factories = build_arg_resolvers( + fn, + path_var_names=path_var_names, + adapters=registry, + ctx_types=(AsyncHTTPReqCtx,), + ) + + # Validate path bindings: every {var} in the template must be + # claimed by a parameter. + bound_path_vars: set[str] = set() + for _name, param, factory in arg_factories: + if isinstance(factory, FromPath): + bound = factory.name or param.name + if bound not in path_var_names: + raise ValueError( + f"handler {_qualname(fn)!r}: parameter {param.name!r} uses FromPath" + f"({bound!r}) but path template {path!r} has no such variable" + f" (available: {sorted(path_var_names) or 'none'})" + ) + bound_path_vars.add(bound) + unbound = path_var_names - bound_path_vars + if unbound: + raise ValueError( + f"handler {_qualname(fn)!r}: path template {path!r} declares variable(s)" + f" {sorted(unbound)!r} that no parameter resolves" + ) + + shapes_list, null_is_not_found = extract_response_shapes(sig.return_annotation) + return_shapes = tuple(shapes_list) + + doc = inspect.getdoc(fn) or "" + summary = doc.split("\n", 1)[0] if doc else f"{method.value} {path}" + description = doc[len(summary) :].lstrip("\n") if doc else "" + op_id = _make_operation_id(method, path, fn) + + return cls( + method=method, + path=path, + template=template, + target=fn, + arg_resolvers=tuple(arg_resolvers), + arg_resolver_factories=tuple(arg_factories), + middlewares=middlewares, + return_shapes=return_shapes, + null_is_not_found=null_is_not_found, + summary=summary, + operation_id=op_id, + description=description, + adapters=registry, + ) + + # ----- runtime ----- + + async def run(self, ctx: AsyncHTTPReqCtx) -> None: + """Drive the full pipeline: middleware chain → arg resolvers → + user fn → response build → ``ctx.complete`` / ``ctx.stream``.""" + chain: AsyncApiOperation = self._run_core + for mw in reversed(self.middlewares): + chain = _wrap_middleware(mw, chain) + result = await chain(ctx) + await self._write_response(ctx, result) + + async def _run_core(self, ctx: AsyncHTTPReqCtx) -> OpResult: + kwargs: dict[str, object] = {} + for name, resolver in self.arg_resolvers: + value = resolver(ctx) + if isinstance(value, OpResult): + return value + kwargs[name] = value + result = self.target(**kwargs) + # ``async def`` -> coroutine; ``async def`` with ``yield`` -> async iterator. + if inspect.iscoroutine(result): + result = await result + if isinstance(result, OpResult): + return result + if is_sync_iterable(result): + name = _qualname(self.target) + raise TypeError( + f"HttpAsyncApp handler {name!r} returned a sync iterator/generator; " + f"use an ``async def`` generator (yields inside ``async def``) for SSE responses" + ) + if is_async_sse_payload(result): + return EventStreamResult(result) + if result is None and self.null_is_not_found: + return NotFound(None) + return Ok(result) + + async def _write_response(self, ctx: AsyncHTTPReqCtx, result: OpResult) -> None: + if isinstance(result, EventStreamResult): + await _stream_sse(ctx, result, self.adapters) + return + response, body = build_http_response(result, self.adapters) + await ctx.complete(response, body) + + # ----- spec build ----- + + def build_spec(self, registry: SchemaRegistry) -> openapi_spec.Operation: + op = openapi_spec.Operation( + summary=self.summary, + operation_id=self.operation_id, + description=self.description, + ) + for _name, param, factory in self.arg_resolver_factories: + if factory is None: + continue + op = factory.update_doc(param, op, registry) + responses = build_responses(self.return_shapes, registry) + op = replace(op, responses=responses) + for mw in self.middlewares: + op = mw.contribute_operation(op, registry) + return op + + +def _wrap_middleware(mw: AsyncOpMiddleware, call_next: AsyncApiOperation) -> AsyncApiOperation: + async def wrapped(ctx: AsyncHTTPReqCtx) -> OpResult: + return await mw(ctx, call_next) + + return wrapped + + +async def _stream_sse( + ctx: AsyncHTTPReqCtx, + result: EventStreamResult[Any], + adapters: AdapterRegistry, +) -> None: + """Run ``result``'s body as an SSE stream over an + :class:`AsyncHTTPReqCtx`. Mirrors the sync ``_stream_sse``.""" + headers = list(_SSE_RESPONSE_HEADERS) + seen = {name for name, _ in headers} + for name, value in iter_response_headers(result.headers): + if name.lower() in seen: + continue + headers.append((name, value)) + + chunks: AsyncIterator[bytes] = async_iter_events(result.body, adapters) + await ctx.stream(_Response(status_code=result.status_code, headers=headers), chunks) diff --git a/localpost/openapi/aio/sse.py b/localpost/openapi/aio/sse.py new file mode 100644 index 0000000..8d53f8d --- /dev/null +++ b/localpost/openapi/aio/sse.py @@ -0,0 +1,42 @@ +"""Async SSE drive — mirror of :mod:`localpost.openapi.sse`'s +:func:`iter_events`. + +The wire-format helpers (:class:`Event`, :func:`encode_event`, +:func:`format_data_field`) live in :mod:`localpost.openapi.sse` and are +shared between sync and async runtimes. Only the iteration over the +source stream is async-specific. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any, cast + +from localpost.openapi.sse import EventStream, encode_event + +if TYPE_CHECKING: + from localpost.openapi.adapters import AdapterRegistry + +__all__ = ["async_iter_events"] + + +async def async_iter_events( + source: object, + adapters: AdapterRegistry | None = None, +) -> AsyncIterator[bytes]: + """Drive ``source`` (an async generator, async iterator, or + :class:`EventStream` whose ``.source`` is itself async-iterable) into + a stream of SSE-encoded event bytes. + + Sync iterators are explicitly rejected — the async runtime won't + silently bridge a blocking generator onto the event loop. + """ + if isinstance(source, EventStream): + source = source.source + if not hasattr(source, "__aiter__"): + raise TypeError( + f"AsyncOperation: SSE source must be an async iterator/generator, got {type(source).__name__}; " + f"use ``async def`` generators in HttpAsyncApp handlers" + ) + async for item in cast(AsyncIterator[Any], source): + yield encode_event(item, adapters) diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py index ce189ba..7fa198b 100644 --- a/localpost/openapi/resolvers.py +++ b/localpost/openapi/resolvers.py @@ -16,7 +16,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, replace from types import NoneType, UnionType -from typing import TYPE_CHECKING, Annotated, Any, Protocol, Union, get_args, get_origin +from typing import TYPE_CHECKING, Annotated, Any, Protocol, Union, get_args, get_origin, runtime_checkable from urllib.parse import parse_qs import msgspec @@ -28,11 +28,12 @@ from localpost.openapi.schemas import SchemaRegistry if TYPE_CHECKING: - from localpost.http import HTTPReqCtx + from localpost.http._types import Request __all__ = [ "ArgResolver", "ArgResolverFactory", + "ResolverCtx", "FromBody", "FromHeader", "FromPath", @@ -46,8 +47,24 @@ _QUERY_CACHE_KEY = "__lp_openapi_query__" +@runtime_checkable +class ResolverCtx(Protocol): + """Minimal request-context shape an :class:`ArgResolver` reads. + + Both :class:`localpost.http.HTTPReqCtx` (sync) and + :class:`localpost.openapi.aio.AsyncHTTPReqCtx` (async) are + structurally compatible — they both expose ``request`` / ``body`` / + ``attrs`` synchronously. Restricting :class:`ArgResolver` to this + minimal protocol means the same factories work in both flavours. + """ + + request: Request + body: bytes + attrs: dict[Any, Any] + + class ArgResolver(Protocol): - def __call__(self, ctx: HTTPReqCtx, /) -> object | OpResult: ... + def __call__(self, ctx: ResolverCtx, /) -> object | OpResult: ... class ArgResolverFactory(Protocol): @@ -154,11 +171,11 @@ def is_body_type(t: Any, adapters: AdapterRegistry | None = None) -> bool: return registry.for_type(t).is_body_type(t) -def _route_match(ctx: HTTPReqCtx) -> RouteMatch: +def _route_match(ctx: ResolverCtx) -> RouteMatch: return ctx.attrs[RouteMatch] -def _query_args(ctx: HTTPReqCtx) -> dict[str, list[str]]: +def _query_args(ctx: ResolverCtx) -> dict[str, list[str]]: cached = ctx.attrs.get(_QUERY_CACHE_KEY) if cached is not None: return cached @@ -182,7 +199,7 @@ def __call__(self, param: inspect.Parameter, /) -> ArgResolver: var_name = self.name or param.name target = _unwrap_annotated(param.annotation) - def resolve(ctx: HTTPReqCtx) -> object | OpResult: + def resolve(ctx: ResolverCtx) -> object | OpResult: raw = _route_match(ctx).path_args.get(var_name) if raw is None: return BadRequest(f"Missing path parameter: {var_name}") @@ -237,7 +254,7 @@ def __call__(self, param: inspect.Parameter, /) -> ArgResolver: elem_type = _list_element_type(target) is_list = elem_type is not None - def resolve(ctx: HTTPReqCtx) -> object | OpResult: + def resolve(ctx: ResolverCtx) -> object | OpResult: values = _query_args(ctx).get(param_name) if not values: if optional: @@ -289,7 +306,7 @@ def __call__(self, param: inspect.Parameter, /) -> ArgResolver: header_name = (self.name or param.name.replace("_", "-")).lower().encode("ascii") target, optional, default = _is_optional_param(param) - def resolve(ctx: HTTPReqCtx) -> object | OpResult: + def resolve(ctx: ResolverCtx) -> object | OpResult: value: str | None = None for name, val in ctx.request.headers: if name == header_name: @@ -368,7 +385,7 @@ def _adapter_decode(body: bytes, _t: Any) -> object: converter = _adapter_decode - def resolve(ctx: HTTPReqCtx) -> object | OpResult: + def resolve(ctx: ResolverCtx) -> object | OpResult: try: return converter(ctx.body, target) except validation_errors as exc: From 6c7ce1d2bba94c6492b5fb49350665d41434376d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 10:53:36 +0400 Subject: [PATCH 201/286] test(openapi): add HttpAsyncApp smoke tests + tighten async-mw guard 52 new tests covering registration, runtime dispatch, async middleware, async auth (sync + async validators), SSE (async generators + sync-gen rejection), OpenAPI doc parity with HttpApp, and ASGI dispatch through the app's .asgi() callable (404 / 405 / 413 / openapi.json / docs UI / body-decode round-trip). Also tightens HttpAsyncApp's middleware guard: AsyncOpMiddleware is a structural Protocol whose method names overlap with the sync OpMiddleware, so isinstance() alone passes for sync middlewares. Now also asserts the unbound __call__ is a coroutine function. --- localpost/openapi/aio/app.py | 21 +- tests/openapi/aio_app.py | 641 +++++++++++++++++++++++++++++++++++ 2 files changed, 655 insertions(+), 7 deletions(-) create mode 100644 tests/openapi/aio_app.py diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py index 90dd88f..fe7ca7f 100644 --- a/localpost/openapi/aio/app.py +++ b/localpost/openapi/aio/app.py @@ -227,14 +227,21 @@ def _ensure_async_middleware(mw: object) -> None: The whole point of the split is two parallel pipelines — silently treating a sync middleware as async would block the event loop. + The :class:`AsyncOpMiddleware` Protocol is structural (it shares + method names with the sync :class:`OpMiddleware`), so isinstance + isn't enough — we additionally check that ``__call__`` is a + coroutine function. """ - if isinstance(mw, AsyncOpMiddleware): - return - name = type(mw).__name__ - raise TypeError( - f"HttpAsyncApp middleware {name!r} is not an AsyncOpMiddleware. " - f"Use @async_op_middleware (or AsyncHttpBearerAuth / AsyncHttpBasicAuth) for the async app." - ) + import inspect # noqa: PLC0415 + + call = getattr(type(mw), "__call__", None) # noqa: B004 — inspecting unbound method, not testing callability + if not inspect.iscoroutinefunction(call): + name = type(mw).__name__ + raise TypeError( + f"HttpAsyncApp middleware {name!r} is not an AsyncOpMiddleware " + f"(its ``__call__`` must be ``async def``). " + f"Use @async_op_middleware (or AsyncHttpBearerAuth / AsyncHttpBasicAuth) for the async app." + ) # --- ASGI dispatch ------------------------------------------------------- diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py new file mode 100644 index 0000000..a188fb5 --- /dev/null +++ b/tests/openapi/aio_app.py @@ -0,0 +1,641 @@ +"""Tests for ``localpost.openapi.HttpAsyncApp`` registration + ASGI dispatch. + +Two layers of coverage: + +1. **Operation-level**: feed an :class:`AsyncOperation` a fake + :class:`AsyncHTTPReqCtx`, run it, inspect the captured response. + Mirrors ``tests/openapi/app.py``'s sync ``run_op`` harness. +2. **ASGI-level**: drive the ASGI 3 callable from + :meth:`HttpAsyncApp.asgi` with a mock ``receive`` / ``send`` pair — + exercises the full route table, body buffering, and response + translation without spinning up a real server. +""" + +from __future__ import annotations + +import threading +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from http import HTTPMethod +from typing import Annotated, Any + +import pytest + +from localpost.http import RouteMatch, URITemplate +from localpost.http._types import Request, Response +from localpost.openapi import ( + AsyncHttpBearerAuth, + AsyncHTTPReqCtx, + AsyncOpMiddleware, + BadRequest, + Created, + FromHeader, + HttpApp, + HttpAsyncApp, + NotFound, + OpResult, + async_op_middleware, +) +from localpost.openapi.aio._ctx import _ASGIReqCtx +from localpost.openapi.aio.middleware import AsyncApiOperation +from localpost.openapi.aio.operation import AsyncOperation + +# --- Fakes --------------------------------------------------------------- + + +@dataclass(slots=True, eq=False) +class FakeAsyncCtx: + """Minimal async ctx for unit tests — captures the response.""" + + request: Request + body: bytes = b"" + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + completed: tuple[Response, bytes] | None = None + streamed: tuple[Response, list[bytes]] | None = None + + @property + def remote_addr(self) -> str | None: + return None + + @property + def local_addr(self) -> str: + return "127.0.0.1:0" + + @property + def scheme(self) -> str: + return "http" + + @property + def disconnected(self) -> bool: + return False + + async def complete(self, response: Response, body: bytes | None = None) -> None: + self.completed = (response, body or b"") + self.response_status = response.status_code + + async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: + captured: list[bytes] = [bytes(chunk) async for chunk in chunks] + self.streamed = (response, captured) + self.response_status = response.status_code + + +def make_async_ctx( + method: str = "GET", + path: str = "/", + query: bytes = b"", + headers: list[tuple[bytes, bytes]] | None = None, + body: bytes = b"", + path_args: dict[str, str] | None = None, + template: URITemplate | None = None, +) -> FakeAsyncCtx: + request = Request( + method=method.encode(), + target=path.encode(), + path=path.encode(), + query_string=query, + headers=headers or [], + http_version=b"1.1", + ) + ctx = FakeAsyncCtx(request=request, body=body) + if path_args is not None: + ctx.attrs[RouteMatch] = RouteMatch( + method=HTTPMethod(method), + matched_template=template or URITemplate.parse(path), + path_args=dict(path_args), + ) + return ctx + + +async def run_async_op(op: AsyncOperation, ctx: FakeAsyncCtx) -> tuple[int, bytes, dict[str, str]]: + await op.run(ctx) + assert ctx.completed is not None, "handler did not complete the response" + response, body = ctx.completed + headers = {k.decode(): v.decode("iso-8859-1") for k, v in response.headers} + return response.status_code, body, headers + + +# --- Sample types -------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +# --- Registration & basic dispatch -------------------------------------- + + +class TestRegistration: + def test_async_handler_records_operation(self): + app = HttpAsyncApp() + + @app.get("/foo") + async def foo() -> str: + return "ok" + + assert len(app.operations) == 1 + op = app.operations[0] + assert op.method is HTTPMethod.GET + assert op.path == "/foo" + + def test_sync_handler_rejected(self): + app = HttpAsyncApp() + + with pytest.raises(TypeError, match="must be ``async def``"): + + @app.get("/foo") + def foo() -> str: + return "ok" + + def test_sync_middleware_rejected_at_construction(self): + from localpost.openapi import OpMiddleware # noqa: PLC0415 + from localpost.openapi.middleware import _FunctionMiddleware, op_middleware # noqa: PLC0415 + + @op_middleware + def sync_mw(ctx, call_next) -> OpResult: # type: ignore[no-untyped-def] + return call_next(ctx) + + assert isinstance(sync_mw, _FunctionMiddleware) + assert isinstance(sync_mw, OpMiddleware) + + with pytest.raises(TypeError, match="not an AsyncOpMiddleware"): + HttpAsyncApp(middlewares=[sync_mw]) # type: ignore[list-item] + + def test_async_middleware_accepted(self): + @async_op_middleware + async def mw(ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation) -> OpResult: + return await call_next(ctx) + + assert isinstance(mw, AsyncOpMiddleware) + app = HttpAsyncApp(middlewares=[mw]) + assert len(app._middlewares) == 1 + + +# --- Operation runtime -------------------------------------------------- + + +class TestRuntime: + @pytest.mark.anyio + async def test_str_return_emits_text(self): + app = HttpAsyncApp() + + @app.get("/hi") + async def hi() -> str: + return "hello" + + op = app.operations[0] + status, body, headers = await run_async_op(op, make_async_ctx(path="/hi")) + assert status == 200 + assert body == b"hello" + assert headers["content-type"].startswith("text/plain") + + @pytest.mark.anyio + async def test_dataclass_return_emits_json(self): + app = HttpAsyncApp() + + @app.get("/b") + async def get() -> Book: + return Book(id="1", title="t") + + op = app.operations[0] + status, body, headers = await run_async_op(op, make_async_ctx(path="/b")) + assert status == 200 + assert body == b'{"id":"1","title":"t"}' + assert headers["content-type"].startswith("application/json") + + @pytest.mark.anyio + async def test_path_var_implicit(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: str) -> str: + return item_id + + op = app.operations[0] + ctx = make_async_ctx(path="/items/abc", path_args={"item_id": "abc"}) + status, body, _ = await run_async_op(op, ctx) + assert status == 200 + assert body == b"abc" + + @pytest.mark.anyio + async def test_query_param_typed(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: str, page: int = 1) -> str: + return f"{item_id}/{page}" + + op = app.operations[0] + ctx = make_async_ctx(path="/items/x", query=b"page=3", path_args={"item_id": "x"}) + status, body, _ = await run_async_op(op, ctx) + assert status == 200 + assert body == b"x/3" + + @pytest.mark.anyio + async def test_body_decode_dataclass(self): + app = HttpAsyncApp() + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + op = app.operations[0] + ctx = make_async_ctx(method="POST", path="/b", body=b'{"id":"1","title":"t"}') + status, body, _ = await run_async_op(op, ctx) + assert status == 201 + assert body == b'{"id":"1","title":"t"}' + + @pytest.mark.anyio + async def test_op_result_short_circuit(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: str) -> Book | NotFound[str]: + return NotFound(f"missing {item_id}") + + op = app.operations[0] + ctx = make_async_ctx(path="/items/x", path_args={"item_id": "x"}) + status, body, _ = await run_async_op(op, ctx) + assert status == 404 + assert body == b"missing x" + + @pytest.mark.anyio + async def test_resolver_validation_error_short_circuits(self): + app = HttpAsyncApp() + + @app.get("/items/{item_id}") + async def get_item(item_id: int) -> str: + return str(item_id) + + op = app.operations[0] + ctx = make_async_ctx(path="/items/x", path_args={"item_id": "x"}) + status, _, _ = await run_async_op(op, ctx) + assert status == 400 + + @pytest.mark.anyio + async def test_async_middleware_short_circuits(self): + @async_op_middleware + async def block( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_block: Annotated[str, FromHeader("X-Block")] = "", + ) -> BadRequest[str] | OpResult: + if x_block == "yes": + return BadRequest("blocked by mw") + return await call_next(ctx) + + app = HttpAsyncApp(middlewares=[block]) + + @app.get("/x") + async def x() -> str: + return "ok" + + op = app.operations[0] + ctx = make_async_ctx(path="/x", headers=[(b"x-block", b"yes")]) + status, body, _ = await run_async_op(op, ctx) + assert status == 400 + assert body == b"blocked by mw" + + @pytest.mark.anyio + async def test_async_middleware_passthrough(self): + @async_op_middleware + async def passthrough(ctx: AsyncHTTPReqCtx, call_next: AsyncApiOperation) -> OpResult: + return await call_next(ctx) + + app = HttpAsyncApp(middlewares=[passthrough]) + + @app.get("/x") + async def x() -> str: + return "ok" + + op = app.operations[0] + status, body, _ = await run_async_op(op, make_async_ctx(path="/x")) + assert status == 200 + assert body == b"ok" + + +# --- SSE ----------------------------------------------------------------- + + +class TestSSE: + @pytest.mark.anyio + async def test_async_generator_streams_events(self): + app = HttpAsyncApp() + + @app.get("/events") + async def events() -> AsyncIterator[str]: + for i in range(3): + yield f"tick-{i}" + + op = app.operations[0] + ctx = make_async_ctx(path="/events") + await op.run(ctx) + assert ctx.streamed is not None + response, chunks = ctx.streamed + assert response.status_code == 200 + ct = next(v for k, v in response.headers if k == b"content-type") + assert ct.startswith(b"text/event-stream") + wire = b"".join(chunks) + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire + + @pytest.mark.anyio + async def test_sync_generator_rejected(self): + app = HttpAsyncApp() + + @app.get("/bad") + async def bad(): + def sync_gen(): + yield "x" + + return sync_gen() + + op = app.operations[0] + with pytest.raises(TypeError, match="sync iterator/generator"): + await op.run(make_async_ctx(path="/bad")) + + +# --- OpenAPI doc parity -------------------------------------------------- + + +class TestSpecParity: + def test_async_app_doc_matches_sync_for_same_routes(self): + # Same routes registered on both flavours should produce the same + # operationId / responses / parameters in the OpenAPI doc. + sync_app = HttpApp() + async_app = HttpAsyncApp() + + @sync_app.get("/items/{item_id}") + def get_item_sync(item_id: str) -> Book | NotFound[str]: + return NotFound("nope") + + @async_app.get("/items/{item_id}") + async def get_item_async(item_id: str) -> Book | NotFound[str]: + return NotFound("nope") + + sync_doc = sync_app.openapi_doc + async_doc = async_app.openapi_doc + s_op = sync_doc.paths["/items/{item_id}"].operations["get"] + a_op = async_doc.paths["/items/{item_id}"].operations["get"] + assert sorted(s_op.responses) == sorted(a_op.responses) + assert [p.name for p in s_op.parameters] == [p.name for p in a_op.parameters] + + +# --- ASGI dispatch ------------------------------------------------------- + + +async def _drive_asgi( + asgi_app: Any, + method: str, + path: str, + *, + body: bytes = b"", + headers: list[tuple[bytes, bytes]] | None = None, + query: bytes = b"", +) -> tuple[int, bytes, list[tuple[bytes, bytes]]]: + """Drive a single HTTP request through an ASGI app and capture the response.""" + scope: dict[str, Any] = { + "type": "http", + "method": method, + "path": path, + "raw_path": path.encode("utf-8"), + "query_string": query, + "headers": headers or [], + "scheme": "http", + "http_version": "1.1", + "client": ["127.0.0.1", 12345], + "server": ["127.0.0.1", 8000], + } + request_done = False + + async def receive() -> dict[str, Any]: + nonlocal request_done + if not request_done: + request_done = True + return {"type": "http.request", "body": body, "more_body": False} + # After the body is delivered, hold open until the response is sent. + # The dispatcher cancels the watcher task once the handler returns. + await _forever() + raise AssertionError("unreachable") + + captured_status: list[int] = [] + captured_headers: list[list[tuple[bytes, bytes]]] = [] + captured_body = bytearray() + + async def send(event: dict[str, Any]) -> None: + kind = event["type"] + if kind == "http.response.start": + captured_status.append(event["status"]) + captured_headers.append(list(event["headers"])) + elif kind == "http.response.body": + captured_body.extend(event.get("body", b"")) + + await asgi_app(scope, receive, send) + assert captured_status, "handler did not start the response" + return captured_status[0], bytes(captured_body), captured_headers[0] + + +async def _forever() -> None: + import anyio # noqa: PLC0415 + + await anyio.sleep_forever() + + +class TestAsgiDispatch: + @pytest.mark.anyio + async def test_simple_get(self): + app = HttpAsyncApp() + + @app.get("/hello/{name}") + async def hello(name: str) -> str: + return f"hi {name}" + + asgi_app = app.asgi() + status, body, _ = await _drive_asgi(asgi_app, "GET", "/hello/world") + assert status == 200 + assert body == b"hi world" + + @pytest.mark.anyio + async def test_404(self): + app = HttpAsyncApp() + + asgi_app = app.asgi() + status, body, _ = await _drive_asgi(asgi_app, "GET", "/nope") + assert status == 404 + assert body == b"Not Found" + + @pytest.mark.anyio + async def test_405_method_not_allowed(self): + app = HttpAsyncApp() + + @app.get("/foo") + async def foo() -> str: + return "ok" + + asgi_app = app.asgi() + status, _, headers = await _drive_asgi(asgi_app, "POST", "/foo") + assert status == 405 + allow = next(v for k, v in headers if k == b"allow") + assert b"GET" in allow + + @pytest.mark.anyio + async def test_post_with_body(self): + app = HttpAsyncApp() + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + asgi_app = app.asgi() + status, body, _ = await _drive_asgi(asgi_app, "POST", "/b", body=b'{"id":"1","title":"t"}') + assert status == 201 + assert body == b'{"id":"1","title":"t"}' + + @pytest.mark.anyio + async def test_openapi_endpoint(self): + app = HttpAsyncApp() + + @app.get("/x") + async def x() -> str: + return "ok" + + asgi_app = app.asgi() + status, body, headers = await _drive_asgi(asgi_app, "GET", "/openapi.json") + assert status == 200 + ct = next(v for k, v in headers if k == b"content-type") + assert ct == b"application/json" + assert b'"openapi"' in body + + @pytest.mark.anyio + async def test_docs_endpoint(self): + app = HttpAsyncApp() + asgi_app = app.asgi() + status, body, headers = await _drive_asgi(asgi_app, "GET", "/docs") + assert status == 200 + ct = next(v for k, v in headers if k == b"content-type") + assert ct.startswith(b"text/html") + assert b"swagger" in body.lower() + + @pytest.mark.anyio + async def test_payload_too_large(self): + app = HttpAsyncApp(max_body_size=8) + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + asgi_app = app.asgi() + status, _, _ = await _drive_asgi(asgi_app, "POST", "/b", body=b'{"id":"1","title":"way-too-long"}') + assert status == 413 + + +# --- Async auth --------------------------------------------------------- + + +class TestAsyncAuth: + @pytest.mark.anyio + async def test_bearer_valid_token(self): + bearer = AsyncHttpBearerAuth(validator=lambda t: {"sub": t} if t == "good" else None) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_async_ctx(path="/me", headers=[(b"authorization", b"Bearer good")]) + status, body, _ = await run_async_op(op, ctx) + assert status == 200 + assert body == b"hello" + + @pytest.mark.anyio + async def test_bearer_invalid_token(self): + bearer = AsyncHttpBearerAuth(validator=lambda _t: None) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_async_ctx(path="/me", headers=[(b"authorization", b"Bearer bad")]) + status, _, _ = await run_async_op(op, ctx) + assert status == 401 + + @pytest.mark.anyio + async def test_bearer_async_validator(self): + async def validate(t: str) -> dict[str, str] | None: + return {"sub": t} if t == "good" else None + + bearer = AsyncHttpBearerAuth(validator=validate) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hello" + + op = app.operations[0] + ctx = make_async_ctx(path="/me", headers=[(b"authorization", b"Bearer good")]) + status, _, _ = await run_async_op(op, ctx) + assert status == 200 + + def test_bearer_registers_security_scheme(self): + bearer = AsyncHttpBearerAuth(validator=lambda _t: None) + app = HttpAsyncApp(middlewares=[bearer]) + + @app.get("/me") + async def me() -> str: + return "hi" + + doc = app.openapi_doc + assert "bearerAuth" in doc.components.security_schemes + op_spec = doc.paths["/me"].operations["get"] + assert {"bearerAuth": ()} in op_spec.security + + +# --- ASGI ctx unit tests ------------------------------------------------ + + +class TestAsgiCtxUnit: + @pytest.mark.anyio + async def test_complete_emits_start_then_body(self): + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + ctx = _ASGIReqCtx( + request=Request(b"GET", b"/", b"/", b"", []), + body=b"", + remote_addr=None, + local_addr="0.0.0.0:0", + scheme="http", + _send=send, + _disconnected=threading.Event(), + ) + await ctx.complete(Response(status_code=200, headers=[(b"x-y", b"z")]), b"hello") + assert [e["type"] for e in events] == ["http.response.start", "http.response.body"] + assert events[0]["status"] == 200 + assert (b"x-y", b"z") in events[0]["headers"] + assert events[1]["body"] == b"hello" + assert events[1]["more_body"] is False + + @pytest.mark.anyio + async def test_complete_twice_raises(self): + async def send(_event: dict[str, Any]) -> None: + pass + + ctx = _ASGIReqCtx( + request=Request(b"GET", b"/", b"/", b"", []), + body=b"", + remote_addr=None, + local_addr="0.0.0.0:0", + scheme="http", + _send=send, + _disconnected=threading.Event(), + ) + await ctx.complete(Response(200), b"x") + with pytest.raises(RuntimeError, match="already started"): + await ctx.complete(Response(200), b"y") From 76053985132ec1ef4c9bbc7fe34abfb2452c606f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 12:49:52 +0400 Subject: [PATCH 202/286] test(openapi): drive HttpAsyncApp via httpx + add uvicorn integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aio_app.py: replace the hand-rolled mocked-ASGI _drive_asgi helper with httpx.ASGITransport. Cleaner, exercises the actual ASGI surface httpx uses, and adds a real chunked-SSE round-trip via httpx.AsyncClient.stream. - aio_integration.py (new, @pytest.mark.integration): spin up uvicorn in a daemon thread per-test and reach it over loopback with httpx. Catches what in-process drivers miss — header round-trip through h11 + uvicorn, ASGI lifespan negotiation, real-socket SSE delivery (per-event chunked flushing, no content-length), peer-disconnect mid-stream, and 413 from an oversized body. --- tests/openapi/aio_app.py | 159 ++++++++---------- tests/openapi/aio_integration.py | 268 +++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 89 deletions(-) create mode 100644 tests/openapi/aio_integration.py diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py index a188fb5..b3f27aa 100644 --- a/tests/openapi/aio_app.py +++ b/tests/openapi/aio_app.py @@ -19,6 +19,7 @@ from http import HTTPMethod from typing import Annotated, Any +import httpx import pytest from localpost.http import RouteMatch, URITemplate @@ -161,7 +162,7 @@ def sync_mw(ctx, call_next) -> OpResult: # type: ignore[no-untyped-def] assert isinstance(sync_mw, OpMiddleware) with pytest.raises(TypeError, match="not an AsyncOpMiddleware"): - HttpAsyncApp(middlewares=[sync_mw]) # type: ignore[list-item] + HttpAsyncApp(middlewares=[sync_mw]) # ty: ignore[invalid-argument-type] def test_async_middleware_accepted(self): @async_op_middleware @@ -380,67 +381,26 @@ async def get_item_async(item_id: str) -> Book | NotFound[str]: s_op = sync_doc.paths["/items/{item_id}"].operations["get"] a_op = async_doc.paths["/items/{item_id}"].operations["get"] assert sorted(s_op.responses) == sorted(a_op.responses) - assert [p.name for p in s_op.parameters] == [p.name for p in a_op.parameters] + # ``parameters`` may contain ``Reference`` per OpenAPI; the test only + # uses inline ``Parameter`` instances, so a getattr fallback is safe. + assert [getattr(p, "name", "") for p in s_op.parameters] == [ + getattr(p, "name", "") for p in a_op.parameters + ] # --- ASGI dispatch ------------------------------------------------------- -async def _drive_asgi( - asgi_app: Any, - method: str, - path: str, - *, - body: bytes = b"", - headers: list[tuple[bytes, bytes]] | None = None, - query: bytes = b"", -) -> tuple[int, bytes, list[tuple[bytes, bytes]]]: - """Drive a single HTTP request through an ASGI app and capture the response.""" - scope: dict[str, Any] = { - "type": "http", - "method": method, - "path": path, - "raw_path": path.encode("utf-8"), - "query_string": query, - "headers": headers or [], - "scheme": "http", - "http_version": "1.1", - "client": ["127.0.0.1", 12345], - "server": ["127.0.0.1", 8000], - } - request_done = False - - async def receive() -> dict[str, Any]: - nonlocal request_done - if not request_done: - request_done = True - return {"type": "http.request", "body": body, "more_body": False} - # After the body is delivered, hold open until the response is sent. - # The dispatcher cancels the watcher task once the handler returns. - await _forever() - raise AssertionError("unreachable") - - captured_status: list[int] = [] - captured_headers: list[list[tuple[bytes, bytes]]] = [] - captured_body = bytearray() - - async def send(event: dict[str, Any]) -> None: - kind = event["type"] - if kind == "http.response.start": - captured_status.append(event["status"]) - captured_headers.append(list(event["headers"])) - elif kind == "http.response.body": - captured_body.extend(event.get("body", b"")) - - await asgi_app(scope, receive, send) - assert captured_status, "handler did not start the response" - return captured_status[0], bytes(captured_body), captured_headers[0] - - -async def _forever() -> None: - import anyio # noqa: PLC0415 - - await anyio.sleep_forever() +def _asgi_client(app: HttpAsyncApp) -> httpx.AsyncClient: + """Build an httpx AsyncClient bound to ``app``'s ASGI callable. + + ``ASGITransport`` drives the app in-process — no socket, no event loop + bridge. Wraps the same surface a real ASGI server would call against, + so this is closer to "real" than the mocked-scope helper that lived + here before. + """ + asgi_app: Any = app.asgi() + return httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi_app), base_url="http://testserver") class TestAsgiDispatch: @@ -452,19 +412,19 @@ async def test_simple_get(self): async def hello(name: str) -> str: return f"hi {name}" - asgi_app = app.asgi() - status, body, _ = await _drive_asgi(asgi_app, "GET", "/hello/world") - assert status == 200 - assert body == b"hi world" + async with _asgi_client(app) as client: + resp = await client.get("/hello/world") + assert resp.status_code == 200 + assert resp.text == "hi world" @pytest.mark.anyio async def test_404(self): app = HttpAsyncApp() - asgi_app = app.asgi() - status, body, _ = await _drive_asgi(asgi_app, "GET", "/nope") - assert status == 404 - assert body == b"Not Found" + async with _asgi_client(app) as client: + resp = await client.get("/nope") + assert resp.status_code == 404 + assert resp.text == "Not Found" @pytest.mark.anyio async def test_405_method_not_allowed(self): @@ -474,11 +434,10 @@ async def test_405_method_not_allowed(self): async def foo() -> str: return "ok" - asgi_app = app.asgi() - status, _, headers = await _drive_asgi(asgi_app, "POST", "/foo") - assert status == 405 - allow = next(v for k, v in headers if k == b"allow") - assert b"GET" in allow + async with _asgi_client(app) as client: + resp = await client.post("/foo") + assert resp.status_code == 405 + assert "GET" in resp.headers["allow"] @pytest.mark.anyio async def test_post_with_body(self): @@ -488,10 +447,10 @@ async def test_post_with_body(self): async def create(book: Book) -> Created[Book]: return Created(book) - asgi_app = app.asgi() - status, body, _ = await _drive_asgi(asgi_app, "POST", "/b", body=b'{"id":"1","title":"t"}') - assert status == 201 - assert body == b'{"id":"1","title":"t"}' + async with _asgi_client(app) as client: + resp = await client.post("/b", content=b'{"id":"1","title":"t"}') + assert resp.status_code == 201 + assert resp.content == b'{"id":"1","title":"t"}' @pytest.mark.anyio async def test_openapi_endpoint(self): @@ -501,22 +460,20 @@ async def test_openapi_endpoint(self): async def x() -> str: return "ok" - asgi_app = app.asgi() - status, body, headers = await _drive_asgi(asgi_app, "GET", "/openapi.json") - assert status == 200 - ct = next(v for k, v in headers if k == b"content-type") - assert ct == b"application/json" - assert b'"openapi"' in body + async with _asgi_client(app) as client: + resp = await client.get("/openapi.json") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + assert '"openapi"' in resp.text @pytest.mark.anyio async def test_docs_endpoint(self): app = HttpAsyncApp() - asgi_app = app.asgi() - status, body, headers = await _drive_asgi(asgi_app, "GET", "/docs") - assert status == 200 - ct = next(v for k, v in headers if k == b"content-type") - assert ct.startswith(b"text/html") - assert b"swagger" in body.lower() + async with _asgi_client(app) as client: + resp = await client.get("/docs") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/html") + assert "swagger" in resp.text.lower() @pytest.mark.anyio async def test_payload_too_large(self): @@ -526,9 +483,33 @@ async def test_payload_too_large(self): async def create(book: Book) -> Created[Book]: return Created(book) - asgi_app = app.asgi() - status, _, _ = await _drive_asgi(asgi_app, "POST", "/b", body=b'{"id":"1","title":"way-too-long"}') - assert status == 413 + async with _asgi_client(app) as client: + resp = await client.post("/b", content=b'{"id":"1","title":"way-too-long"}') + assert resp.status_code == 413 + + @pytest.mark.anyio + async def test_sse_round_trip(self): + """Real httpx round-trip over an SSE stream — the mocked helper + could only inspect the captured chunks, this drives the actual + chunked-response machinery in httpx.""" + app = HttpAsyncApp() + + @app.get("/events") + async def events() -> AsyncIterator[str]: + for i in range(3): + yield f"tick-{i}" + + async with _asgi_client(app) as client: + async with client.stream("GET", "/events") as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + wire = b"" + async for chunk in resp.aiter_bytes(): + wire += chunk + # Each event is "data: \n\n" — three of them. + assert wire.count(b"\n\n") == 3 + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire # --- Async auth --------------------------------------------------------- diff --git a/tests/openapi/aio_integration.py b/tests/openapi/aio_integration.py new file mode 100644 index 0000000..3e0a245 --- /dev/null +++ b/tests/openapi/aio_integration.py @@ -0,0 +1,268 @@ +"""End-to-end HTTP tests against ``HttpAsyncApp`` deployed under uvicorn. + +The unit suite (``aio_app.py``) drives the ASGI callable directly via +``httpx.ASGITransport`` — fast, no socket. These tests run the full stack +under a real uvicorn instance to catch what in-process drivers can't: + +- Header round-trip through h11 + uvicorn. +- ASGI lifespan negotiation. +- Real chunked SSE delivery (per-event flushing on the wire). +- Peer-disconnect mid-stream. + +uvicorn runs in a background thread per-test (its own asyncio loop); the +test reaches it over loopback. Mirrors ``tests/openapi/conftest.py``'s +sync ``serve_app`` shape so the fixtures read alike. +""" + +from __future__ import annotations + +import asyncio +import socket +import threading +import time +from collections.abc import AsyncIterator, Callable, Iterator +from dataclasses import dataclass + +import httpx +import pytest +import uvicorn + +from localpost.openapi import ( + BadRequest, + Created, + HttpAsyncApp, + NotFound, + spec, +) + +# Keep these tests off the unit run; they cost ~1-2s each. +pytestmark = pytest.mark.integration + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _UvicornThread: + """Run uvicorn in a daemon thread, surface its port to the test. + + uvicorn drives its own asyncio loop; we just hand it the ASGI app + (``HttpAsyncApp.asgi()``) and a free port. ``__enter__`` blocks until + the server is accepting connections, ``__exit__`` triggers a graceful + shutdown. + """ + + def __init__(self, app: HttpAsyncApp, port: int) -> None: + self._asgi = app.asgi() + self._port = port + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + self._error: BaseException | None = None + + def __enter__(self) -> int: + config = uvicorn.Config( + app=self._asgi, + host="127.0.0.1", + port=self._port, + log_level="warning", + access_log=False, + loop="asyncio", + lifespan="on", + ) + server = uvicorn.Server(config) + self._server = server + + def run() -> None: + try: + asyncio.run(server.serve()) + except BaseException as e: # noqa: BLE001 + self._error = e + + self._thread = threading.Thread(target=run, daemon=True, name=f"uvicorn-{self._port}") + self._thread.start() + # Wait for "started" — uvicorn flips ``server.started`` once the + # socket is listening. Bound to ~5s so a stuck startup fails fast. + deadline = time.monotonic() + 5.0 + while not server.started and time.monotonic() < deadline: + if self._error is not None: + raise self._error + time.sleep(0.01) + if not server.started: + raise RuntimeError("uvicorn failed to start within 5s") + return self._port + + def __exit__(self, *_exc: object) -> None: + if self._server is not None: + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=5) + if self._error is not None: + raise self._error + + +ServeAsyncApp = Callable[[HttpAsyncApp], _UvicornThread] + + +@pytest.fixture +def serve_async_app() -> Iterator[ServeAsyncApp]: + active: list[_UvicornThread] = [] + + def make(app: HttpAsyncApp) -> _UvicornThread: + return _UvicornThread(app, _free_port()) + + yield make + + # If a test forgot to ``__exit__``, drain anything still in-flight. + for t in active: + if t._server is not None: + t._server.should_exit = True + if t._thread is not None and t._thread.is_alive(): + t._thread.join(timeout=5) + + +# --- Sample app ---------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +@pytest.fixture +def library_app() -> HttpAsyncApp: + app = HttpAsyncApp(info=spec.Info(title="Library API", version="1.0.0")) + library: dict[str, Book] = {"42": Book(id="42", title="HHGTTG")} + + @app.get("/hello/{name}") + async def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + async def get_book(book_id: str) -> Book | NotFound[str]: + book = library.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + @app.post("/books") + async def create_book(book: Book) -> Created[Book]: + library[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + @app.get("/clock") + async def clock() -> AsyncIterator[str]: + # Three quick events for SSE round-trip / peer-disconnect tests. + # Cap the loop so a hung client doesn't keep the worker alive. + for i in range(3): + yield f"tick-{i}" + await asyncio.sleep(0.05) + + _ = (hello, get_book, create_book, clock) + return app + + +# --- Tests --------------------------------------------------------------- + + +class TestRoundTrip: + def test_hello_world(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + assert resp.status_code == 200 + assert resp.text == "Hello, world!" + assert resp.headers["content-type"].startswith("text/plain") + + def test_op_result_404_branch(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/missing", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Book not found: missing" + + def test_post_json_round_trip(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + json={"id": "7", "title": "Dune"}, + timeout=5, + ) + assert resp.status_code == 201 + assert resp.headers["Location"] == "/books/7" + assert resp.json() == {"id": "7", "title": "Dune"} + + +class TestBuiltInRoutes: + def test_openapi_json(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + doc = resp.json() + assert doc["openapi"].startswith("3.2") + assert "/hello/{name}" in doc["paths"] + assert "Book" in doc["components"]["schemas"] + + def test_swagger_ui(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + with serve_async_app(library_app) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/docs", timeout=5) + assert resp.status_code == 200 + assert b"Swagger UI" in resp.content + + +class TestSSE: + def test_chunked_event_delivery(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: + """Exercise the wire: each event must arrive as a separate + ``\\n\\n``-terminated block; ``content-length`` must be absent + (chunked transfer).""" + with serve_async_app(library_app) as port: + with httpx.stream("GET", f"http://127.0.0.1:{port}/clock", timeout=5) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + assert "content-length" not in resp.headers + wire = b"".join(resp.iter_bytes()) + assert wire.count(b"\n\n") == 3 + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire + + def test_peer_disconnect_mid_stream( + self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp + ) -> None: + """Close the response stream after one event; the server-side + generator should be cancelled (no leaked tasks, server stays + responsive for the next request).""" + with serve_async_app(library_app) as port: + base = f"http://127.0.0.1:{port}" + # Read just one event then disconnect. + with httpx.stream("GET", f"{base}/clock", timeout=5) as resp: + assert resp.status_code == 200 + iterator: Iterator[bytes] = resp.iter_bytes() + first = next(iterator) + assert b"data: tick-0" in first + # Closing the response forces the underlying connection + # closed → uvicorn raises http.disconnect to the app. + resp.close() + # Sanity: the server is still alive for a follow-up request. + resp = httpx.get(f"{base}/hello/world", timeout=5) + assert resp.status_code == 200 + + +class TestPayloadLimits: + def test_413_on_oversized_body(self, serve_async_app: ServeAsyncApp) -> None: + app = HttpAsyncApp(max_body_size=8) + + @app.post("/b") + async def create(book: Book) -> Created[Book]: + return Created(book) + + with serve_async_app(app) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/b", + content=b'{"id":"1","title":"way-too-long"}', + headers={"content-type": "application/json"}, + timeout=5, + ) + assert resp.status_code == 413 From 0f46f25a29080e145ec62ec1d6afb3b946cf8e02 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 12:52:37 +0400 Subject: [PATCH 203/286] feat(openapi): async example app + benchmark stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/openapi/async_app.py: HttpAsyncApp mirror of the existing sync example (Library API with hello/get_book/create_book/SSE pages). Uses @async_op_middleware for require_api_key + rate_limit, an async def generator for the SSE pages endpoint, and uvicorn.run() as the entry point. - benchmarks/openapi/apps/localpost_openapi_async.py: same routes / models as the sync localpost_openapi bench app, adapted to HttpAsyncApp + uvicorn. Attaches the same 400→422 remap middleware on the profile endpoint to keep validation_failure apples-to-apples. - benchmarks/openapi/stacks.py: register localpost_openapi_async with framework=localpost (so the localpost group filter pulls both), server=uvicorn (so result tables discriminate them), schema=msgspec. - benchmarks/openapi/README.md: stack table refreshed; brief note on why both LocalPost flavours live under framework=localpost. --- benchmarks/openapi/README.md | 26 +-- .../openapi/apps/localpost_openapi_async.py | 122 +++++++++++++ benchmarks/openapi/stacks.py | 18 +- examples/openapi/async_app.py | 164 ++++++++++++++++++ 4 files changed, 313 insertions(+), 17 deletions(-) create mode 100644 benchmarks/openapi/apps/localpost_openapi_async.py create mode 100644 examples/openapi/async_app.py diff --git a/benchmarks/openapi/README.md b/benchmarks/openapi/README.md index bb8f3d7..f07bc70 100644 --- a/benchmarks/openapi/README.md +++ b/benchmarks/openapi/README.md @@ -39,19 +39,25 @@ Output lands in `benchmarks/openapi/results///`: ## What's compared -Three stacks, one peer framework each: +Four stacks — the two `localpost.openapi` flavours plus one peer framework each: -| Stack ID | Framework | Server | Schema | -|---------------------|------------------------|------------------------------|----------| -| `localpost_openapi` | `localpost.openapi` | `localpost.http` (h11) | msgspec | -| `flask_openapi` | `flask-openapi` (v5) | gunicorn sync (32 threads) | pydantic | -| `fastapi` | FastAPI | uvicorn (1 worker) | pydantic | +| Stack ID | Framework | Server | Schema | +|----------------------------|-----------------------|------------------------------|----------| +| `localpost_openapi` | `localpost.openapi` | `localpost.http` (h11), sync | msgspec | +| `localpost_openapi_async` | `localpost.openapi` | uvicorn (1 worker), async | msgspec | +| `flask_openapi` | `flask-openapi` (v5) | gunicorn sync (32 threads) | pydantic | +| `fastapi` | FastAPI | uvicorn (1 worker) | pydantic | Single-process by design — we measure framework-layer overhead, not the multiplicative effect of more workers. All servers configured to be roughly comparable (`max_concurrency=32` for LocalPost, `--threads 32` for Gunicorn, etc.). +The two LocalPost stacks share `framework=localpost`; the `server` dim +discriminates them in result tables (`lp-h11` vs `uvicorn`). The async +flavour is the natural FastAPI comparator (both ASGI / uvicorn / async); +the sync flavour anchors what dropping the event loop costs / saves. + `flask-openapi3` was renamed to `flask-openapi` for the v5 line; the `bench-openapi` dependency group pins `flask-openapi >=5.0.0rc1`. @@ -75,10 +81,10 @@ business logic. For `validation_failure`: LocalPost's body resolver returns `BadRequest` (400) by default, while FastAPI and flask-openapi v5 -return 422. To keep the comparison apples-to-apples, the -`localpost_openapi` bench app attaches a small `op_middleware` that -remaps 400 → 422 on the profile endpoint. The framework default is -unchanged. +return 422. To keep the comparison apples-to-apples, both +`localpost_openapi` and `localpost_openapi_async` attach a small +middleware that remaps 400 → 422 on the profile endpoint. The framework +default is unchanged. ## Adding a new stack / scenario diff --git a/benchmarks/openapi/apps/localpost_openapi_async.py b/benchmarks/openapi/apps/localpost_openapi_async.py new file mode 100644 index 0000000..6e5c01e --- /dev/null +++ b/benchmarks/openapi/apps/localpost_openapi_async.py @@ -0,0 +1,122 @@ +"""LocalPost — ``localpost.openapi.HttpAsyncApp`` on Uvicorn. + +Async sibling of ``localpost_openapi.py`` — same dataclass models, same +routes, async handlers, deployed via ``app.asgi()`` under uvicorn (1 +worker). Body inputs auto-resolve via ``FromBody`` because the parameter +type is a dataclass; path/query coercion happens in the resolvers. + +Like the sync flavour, LocalPost's body resolver returns ``BadRequest`` +(400) on schema failure; FastAPI and flask-openapi return 422. The +``remap_validation_status`` middleware below remaps 400 → 422 on the +profile endpoint to keep the bench's ``validation_failure`` scenario +apples-to-apples. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any + +import uvicorn + +from benchmarks.openapi.apps._cli import parse_port +from localpost.openapi import ( + AsyncApiOperation, + AsyncHTTPReqCtx, + BadRequest, + HttpAsyncApp, + OpResult, + UnprocessableEntity, + async_op_middleware, +) + + +@dataclass +class Item: + id: int + + +@dataclass +class SearchResult: + q: str + limit: int + offset: int + + +@dataclass +class ProfileUpdate: + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@dataclass +class Profile: + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@async_op_middleware +async def remap_validation_status( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, +) -> UnprocessableEntity[str] | OpResult: + result = await call_next(ctx) + if isinstance(result, BadRequest): + body = result.body if isinstance(result.body, str) else str(result.body) + return UnprocessableEntity(body) + return result + + +def build_app() -> HttpAsyncApp: + app = HttpAsyncApp() + + @app.get("/ping") + async def ping() -> str: + return "pong" + + @app.get("/items/{item_id}") + async def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.get("/search") + async def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", middlewares=[remap_validation_status]) + async def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, search, update_profile) + return app + + +def main() -> int: + port = parse_port() + uvicorn.run( + build_app().asgi(), + host="127.0.0.1", + port=port, + log_level="warning", + access_log=False, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/openapi/stacks.py b/benchmarks/openapi/stacks.py index 376041d..efed3e6 100644 --- a/benchmarks/openapi/stacks.py +++ b/benchmarks/openapi/stacks.py @@ -1,16 +1,19 @@ """OpenAPI bench stack registry. -Three stacks for v1 — one per peer framework, each on its naturally-fitting -server. Single-process by design so we measure framework overhead, not -worker multiplexing. +Four stacks for v1 — the two LocalPost flavours plus one per peer +framework. Single-process by design so we measure framework overhead, +not worker multiplexing. -* ``localpost_openapi`` — ``localpost.openapi.HttpApp`` on ``localpost.http`` (h11). -* ``flask_openapi`` — ``flask-openapi3`` on Gunicorn (1 worker, 32 threads). -* ``fastapi`` — FastAPI on Uvicorn (1 worker). +* ``localpost_openapi`` — ``HttpApp`` on ``localpost.http`` (h11), sync handlers. +* ``localpost_openapi_async`` — ``HttpAsyncApp`` on Uvicorn (1 worker), async handlers. +* ``flask_openapi`` — ``flask-openapi3`` on Gunicorn (1 worker, 32 threads). +* ``fastapi`` — FastAPI on Uvicorn (1 worker). Dim keys: ``framework``, ``server``, ``schema`` (the validation library each framework drives — ``msgspec`` for LocalPost, ``pydantic`` for the -other two). +other two). The two LocalPost stacks share ``framework=localpost`` so +the ``localpost`` group filter pulls both; ``server`` discriminates +them in result tables (``lp-h11`` vs ``uvicorn``). """ from __future__ import annotations @@ -32,6 +35,7 @@ def _stack(name: str, *, framework: str, server: str, schema: str) -> Stack: STACKS: Final[tuple[Stack, ...]] = ( _stack("localpost_openapi", framework="localpost", server="lp-h11", schema="msgspec"), + _stack("localpost_openapi_async", framework="localpost", server="uvicorn", schema="msgspec"), _stack("flask_openapi", framework="flask-openapi3", server="gunicorn", schema="pydantic"), _stack("fastapi", framework="fastapi", server="uvicorn", schema="pydantic"), ) diff --git a/examples/openapi/async_app.py b/examples/openapi/async_app.py new file mode 100644 index 0000000..456423f --- /dev/null +++ b/examples/openapi/async_app.py @@ -0,0 +1,164 @@ +"""Working example for ``localpost.openapi.HttpAsyncApp`` (async / ASGI). + +Mirrors ``examples/openapi/app.py`` route-for-route, but every handler +is ``async def`` and the deployment target is ASGI (uvicorn here; same +ASGI app works under hypercorn or ``granian --interface asgi``). + +Run:: + + uv run python examples/openapi/async_app.py + +Then:: + + curl http://localhost:8000/hello/world + curl http://localhost:8000/books/42 + curl -X POST http://localhost:8000/books \\ + -H 'Content-Type: application/json' \\ + -H 'X-API-Key: secret' \\ + -d '{"id": "1", "title": "Dune", "author": "Frank Herbert"}' + curl -N http://localhost:8000/books/42/pages # streams as SSE + +Docs UIs: + http://localhost:8000/docs (Swagger UI) + http://localhost:8000/docs/redoc (ReDoc) + http://localhost:8000/docs/scalar (Scalar) +""" + +import asyncio +import sys +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Annotated + +import uvicorn + +from localpost.openapi import ( + AsyncApiOperation, + AsyncHTTPReqCtx, + BadRequest, + Created, + Event, + FromHeader, + HttpAsyncApp, + OpResult, + TooManyRequests, + Unauthorized, + async_op_middleware, + spec, +) + + +@dataclass +class Book: + id: str + title: str + author: str + + +@dataclass +class BookPage: + book_id: str + number: int + content: str + + +_LIBRARY: dict[str, Book] = { + "42": Book(id="42", title="The Hitchhiker's Guide to the Galaxy", author="Douglas Adams"), +} + + +app = HttpAsyncApp(info=spec.Info(title="Library API (async)", version="1.0.0")) + + +# A middleware-as-a-tiny-operation: same shape as the sync flavour, but +# ``async def`` and built with ``@async_op_middleware``. The framework +# still reads the input header and the failure response from the +# signature and threads them into every operation that uses it. +@async_op_middleware +async def require_api_key( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", +) -> Unauthorized[str] | OpResult: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return await call_next(ctx) + + +# Every-N-requests rate limiter, just for the demo. +_call_counter = {"n": 0} + + +@async_op_middleware +async def rate_limit( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, +) -> TooManyRequests[str] | OpResult: + _call_counter["n"] += 1 + if _call_counter["n"] % 10 == 0: + return TooManyRequests("Slow down") + return await call_next(ctx) + + +@app.get("/hello/{name}") +async def hello(name: str) -> str | BadRequest[str]: + """Greet someone.""" + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +async def get_book(book_id: str) -> Book | None: + """Look up a book by ID. + + ``Book | None`` is the implicit "a book or NotFound" — the framework + promotes a ``None`` return into a 404, and the OpenAPI doc declares + both branches. + """ + return _LIBRARY.get(book_id) + + +@app.post("/books", middlewares=[require_api_key, rate_limit]) +async def create_book(book: Book) -> Created[Book]: + """Add a new book to the library. + + Requires ``X-API-Key: secret``; subject to a (silly) every-10th-request + rate limit. Both middlewares' input headers and failure responses + appear in the OpenAPI doc automatically. + """ + _LIBRARY[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +@app.get("/books/{book_id}/pages") +async def stream_pages(book_id: str) -> AsyncIterator[Event[BookPage]]: + """Stream the book's pages as Server-Sent Events. + + The ``AsyncIterator`` return type auto-promotes to ``text/event-stream``. + ``HttpAsyncApp`` rejects sync generators here — use ``async def`` + generators so I/O between yields stays on the event loop. + """ + book = _LIBRARY.get(book_id) + if book is None: + # Same caveat as the sync example: SSE handlers can only yield + # events; "not found" surfaces as a single error event. + yield Event(data=BookPage(book_id=book_id, number=0, content="not found"), event="error") + return + for n in range(1, 6): + yield Event(data=BookPage(book_id=book_id, number=n, content=f"page {n} of {book.title}"), id=str(n)) + await asyncio.sleep(0.5) + + +def main() -> int: + # ``app.asgi()`` returns a plain ASGI 3 callable — works under any + # ASGI server. For ``localpost.hosting`` integration use + # ``app.service(uvicorn.Config(...))`` instead and feed it to + # ``hosting.run_app(...)``; that wires uvicorn into the lifecycle + # alongside other LocalPost services. + uvicorn.run(app.asgi(), host="127.0.0.1", port=8000, log_level="info") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ea598207e506ea4755cbf3f5581682b0902c0f85 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 12:54:54 +0400 Subject: [PATCH 204/286] docs(openapi): add async-flavour section to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Async flavour (HttpAsyncApp)" section after Hosting: - Quick-start side-by-side with the sync flavour. - Explicit list of what differs (handlers, middleware, auth, body buffering, shared resolvers). - Deployment block — uvicorn / hypercorn / granian via app.asgi(), plus pointer to the localpost.hosting integration. - Link to examples/openapi/async_app.py. Sub-modules table: add aio/ row, mark sync entries explicitly, add _operation_core.py (shared type-level core). Plus a small docstring fix on tests/openapi/aio_app.py — it still described the (long-removed) mocked receive/send helper instead of the httpx.ASGITransport that drives the suite now. --- localpost/openapi/README.md | 122 +++++++++++++++++++++++++++++-- tests/openapi/aio_app.py | 11 ++- tests/openapi/aio_integration.py | 4 +- 3 files changed, 122 insertions(+), 15 deletions(-) diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index bdb3429..d06534d 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -301,6 +301,114 @@ cover. `OpenIDConnectAuth` is a follow-up. Pass `selectors=N` and/or `acceptor=True` to use multi-selector topology. +## Async flavour (`HttpAsyncApp`) + +Parallel to `HttpApp`, like `httpx.Client` and `httpx.AsyncClient`. +Same decorator API, same OpenAPI 3.2 emission, same `OpResult` +hierarchy — handlers are `async def`, middleware is built with +`@async_op_middleware`, and the deployment target is **ASGI** (uvicorn, +hypercorn, or `granian --interface asgi`): + +```python +import sys +from dataclasses import dataclass + +import uvicorn + +from localpost.openapi import HttpAsyncApp, NotFound + + +@dataclass +class Book: + id: str + title: str + + +app = HttpAsyncApp() + + +@app.get("/books/{book_id}") +async def get_book(book_id: str) -> Book | NotFound[str]: + book = await fetch_book(book_id) # your async DB call, etc. + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + +if __name__ == "__main__": + sys.exit(uvicorn.run(app.asgi(), host="127.0.0.1", port=8000)) +``` + +For `localpost.hosting` integration use +`app.service(uvicorn.Config(...))` and feed it to +`hosting.run_app(...)` — same shape as `HttpApp.service(config)` but +running the ASGI app under uvicorn. + +A side-by-side example ports the sync `examples/openapi/app.py` +verbatim: see [`examples/openapi/async_app.py`](../../examples/openapi/async_app.py). + +### What's different + +- **Handlers are async.** Plain `async def` for normal routes; + `async def` generators for SSE. Sync generators are rejected at + request time — `HttpAsyncApp` won't silently bridge a blocking + generator onto the event loop. +- **Middleware is async.** Use `@async_op_middleware` to build them. + Sync `OpMiddleware` instances are rejected at app construction time: + + ```python + from typing import Annotated + + from localpost.openapi import ( + AsyncApiOperation, AsyncHTTPReqCtx, FromHeader, + OpResult, Unauthorized, async_op_middleware, + ) + + + @async_op_middleware + async def require_api_key( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", + ) -> Unauthorized[str] | OpResult: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return await call_next(ctx) + ``` +- **Auth.** `AsyncHttpBearerAuth` and `AsyncHttpBasicAuth` mirror their + sync siblings; the validator may be sync or async. +- **Body buffering.** The ASGI bridge pre-buffers the request body + before dispatch (matches the JSON-API common case the sync flavour + is tuned for) so the same `FromBody` resolver works in both + flavours. Cap the size with `HttpAsyncApp(max_body_size=N)` (default + 1 MiB; `-1` disables). Streaming uploads via `ctx.receive(...)` are + on the follow-up list. +- **Resolvers.** `FromPath`, `FromQuery`, `FromHeader`, `FromBody` are + shared — they only read sync attributes (`request`, `body`, `attrs`) + off the ctx, so the same factories work in both apps. + +### Deployment + +`app.asgi()` returns a plain ASGI 3 callable. Any ASGI server works: + +```python +# myapp.py +from localpost.openapi import HttpAsyncApp + +app = HttpAsyncApp() +# … register routes … +asgi_app = app.asgi() +``` + +```bash +uvicorn myapp:asgi_app +hypercorn myapp:asgi_app +granian --interface asgi myapp:asgi_app +``` + +For native Granian / RSGI deployment (no ASGI bridge), see +`plans/rsgi-deployment.md` — `as_rsgi()` is a planned follow-up. + ## Design notes See the in-tree design doc for rationale and a layout map: keep this README @@ -310,13 +418,15 @@ focused on user-facing concepts. | Module | Provides | |---|---| -| `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs | -| `operation.py` | `Operation`: signature parsing, return-type inference, runtime closure | -| `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories | +| `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs (sync) | +| `operation.py` | `Operation`: sync runtime closure | +| `_operation_core.py` | Shared type-level core (signature parsing, return-type → response-shape, doc helpers, response encoding) — used by both flavours | +| `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories — shared between sync and async | | `results.py` | `OpResult` hierarchy (incl. `EventStreamResult`) | -| `middleware.py` | `OpMiddleware` protocol, `@op_middleware`, `ApiOperation` | -| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth middlewares | -| `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events | +| `middleware.py` | `OpMiddleware` protocol, `@op_middleware`, `ApiOperation` (sync) | +| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth middlewares (sync) | +| `aio/` | Async flavour: `HttpAsyncApp`, `AsyncOperation`, `AsyncOpMiddleware`, `@async_op_middleware`, `AsyncHttpBearerAuth`, `AsyncHttpBasicAuth`, `AsyncHTTPReqCtx`, ASGI bridge | +| `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events (sync drive) | | `spec.py` | OpenAPI 3.2 dataclasses | | `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | | `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py index b3f27aa..ade5e84 100644 --- a/tests/openapi/aio_app.py +++ b/tests/openapi/aio_app.py @@ -6,9 +6,10 @@ :class:`AsyncHTTPReqCtx`, run it, inspect the captured response. Mirrors ``tests/openapi/app.py``'s sync ``run_op`` harness. 2. **ASGI-level**: drive the ASGI 3 callable from - :meth:`HttpAsyncApp.asgi` with a mock ``receive`` / ``send`` pair — - exercises the full route table, body buffering, and response - translation without spinning up a real server. + :meth:`HttpAsyncApp.asgi` via :class:`httpx.ASGITransport` — same + surface a real ASGI server would call against, but in-process and + without binding sockets. End-to-end tests under a real uvicorn live + in :mod:`tests.openapi.aio_integration` (marked ``integration``). """ from __future__ import annotations @@ -383,9 +384,7 @@ async def get_item_async(item_id: str) -> Book | NotFound[str]: assert sorted(s_op.responses) == sorted(a_op.responses) # ``parameters`` may contain ``Reference`` per OpenAPI; the test only # uses inline ``Parameter`` instances, so a getattr fallback is safe. - assert [getattr(p, "name", "") for p in s_op.parameters] == [ - getattr(p, "name", "") for p in a_op.parameters - ] + assert [getattr(p, "name", "") for p in s_op.parameters] == [getattr(p, "name", "") for p in a_op.parameters] # --- ASGI dispatch ------------------------------------------------------- diff --git a/tests/openapi/aio_integration.py b/tests/openapi/aio_integration.py index 3e0a245..9851825 100644 --- a/tests/openapi/aio_integration.py +++ b/tests/openapi/aio_integration.py @@ -228,9 +228,7 @@ def test_chunked_event_delivery(self, serve_async_app: ServeAsyncApp, library_ap assert b"data: tick-0\n\n" in wire assert b"data: tick-2\n\n" in wire - def test_peer_disconnect_mid_stream( - self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp - ) -> None: + def test_peer_disconnect_mid_stream(self, serve_async_app: ServeAsyncApp, library_app: HttpAsyncApp) -> None: """Close the response stream after one event; the server-side generator should be cancelled (no leaked tasks, server stays responsive for the next request).""" From 127598ecae709c7b7bbabcbe141d4ad01705132d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 13:21:44 +0400 Subject: [PATCH 205/286] refactor(http): hoist async ctx Protocol + ASGI bridge to localpost.http MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async ctx and its ASGI bridge were living in localpost.openapi.aio because they were built for HttpAsyncApp, but the ctx Protocol stands alone — it describes "an async HTTP request context" independent of any particular framework. Promote it to localpost.http alongside the sync HTTPReqCtx + WSGI bridge: - localpost/http/_async_base.py (NEW): AsyncHTTPReqCtx Protocol + AsyncRequestHandler type alias. Methods: receive(size), complete, stream, sendfile (+ disconnected property). Same shape as HTTPReqCtx where it makes sense (request/body/attrs/addrs/scheme/ response_status); skips borrow() which doesn't translate, and receive() is async since the streaming-vs-pre-buffered distinction is the transport's call (same name as sync, same contract — "give me up to size bytes of body"). - localpost/http/asgi.py (NEW): ASGI 3 bridge mirroring localpost.http.wsgi. - to_asgi(handler, max_body_size) -> ASGIApp public adapter. - _ASGIReqCtx concrete impl (now also implements receive() + sendfile()). - build_request_from_scope, addrs_from_scope helpers. - Lifespan, body buffering, http.disconnect watcher — all transport concerns the framework layer shouldn't see. - localpost/openapi/aio/app.py: HttpAsyncApp.asgi() shrinks to to_asgi(self._build_async_handler()). _build_async_handler builds the route table + 404/405/built-ins as a plain AsyncRequestHandler. _AsgiDispatch / its body-buffering + lifespan plumbing — gone (lives in to_asgi now). Symmetric with HttpApp.as_wsgi() = to_wsgi(self._build_router_handler()). - localpost/openapi/aio/_ctx.py: shrinks to a thin re-export of AsyncHTTPReqCtx for back-compat. - tests/http/asgi.py (NEW): ctx-unit tests + to_asgi smoke tests, moved from tests/openapi/aio_app.py. Symmetric with tests/http/wsgi_to.py. This unlocks the planned RSGI bridge (drop in localpost/http/rsgi.py implementing the same Protocol; HttpAsyncApp.as_rsgi() becomes sugar over to_rsgi(...)) and gives streaming uploads a single async receive() path per transport. Cancellation is the existing ctx.disconnected poll — no thread-local indirection on the async side; user already holds ctx. 229 unit + 8 integration tests pass. --- localpost/http/__init__.py | 9 +- localpost/http/_async_base.py | 110 +++++++++++ localpost/http/asgi.py | 362 ++++++++++++++++++++++++++++++++++ localpost/openapi/aio/_ctx.py | 208 +------------------ localpost/openapi/aio/app.py | 344 +++++++++++++------------------- tests/http/asgi.py | 253 ++++++++++++++++++++++++ tests/openapi/aio_app.py | 55 +----- 7 files changed, 883 insertions(+), 458 deletions(-) create mode 100644 localpost/http/_async_base.py create mode 100644 localpost/http/asgi.py create mode 100644 tests/http/asgi.py diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index d2e9ebd..84a43a9 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,3 +1,4 @@ +from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler from localpost.http._base import ( BodyHandler, ConnFactory, @@ -16,6 +17,7 @@ from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, wsgi_server from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response +from localpost.http.asgi import to_asgi from localpost.http.compress import DEFAULT_COMPRESSIBLE_TYPES, compress_handler from localpost.http.config import LOGGER_NAME, ServerConfig from localpost.http.router import ( @@ -33,13 +35,16 @@ # config "ServerConfig", "LOGGER_NAME", - # server + # server (sync) "start_http_server", "HTTPReqCtx", "RequestHandler", "BodyHandler", "Middleware", "compose", + # async ctx (transport-neutral; concrete adapters in localpost.http.asgi etc.) + "AsyncHTTPReqCtx", + "AsyncRequestHandler", # selector / accept-side topology "Selector", "SelectorCallback", @@ -62,6 +67,8 @@ # WSGI adapters "to_wsgi", "wrap_wsgi", + # ASGI adapters + "to_asgi", # hosting "http_server", "wsgi_server", diff --git a/localpost/http/_async_base.py b/localpost/http/_async_base.py new file mode 100644 index 0000000..7dd88d8 --- /dev/null +++ b/localpost/http/_async_base.py @@ -0,0 +1,110 @@ +"""Async HTTP request-context protocol — transport-neutral. + +Sibling of :class:`localpost.http.HTTPReqCtx` (sync). This module +defines the Protocol that async transports implement — +:mod:`localpost.http.asgi` does so today; an RSGI bridge will follow. +Frameworks on top of ``localpost.http`` (notably +:class:`localpost.openapi.HttpAsyncApp`) write handlers against this +Protocol and never import a transport-specific type. + +``localpost.http`` itself stays sync server-wise — production-grade +async HTTP servers already exist (uvicorn, hypercorn, granian). What +lives here is the Protocol + the adapters that translate those +servers' surfaces into our request-handler shape. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import TYPE_CHECKING, Any, BinaryIO, Protocol, runtime_checkable + +if TYPE_CHECKING: + from localpost.http._types import Request, Response + +__all__ = [ + "AsyncHTTPReqCtx", + "AsyncRequestHandler", +] + + +type AsyncRequestHandler = Callable[["AsyncHTTPReqCtx"], Awaitable[None]] +"""Top-level async request handler — drives one request to a response. + +Symmetric with :data:`localpost.http.RequestHandler` (sync). The async +side doesn't currently split into a pre-body / body-handler phase — +transports either pre-buffer the body (the ASGI default, capped by +``max_body_size``) or expose it via :meth:`AsyncHTTPReqCtx.receive`, +both before this handler runs. +""" + + +@runtime_checkable +class AsyncHTTPReqCtx(Protocol): + """Per-request context for async handlers. + + Mirrors :class:`localpost.http.HTTPReqCtx`'s sync attributes + (``request`` / ``body`` / ``attrs`` / ``response_status`` / addrs / + scheme) so resolvers that read those attributes work against either + flavour. Methods that touch the wire are async. + + ``body`` is empty unless the transport pre-buffers it before + dispatch. Handlers that need streaming reads call + ``await ctx.receive(size)`` directly — same name as the sync ctx, + same contract ("give me up to ``size`` bytes of request body, or + ``b''`` at EOF"). Whether that comes from a slice of a buffered + body or a fresh wire read is the transport's choice. + """ + + request: Request + body: bytes + response_status: int | None + attrs: dict[Any, Any] + + @property + def remote_addr(self) -> str | None: ... + @property + def local_addr(self) -> str: ... + @property + def scheme(self) -> str: ... + @property + def disconnected(self) -> bool: + """True once the peer has gone away (mid-response). + + SSE generators / long handlers poll this between events to + short-circuit cleanly when the client disconnects. Mirrors + what :func:`localpost.http.check_cancelled` does on the sync + side — but there's no thread-local indirection here, the user + already holds ``ctx``. + """ + ... + + async def receive(self, size: int = ..., /) -> bytes: + """Read up to ``size`` bytes of the request body. + + If the transport pre-buffered the body before dispatch (the + ASGI default), this slices the buffer. Otherwise it reads from + the wire. Returns ``b""`` at EOF. + """ + ... + + async def complete(self, response: Response, body: bytes | None = None) -> None: + """Send the final response and body in one shot. Terminal.""" + ... + + async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: + """Send ``response`` then drain ``chunks`` to the wire. + + Declarative streaming — the transport owns iteration, so it + chooses pacing / cancellation policy. Terminal. + """ + ... + + async def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + """Send ``response`` and stream ``count`` bytes from ``file`` + starting at ``offset``. + + Transports with native zero-copy support (RSGI's + ``response_file_range``) use it; ASGI falls back to chunked + reads. Terminal. + """ + ... diff --git a/localpost/http/asgi.py b/localpost/http/asgi.py new file mode 100644 index 0000000..b0cb5e9 --- /dev/null +++ b/localpost/http/asgi.py @@ -0,0 +1,362 @@ +"""ASGI 3 transport bridge — adapt :data:`AsyncRequestHandler` ⇆ ASGI app. + +Symmetric with :mod:`localpost.http.wsgi`: this module owns the +translation between the foreign protocol (ASGI 3) and our async +request-context shape (:class:`AsyncHTTPReqCtx`). The handler doesn't +know anything about ASGI — it just reads ``ctx.request`` / ``ctx.body`` +and calls ``await ctx.complete(...)`` / ``await ctx.stream(...)``. + +``localpost.http`` itself doesn't ship an async server — production +ASGI servers (uvicorn, hypercorn, granian) already exist. This module +plugs an :data:`AsyncRequestHandler` into one of them via :func:`to_asgi`. + +Pre-buffer policy: by default the body is read into ``ctx.body`` before +dispatch (matches the JSON-API common case the ``localpost.openapi`` +flavours target). ``max_body_size`` caps the buffer; bodies that +exceed it produce a 413 before the handler runs. +""" + +from __future__ import annotations + +import threading +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import Any, BinaryIO, final + +import anyio + +from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler +from localpost.http._types import Request +from localpost.http._types import Response as _Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +__all__ = [ + "ASGIScope", + "ASGIReceive", + "ASGISend", + "ASGIApp", + "to_asgi", + "build_request_from_scope", + "addrs_from_scope", +] + + +# ASGI 3 callable types — kept loose so we don't depend on a specific +# asgiref TypedDict. Scope / event dicts pass through verbatim. +type ASGIScope = dict[str, Any] +type ASGIReceive = Callable[[], Awaitable[dict[str, Any]]] +type ASGISend = Callable[[dict[str, Any]], Awaitable[None]] +type ASGIApp = Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]] + + +# --- Public adapter ----------------------------------------------------- + + +def to_asgi(handler: AsyncRequestHandler, *, max_body_size: int = 1 << 20) -> ASGIApp: + """Wrap an :data:`AsyncRequestHandler` as an ASGI 3 application. + + Deploy with any ASGI server:: + + from localpost.http.asgi import to_asgi + + + async def my_handler(ctx): + await ctx.complete(Response(200), b"hi") + + + asgi_app = to_asgi(my_handler) + # uvicorn myapp:asgi_app + + Args: + handler: The async request handler. + max_body_size: Cap on the buffered request body, in bytes. + Bodies above this raise ``413 Payload Too Large`` before + the handler runs. ``-1`` disables the cap. Defaults to + ``1 << 20`` (1 MiB). + + The returned callable handles ``lifespan`` (no-op accept) and + ``http`` scopes; WebSocket scopes are rejected with + :class:`ValueError`. A peer ``http.disconnect`` flips + ``ctx.disconnected``; long handlers / SSE generators poll it + between events to short-circuit cleanly. + """ + + async def asgi_app(scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: + kind = scope.get("type") + if kind == "lifespan": + await _handle_lifespan(receive, send) + return + if kind != "http": + raise ValueError(f"to_asgi: unsupported ASGI scope type: {kind!r}") + await _handle_http(handler, max_body_size, scope, receive, send) + + return asgi_app + + +async def _handle_http( + handler: AsyncRequestHandler, + max_body_size: int, + scope: ASGIScope, + receive: ASGIReceive, + send: ASGISend, +) -> None: + try: + body = await _read_body(receive, max_body_size) + except ValueError: + await _send_canned(send, 413, b"Payload Too Large") + return + + request = build_request_from_scope(scope) + remote, local = addrs_from_scope(scope) + disconnected = threading.Event() + ctx = _ASGIReqCtx( + request=request, + body=body, + remote_addr=remote, + local_addr=local, + scheme=str(scope.get("scheme", "http")), + _send=send, + _disconnected=disconnected, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(_watch_disconnect, receive, disconnected) + try: + await handler(ctx) + finally: + tg.cancel_scope.cancel() + + +# --- Concrete ctx ------------------------------------------------------- + + +class _ResponseAlreadyStarted(RuntimeError): + """Raised if a handler tries to start two responses on one request.""" + + +@final +@dataclass(slots=True, eq=False) +class _ASGIReqCtx: + """:class:`AsyncHTTPReqCtx` backed by an ASGI 3 ``scope`` + ``send``. + + The request body is pre-buffered by :func:`_read_body` before this + ctx is built, so :meth:`receive` slices the buffer (mirrors + :class:`localpost.http._WSGIReqCtx`'s shape). ``complete`` and + ``stream`` translate into ``http.response.start`` + + ``http.response.body`` events. ``disconnected`` flips when the + watcher task receives ``http.disconnect``. + """ + + request: Request + body: bytes + remote_addr: str | None + local_addr: str + scheme: str + _send: ASGISend + _disconnected: threading.Event + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _started: bool = False + _body_cursor: int = 0 + + @property + def disconnected(self) -> bool: + return self._disconnected.is_set() + + async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + if self._body_cursor >= len(self.body): + return b"" + end = self._body_cursor + size + chunk = self.body[self._body_cursor : end] + self._body_cursor += len(chunk) + return chunk + + async def complete(self, response: _Response, body: bytes | None = None) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + await self._send(_response_start_event(response)) + await self._send({"type": "http.response.body", "body": body or b"", "more_body": False}) + + async def stream(self, response: _Response, chunks: AsyncIterator[bytes], /) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + await self._send(_response_start_event(response)) + try: + async for chunk in chunks: + if self._disconnected.is_set(): + return + await self._send({"type": "http.response.body", "body": bytes(chunk), "more_body": True}) + finally: + # Always close the response — the ASGI server expects a + # final ``more_body=False`` event to release the + # connection. Skip when the peer is already gone. + if not self._disconnected.is_set(): + await self._send({"type": "http.response.body", "body": b"", "more_body": False}) + + async def sendfile(self, response: _Response, file: BinaryIO, offset: int, count: int) -> None: + """ASGI fallback: chunked read + stream. + + Native zero-copy isn't standardised across ASGI servers; some + expose ``http.response.pathsend`` extension, but it's optional. + We always go through the chunked path here. + """ + file.seek(offset) + chunks = _read_file_chunks(file, count, DEFAULT_BUFFER_SIZE) + await self.stream(response, chunks) + + def _check_not_started(self) -> None: + if self._started: + raise _ResponseAlreadyStarted("Response already started") + + +# Concrete ctx implements the AsyncHTTPReqCtx Protocol — verify at import. +# (Cheap insurance against drift; structural protocols are easy to break +# silently.) +_: type[AsyncHTTPReqCtx] = _ASGIReqCtx + + +# --- Scope / event translation ------------------------------------------ + + +def build_request_from_scope(scope: ASGIScope) -> Request: + """Build a localpost :class:`Request` from an ASGI ``http`` scope.""" + method = scope["method"].encode("ascii") + raw_path: bytes = scope.get("raw_path") or scope["path"].encode("utf-8") + query_string: bytes = scope.get("query_string", b"") or b"" + target = raw_path + (b"?" + query_string if query_string else b"") + headers = tuple((bytes(name).lower(), bytes(value)) for name, value in scope.get("headers", ())) + http_version = scope.get("http_version", "1.1").encode("ascii") + return Request( + method=method, + target=target, + path=raw_path, + query_string=query_string, + headers=headers, + http_version=http_version, + ) + + +def addrs_from_scope(scope: ASGIScope) -> tuple[str | None, str]: + """Return ``(remote_addr, local_addr)`` from an ASGI scope. + + ASGI ``client`` / ``server`` are ``[host, port]`` lists or ``None``. + Mirrors the sync ctx ``"host:port"`` formatting. + """ + client = scope.get("client") + server = scope.get("server") or [None, None] + remote = _fmt_addr(client[0], client[1]) if client else None + local = _fmt_addr(server[0], server[1]) or "" + return remote, local + + +def _fmt_addr(host: Any, port: Any) -> str | None: + if host is None: + return None + if port is None or port == "": + return str(host) + return f"{host}:{port}" + + +def _response_start_event(response: _Response) -> dict[str, Any]: + return { + "type": "http.response.start", + "status": response.status_code, + "headers": [(bytes(name), bytes(value)) for name, value in response.headers], + } + + +# --- Body buffering / disconnect / lifespan / canned responses --------- + + +async def _read_body(receive: ASGIReceive, max_size: int) -> bytes: + """Buffer the entire request body from successive ``http.request`` events. + + Raises :class:`ValueError` if the accumulated size would exceed + ``max_size``. ``http.disconnect`` while reading aborts cleanly with + whatever bytes have been received so far. + """ + chunks: list[bytes] = [] + total = 0 + while True: + event = await receive() + kind = event.get("type") + if kind == "http.disconnect": + return b"".join(chunks) + if kind != "http.request": + continue + body: bytes = event.get("body", b"") or b"" + if body: + total += len(body) + if max_size >= 0 and total > max_size: + raise ValueError(f"Request body exceeds max_body_size ({max_size} bytes)") + chunks.append(body) + if not event.get("more_body", False): + return b"".join(chunks) + + +async def _watch_disconnect(receive: ASGIReceive, flag: threading.Event) -> None: + """Drain ASGI events while the handler runs; flip ``flag`` on + ``http.disconnect``. Loop ends silently when the task group cancels.""" + try: + while True: + event = await receive() + if event.get("type") == "http.disconnect": + flag.set() + return + except Exception: # noqa: BLE001 + # ``receive`` may raise once the response is fully sent; treat as benign. + return + + +async def _handle_lifespan(receive: ASGIReceive, send: ASGISend) -> None: + """Minimal lifespan loop — accept startup / shutdown events without + plugging into the user's hosting service. (Hosting integration + drives lifecycle through :mod:`localpost.hosting`.)""" + while True: + event = await receive() + kind = event.get("type") + if kind == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif kind == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + +async def _send_canned( + send: ASGISend, + status: int, + body: bytes, + *, + extra_headers: Sequence[tuple[bytes, bytes]] = (), +) -> None: + headers = [ + (b"content-type", b"text/plain; charset=utf-8"), + (b"content-length", str(len(body)).encode("ascii")), + *extra_headers, + ] + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + +def _read_file_chunks(file: BinaryIO, count: int, blksize: int) -> AsyncIterator[bytes]: + """Async-iterator wrapper around a sync file's chunked read. + + Used by :meth:`_ASGIReqCtx.sendfile` — the ``file`` is a sync + handle (``BinaryIO``); doing the read on the event loop is fine + for small files but blocks for large ones. A real production + sendfile path should bridge to a thread; for now we keep the + semantics simple and document. + """ + + async def gen() -> AsyncIterator[bytes]: + remaining = count + while remaining > 0: + chunk = file.read(min(blksize, remaining)) + if not chunk: + return + remaining -= len(chunk) + yield chunk + + return gen() diff --git a/localpost/openapi/aio/_ctx.py b/localpost/openapi/aio/_ctx.py index 60ce2c0..a445deb 100644 --- a/localpost/openapi/aio/_ctx.py +++ b/localpost/openapi/aio/_ctx.py @@ -1,207 +1,13 @@ -"""Async per-request context — the ASGI 3 bridge. +"""Re-export the async ctx Protocol from :mod:`localpost.http`. -:class:`AsyncHTTPReqCtx` mirrors the sync :class:`localpost.http.HTTPReqCtx` -Protocol but with async ``complete`` / ``stream`` / ``receive`` methods. -The concrete :class:`_ASGIReqCtx` implementation builds a :class:`Request` -from an ASGI ``scope``, pre-buffers the request body (matches the JSON-API -common case), and translates ``ctx.complete(...)`` / ``ctx.stream(...)`` -into ASGI ``http.response.start`` + ``http.response.body`` events. - -Resolvers (:class:`FromPath` / :class:`FromQuery` / :class:`FromHeader` / -:class:`FromBody`) only read sync attributes (``request``, ``body``, -``attrs``) — that's the entire point of pre-buffering. The same -sync resolvers work in both flavours. +Kept as a thin alias for back-compat with code that imported +``AsyncHTTPReqCtx`` from this module before the Protocol was hoisted +into ``localpost.http``. New code should import from +``localpost.http`` (or the top-level ``localpost.openapi`` namespace). """ from __future__ import annotations -import threading -from collections.abc import AsyncIterator, Awaitable, Callable -from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable - -from localpost.http._types import Request, Response - -__all__ = [ - "AsyncHTTPReqCtx", - "ASGIScope", - "ASGIReceive", - "ASGISend", -] - - -# ASGI 3 callable types — we don't depend on a particular asgiref TypedDict -# to keep the surface narrow. Scope/event dicts are passed through verbatim. -type ASGIScope = dict[str, Any] -type ASGIReceive = Callable[[], Awaitable[dict[str, Any]]] -type ASGISend = Callable[[dict[str, Any]], Awaitable[None]] - - -@runtime_checkable -class AsyncHTTPReqCtx(Protocol): - """Per-request context handed to an :class:`AsyncOperation`. - - Mirrors :class:`localpost.http.HTTPReqCtx`'s sync attributes - (``request``, ``body``, ``attrs``, ``response_status``, addr/scheme - properties) so the same arg-resolvers work against either flavour. - Methods that touch the wire are async. - """ - - request: Request - body: bytes - response_status: int | None - attrs: dict[Any, Any] - - @property - def remote_addr(self) -> str | None: ... - @property - def local_addr(self) -> str: ... - @property - def scheme(self) -> str: ... - @property - def disconnected(self) -> bool: ... - - async def complete(self, response: Response, body: bytes | None = None) -> None: ... - async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: ... - - -# --- ASGI bridge --------------------------------------------------------- - - -class _ResponseAlreadyStarted(RuntimeError): - """Raised if a handler tries to start two responses on one request.""" - - -@dataclass(slots=True, eq=False) -class _ASGIReqCtx: - """:class:`AsyncHTTPReqCtx` backed by an ASGI 3 ``scope`` + ``send``. - - The body is pre-buffered by :func:`_read_body` before dispatch so - sync resolvers can read ``ctx.body`` directly. ``complete`` and - ``stream`` translate into ``http.response.start`` + - ``http.response.body`` ASGI events. ``disconnected`` flips when an - ``http.disconnect`` event arrives via the watcher task spawned by - :class:`HttpAsyncApp`. - """ - - request: Request - body: bytes - remote_addr: str | None - local_addr: str - scheme: str - _send: ASGISend - _disconnected: threading.Event - response_status: int | None = None - attrs: dict[Any, Any] = field(default_factory=dict) - _started: bool = False - - @property - def disconnected(self) -> bool: - return self._disconnected.is_set() - - async def complete(self, response: Response, body: bytes | None = None) -> None: - self._check_not_started() - self._started = True - self.response_status = response.status_code - await self._send(_response_start_event(response)) - await self._send({"type": "http.response.body", "body": body or b"", "more_body": False}) - - async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> None: - self._check_not_started() - self._started = True - self.response_status = response.status_code - await self._send(_response_start_event(response)) - try: - async for chunk in chunks: - if self._disconnected.is_set(): - return - await self._send({"type": "http.response.body", "body": bytes(chunk), "more_body": True}) - finally: - # Always close the response — even if iteration raised, the ASGI - # server expects a final ``more_body=False`` event to release - # the connection. Skip when the peer is already gone (the - # transport will surface the close on its own). - if not self._disconnected.is_set(): - await self._send({"type": "http.response.body", "body": b"", "more_body": False}) - - def _check_not_started(self) -> None: - if self._started: - raise _ResponseAlreadyStarted("Response already started") - - -# --- Scope / event translation ------------------------------------------ - - -def build_request_from_scope(scope: ASGIScope) -> Request: - """Build a localpost :class:`Request` from an ASGI ``http`` scope.""" - method = scope["method"].encode("ascii") - raw_path: bytes = scope.get("raw_path") or scope["path"].encode("utf-8") - query_string: bytes = scope.get("query_string", b"") or b"" - target = raw_path + (b"?" + query_string if query_string else b"") - # ASGI lowercases header names already; it sends bytes pairs. - headers = tuple((bytes(name).lower(), bytes(value)) for name, value in scope.get("headers", ())) - http_version = scope.get("http_version", "1.1").encode("ascii") - return Request( - method=method, - target=target, - path=raw_path, - query_string=query_string, - headers=headers, - http_version=http_version, - ) - - -def addrs_from_scope(scope: ASGIScope) -> tuple[str | None, str]: - """Return ``(remote_addr, local_addr)`` from an ASGI scope. - - ASGI ``client`` / ``server`` are ``[host, port]`` lists or ``None``. - Mirrors the sync ctx ``"host:port"`` formatting. - """ - client = scope.get("client") - server = scope.get("server") or [None, None] - remote = _fmt_addr(client[0], client[1]) if client else None - local = _fmt_addr(server[0], server[1]) or "" - return remote, local - - -def _fmt_addr(host: Any, port: Any) -> str | None: - if host is None: - return None - if port is None or port == "": - return str(host) - return f"{host}:{port}" - - -def _response_start_event(response: Response) -> dict[str, Any]: - return { - "type": "http.response.start", - "status": response.status_code, - "headers": [(bytes(name), bytes(value)) for name, value in response.headers], - } - - -async def read_body(receive: ASGIReceive, max_size: int) -> bytes: - """Buffer the entire request body from successive ``http.request`` events. +from localpost.http._async_base import AsyncHTTPReqCtx - Raises :class:`ValueError` if the accumulated size would exceed - ``max_size``. ``http.disconnect`` while reading aborts cleanly with - whatever bytes have been received so far. - """ - chunks: list[bytes] = [] - total = 0 - while True: - event = await receive() - kind = event.get("type") - if kind == "http.disconnect": - return b"".join(chunks) - if kind != "http.request": - # Spec says we may ignore unknown events; keep looping. - continue - body: bytes = event.get("body", b"") or b"" - if body: - total += len(body) - if max_size >= 0 and total > max_size: - raise ValueError(f"Request body exceeds max_body_size ({max_size} bytes)") - chunks.append(body) - if not event.get("more_body", False): - return b"".join(chunks) +__all__ = ["AsyncHTTPReqCtx"] diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py index fe7ca7f..e01dc7c 100644 --- a/localpost/openapi/aio/app.py +++ b/localpost/openapi/aio/app.py @@ -6,6 +6,13 @@ ``uvicorn``, ``hypercorn``, or ``granian --interface asgi``. :meth:`service` wires up uvicorn under :mod:`localpost.hosting` for ``hosting.run_app(app.service(config))`` parity with the sync flavour. + +Transport translation lives in :mod:`localpost.http.asgi` — +:meth:`asgi` is sugar over :func:`localpost.http.to_asgi`. The route +table + 404/405/built-ins are built once in +:meth:`_build_async_handler` as a plain :data:`AsyncRequestHandler`, +exactly mirroring how :class:`HttpApp` builds a sync +:data:`RequestHandler` for :func:`localpost.http.to_wsgi`. """ from __future__ import annotations @@ -16,22 +23,13 @@ from http import HTTPMethod from typing import Any, Literal -import anyio - +from localpost.http import AsyncHTTPReqCtx, AsyncRequestHandler, to_asgi from localpost.http._types import Response +from localpost.http.asgi import ASGIApp from localpost.http.router import RouteMatch, URITemplate from localpost.openapi import spec as openapi_spec from localpost.openapi._docs import redoc_html, scalar_html, swagger_html from localpost.openapi.adapters import AdapterRegistry, default_registry -from localpost.openapi.aio._ctx import ( - ASGIReceive, - ASGIScope, - ASGISend, - _ASGIReqCtx, - addrs_from_scope, - build_request_from_scope, - read_body, -) from localpost.openapi.aio.middleware import AsyncOpMiddleware from localpost.openapi.aio.operation import AsyncOperation from localpost.openapi.schemas import SchemaRegistry @@ -42,9 +40,6 @@ _FluentDecorator = Callable[[Callable[..., Any]], Callable[..., Any]] DocsUI = Literal["swagger", "redoc", "scalar", "all"] -# Per-request callable: builds and writes the response over ``ctx``. -type _AsyncHandler = Callable[[_ASGIReqCtx], Awaitable[None]] - class HttpAsyncApp: """Async type-driven HTTP application that emits an OpenAPI 3.2 spec. @@ -178,19 +173,16 @@ def _openapi_bytes(self) -> bytes: # ----- Hosting ----- - def asgi(self) -> Callable[[ASGIScope, ASGIReceive, ASGISend], Awaitable[None]]: + def asgi(self) -> ASGIApp: """Return an ASGI 3 callable that dispatches this app. - Deploy with e.g. ``uvicorn myapp:asgi_app`` or - ``granian --interface asgi myapp:asgi_app``:: - - app = HttpAsyncApp() - asgi_app = app.asgi() - - The returned callable handles ``lifespan`` (no-op) and ``http`` - scopes. WebSocket scopes are rejected. + Sugar over :func:`localpost.http.to_asgi` — the app's route + table plus built-in ``/openapi.json`` and ``/docs`` UIs are + compiled once into a single :data:`AsyncRequestHandler`, then + wrapped by the ASGI bridge. Deploy with e.g. ``uvicorn + myapp:asgi_app`` or ``granian --interface asgi myapp:asgi_app``. """ - return _AsgiDispatch.from_app(self) + return to_asgi(self._build_async_handler(), max_body_size=self._max_body_size) def service( self, @@ -204,10 +196,6 @@ def service( :class:`uvicorn.Config` for ``server="uvicorn"`` (the default) or :class:`hypercorn.Config` for ``server="hypercorn"``. Both servers are configured with the ASGI app from :meth:`asgi`. - - Pulls the ``localpost.hosting.services.uvicorn`` / - ``hypercorn`` adapters lazily so the import only happens when - the user picks that server. """ asgi_app = self.asgi() if server == "uvicorn": @@ -221,6 +209,85 @@ def service( return hypercorn_server(asgi_app, config) raise ValueError(f"Unknown ASGI server: {server!r}") + # ----- Internals ----- + + def _build_async_handler(self) -> AsyncRequestHandler: + """Compile the route table + built-ins into a single + :data:`AsyncRequestHandler`. Symmetric with + :meth:`HttpApp._build_router_handler`.""" + bucket: dict[str, _RouteEntry] = {} + + def add(method: HTTPMethod, template: URITemplate, handler: _AsyncMatchedHandler) -> None: + entry = bucket.setdefault(template.template, _RouteEntry(template=template, methods={})) + if method in entry.methods: + raise ValueError(f"Duplicate route: {method.value} {template.template}") + entry.methods[method] = handler + + for op in self._operations: + add(op.method, op.template, _async_op_handler(op)) + + if self._openapi_path: + add( + HTTPMethod.GET, + URITemplate.parse(self._openapi_path), + _static_handler(self._openapi_bytes, b"application/json"), + ) + if self._docs_path and self._openapi_path: + ui = self._docs_ui + html_ct = b"text/html; charset=utf-8" + if ui in ("swagger", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(self._docs_path), + _static_handler(_const_bytes(swagger_html(self._openapi_path)), html_ct), + ) + if ui in ("redoc", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(f"{self._docs_path}/redoc"), + _static_handler(_const_bytes(redoc_html(self._openapi_path)), html_ct), + ) + if ui in ("scalar", "all"): + add( + HTTPMethod.GET, + URITemplate.parse(f"{self._docs_path}/scalar"), + _static_handler(_const_bytes(scalar_html(self._openapi_path)), html_ct), + ) + + entries = tuple(bucket.values()) + + async def dispatch(ctx: AsyncHTTPReqCtx) -> None: + path = ctx.request.path.decode("iso-8859-1") + method_str = ctx.request.method.decode("ascii") + try: + method = HTTPMethod(method_str) + except ValueError: + method = None + + match = _match(entries, path, method) + if isinstance(match, _MatchNotFound): + await ctx.complete(_canned_response(404, b"Not Found"), b"Not Found") + return + if isinstance(match, _MatchMethodNotAllowed): + await ctx.complete( + _canned_response( + 405, + b"Method Not Allowed", + extra_headers=[(b"allow", match.allow.encode("ascii"))], + ), + b"Method Not Allowed", + ) + return + + ctx.attrs[RouteMatch] = RouteMatch( + method=method or HTTPMethod.GET, + matched_template=match.template, + path_args=match.path_args, + ) + await match.handler(ctx) + + return dispatch + def _ensure_async_middleware(mw: object) -> None: """Reject sync :class:`OpMiddleware` instances at registration time. @@ -244,20 +311,24 @@ def _ensure_async_middleware(mw: object) -> None: ) -# --- ASGI dispatch ------------------------------------------------------- +# --- Route-table helpers ------------------------------------------------ + +# Matched-handler shape — same as AsyncRequestHandler but spelled out so +# the route table types stay readable. +type _AsyncMatchedHandler = Callable[[AsyncHTTPReqCtx], Awaitable[None]] @dataclass(frozen=True, slots=True) class _RouteEntry: template: URITemplate - methods: dict[HTTPMethod, _AsyncHandler] + methods: dict[HTTPMethod, _AsyncMatchedHandler] @dataclass(frozen=True, slots=True) class _MatchOk: template: URITemplate path_args: dict[str, str] - handler: _AsyncHandler + handler: _AsyncMatchedHandler @dataclass(frozen=True, slots=True) @@ -273,153 +344,39 @@ class _MatchMethodNotAllowed: _MATCH_NOT_FOUND = _MatchNotFound() -class _AsgiDispatch: - """Composed ASGI dispatcher for an :class:`HttpAsyncApp`. - - Built once per :meth:`HttpAsyncApp.asgi` call. Stores the route table - and exposes a single ``__call__(scope, receive, send)`` that handles - ``lifespan`` and ``http`` scopes. - """ - - __slots__ = ("_entries", "_max_body_size") - - def __init__(self, entries: tuple[_RouteEntry, ...], max_body_size: int) -> None: - self._entries = entries - self._max_body_size = max_body_size - - @classmethod - def from_app(cls, app: HttpAsyncApp) -> _AsgiDispatch: - bucket: dict[str, _RouteEntry] = {} - - def add(method: HTTPMethod, template: URITemplate, handler: _AsyncHandler) -> None: - entry = bucket.setdefault(template.template, _RouteEntry(template=template, methods={})) - if method in entry.methods: - raise ValueError(f"Duplicate route: {method.value} {template.template}") - entry.methods[method] = handler - - for op in app.operations: - add(op.method, op.template, _async_op_handler(op)) - - if app._openapi_path: - add( - HTTPMethod.GET, - URITemplate.parse(app._openapi_path), - _static_handler(app._openapi_bytes, b"application/json"), - ) - if app._docs_path and app._openapi_path: - ui = app._docs_ui - html_ct = b"text/html; charset=utf-8" - if ui in ("swagger", "all"): - add( - HTTPMethod.GET, - URITemplate.parse(app._docs_path), - _static_handler(_const_bytes(swagger_html(app._openapi_path)), html_ct), - ) - if ui in ("redoc", "all"): - add( - HTTPMethod.GET, - URITemplate.parse(f"{app._docs_path}/redoc"), - _static_handler(_const_bytes(redoc_html(app._openapi_path)), html_ct), - ) - if ui in ("scalar", "all"): - add( - HTTPMethod.GET, - URITemplate.parse(f"{app._docs_path}/scalar"), - _static_handler(_const_bytes(scalar_html(app._openapi_path)), html_ct), - ) - - return cls(entries=tuple(bucket.values()), max_body_size=app._max_body_size) - - async def __call__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: - kind = scope.get("type") - if kind == "lifespan": - await _handle_lifespan(receive, send) - return - if kind != "http": - raise ValueError(f"HttpAsyncApp: unsupported ASGI scope type: {kind!r}") - await self._handle_http(scope, receive, send) - - async def _handle_http(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: - path = scope["path"] - method_str = scope["method"] - try: - method = HTTPMethod(method_str) - except ValueError: - method = None - - match = self._match(path, method) - if isinstance(match, _MatchNotFound): - await _send_canned(send, 404, b"Not Found") - return - if isinstance(match, _MatchMethodNotAllowed): - await _send_canned( - send, - 405, - b"Method Not Allowed", - extra_headers=[(b"allow", match.allow.encode("ascii"))], - ) - return - - try: - body = await read_body(receive, self._max_body_size) - except ValueError: - await _send_canned(send, 413, b"Payload Too Large") - return - - request = build_request_from_scope(scope) - remote, local = addrs_from_scope(scope) - disconnected = threading.Event() - ctx = _ASGIReqCtx( - request=request, - body=body, - remote_addr=remote, - local_addr=local, - scheme=str(scope.get("scheme", "http")), - _send=send, - _disconnected=disconnected, - ) - ctx.attrs[RouteMatch] = RouteMatch( - method=method or HTTPMethod.GET, - matched_template=match.template, - path_args=match.path_args, - ) - - async with anyio.create_task_group() as tg: - tg.start_soon(_watch_disconnect, receive, disconnected) - try: - await match.handler(ctx) - finally: - tg.cancel_scope.cancel() - - def _match(self, path: str, method: HTTPMethod | None) -> _MatchOk | _MatchNotFound | _MatchMethodNotAllowed: - any_template_matched = False - allow: set[HTTPMethod] = set() - for entry in self._entries: - args = entry.template.match(path) - if args is None: - continue - any_template_matched = True - allow.update(entry.methods) - if method is None: - continue - handler = entry.methods.get(method) - if handler is None: - continue - return _MatchOk(template=entry.template, path_args=args, handler=handler) - if any_template_matched: - return _MatchMethodNotAllowed(allow=", ".join(sorted(m.value for m in allow))) - return _MATCH_NOT_FOUND - - -def _async_op_handler(op: AsyncOperation) -> _AsyncHandler: - async def run(ctx: _ASGIReqCtx) -> None: +def _match( + entries: tuple[_RouteEntry, ...], + path: str, + method: HTTPMethod | None, +) -> _MatchOk | _MatchNotFound | _MatchMethodNotAllowed: + any_template_matched = False + allow: set[HTTPMethod] = set() + for entry in entries: + args = entry.template.match(path) + if args is None: + continue + any_template_matched = True + allow.update(entry.methods) + if method is None: + continue + handler = entry.methods.get(method) + if handler is None: + continue + return _MatchOk(template=entry.template, path_args=args, handler=handler) + if any_template_matched: + return _MatchMethodNotAllowed(allow=", ".join(sorted(m.value for m in allow))) + return _MATCH_NOT_FOUND + + +def _async_op_handler(op: AsyncOperation) -> _AsyncMatchedHandler: + async def run(ctx: AsyncHTTPReqCtx) -> None: await op.run(ctx) return run -def _static_handler(body_provider: Callable[[], bytes], content_type: bytes) -> _AsyncHandler: - async def run(ctx: _ASGIReqCtx) -> None: +def _static_handler(body_provider: Callable[[], bytes], content_type: bytes) -> _AsyncMatchedHandler: + async def run(ctx: AsyncHTTPReqCtx) -> None: body = body_provider() response = Response( status_code=200, @@ -440,48 +397,15 @@ def provider() -> bytes: return provider -async def _watch_disconnect(receive: ASGIReceive, flag: threading.Event) -> None: - """Drain ASGI events while the handler runs; flip ``flag`` on - ``http.disconnect``. Loop ends silently when the task group cancels.""" - try: - while True: - event = await receive() - if event.get("type") == "http.disconnect": - flag.set() - return - except Exception: # noqa: BLE001 - # ``receive`` may raise once the response is fully sent; treat as benign. - return - - -# --- ASGI lifespan / canned responses ----------------------------------- - - -async def _handle_lifespan(receive: ASGIReceive, send: ASGISend) -> None: - """Minimal lifespan loop — accept startup / shutdown events without - plugging into the user's hosting service. (The hosting integration - drives lifecycle through :mod:`localpost.hosting`.)""" - while True: - event = await receive() - kind = event.get("type") - if kind == "lifespan.startup": - await send({"type": "lifespan.startup.complete"}) - elif kind == "lifespan.shutdown": - await send({"type": "lifespan.shutdown.complete"}) - return - - -async def _send_canned( - send: ASGISend, +def _canned_response( status: int, body: bytes, *, extra_headers: Sequence[tuple[bytes, bytes]] = (), -) -> None: - headers = [ +) -> Response: + headers: list[tuple[bytes, bytes]] = [ (b"content-type", b"text/plain; charset=utf-8"), (b"content-length", str(len(body)).encode("ascii")), *extra_headers, ] - await send({"type": "http.response.start", "status": status, "headers": headers}) - await send({"type": "http.response.body", "body": body, "more_body": False}) + return Response(status_code=status, headers=headers) diff --git a/tests/http/asgi.py b/tests/http/asgi.py new file mode 100644 index 0000000..fa99160 --- /dev/null +++ b/tests/http/asgi.py @@ -0,0 +1,253 @@ +"""Tests for ``localpost.http.asgi`` — :func:`to_asgi` adapter and the +underlying :class:`_ASGIReqCtx` Protocol implementation. + +Drives the ASGI app directly via :class:`httpx.ASGITransport` (no real +ASGI server needed); also exercises the ctx in isolation by handing it +captured ``send`` / mocked ``receive`` channels. + +Symmetric with ``tests/http/wsgi_to.py`` for the sync side. +""" + +from __future__ import annotations + +import threading +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest + +from localpost.http import ( + AsyncHTTPReqCtx, + Response, + to_asgi, +) +from localpost.http._types import Request +from localpost.http.asgi import _ASGIReqCtx, addrs_from_scope, build_request_from_scope + +# --- Helpers ------------------------------------------------------------ + + +def _build_ctx( + *, + request: Request | None = None, + body: bytes = b"", + send=None, # type: ignore[no-untyped-def] +) -> _ASGIReqCtx: + """Build a minimal _ASGIReqCtx for unit-level checks.""" + + async def _swallow(_event: dict[str, Any]) -> None: + pass + + return _ASGIReqCtx( + request=request or Request(b"GET", b"/", b"/", b"", []), + body=body, + remote_addr=None, + local_addr="0.0.0.0:0", + scheme="http", + _send=send or _swallow, + _disconnected=threading.Event(), + ) + + +# --- Protocol conformance ----------------------------------------------- + + +def test_asgi_ctx_satisfies_async_protocol() -> None: + """Concrete ``_ASGIReqCtx`` should satisfy ``AsyncHTTPReqCtx`` via + structural typing — guard against drift.""" + ctx = _build_ctx() + assert isinstance(ctx, AsyncHTTPReqCtx) + + +# --- Scope translation -------------------------------------------------- + + +class TestScopeTranslation: + def test_request_built_from_scope(self) -> None: + scope: dict[str, Any] = { + "type": "http", + "method": "POST", + "path": "/items/42", + "raw_path": b"/items/42", + "query_string": b"q=1", + "headers": [(b"content-type", b"application/json")], + "http_version": "1.1", + } + req = build_request_from_scope(scope) + assert req.method == b"POST" + assert req.path == b"/items/42" + assert req.query_string == b"q=1" + assert req.target == b"/items/42?q=1" + assert (b"content-type", b"application/json") in req.headers + + def test_addrs_from_scope_with_client_and_server(self) -> None: + scope = {"client": ["1.2.3.4", 5555], "server": ["10.0.0.1", 8000]} + remote, local = addrs_from_scope(scope) + assert remote == "1.2.3.4:5555" + assert local == "10.0.0.1:8000" + + def test_addrs_from_scope_no_client(self) -> None: + scope: dict[str, Any] = {"server": ["10.0.0.1", 8000]} + remote, local = addrs_from_scope(scope) + assert remote is None + assert local == "10.0.0.1:8000" + + +# --- Ctx behaviour ------------------------------------------------------ + + +class TestCtxComplete: + @pytest.mark.anyio + async def test_complete_emits_start_then_body(self) -> None: + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + ctx = _build_ctx(send=send) + await ctx.complete(Response(status_code=200, headers=[(b"x-y", b"z")]), b"hello") + assert [e["type"] for e in events] == ["http.response.start", "http.response.body"] + assert events[0]["status"] == 200 + assert (b"x-y", b"z") in events[0]["headers"] + assert events[1]["body"] == b"hello" + assert events[1]["more_body"] is False + assert ctx.response_status == 200 + + @pytest.mark.anyio + async def test_complete_twice_raises(self) -> None: + ctx = _build_ctx() + await ctx.complete(Response(200), b"x") + with pytest.raises(RuntimeError, match="already started"): + await ctx.complete(Response(200), b"y") + + +class TestCtxStream: + @pytest.mark.anyio + async def test_stream_drains_chunks_and_finalises(self) -> None: + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + yield b"b" + + ctx = _build_ctx(send=send) + await ctx.stream(Response(200), chunks()) + types = [e["type"] for e in events] + assert types[0] == "http.response.start" + bodies = [e for e in events if e["type"] == "http.response.body"] + assert [e["body"] for e in bodies[:2]] == [b"a", b"b"] + # Final terminator: empty body, more_body=False. + assert bodies[-1] == {"type": "http.response.body", "body": b"", "more_body": False} + + @pytest.mark.anyio + async def test_stream_short_circuits_on_disconnect(self) -> None: + events: list[dict[str, Any]] = [] + + async def send(event: dict[str, Any]) -> None: + events.append(event) + + ctx = _build_ctx(send=send) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + ctx._disconnected.set() + yield b"b" # should not reach the wire + + await ctx.stream(Response(200), chunks()) + bodies = [e for e in events if e["type"] == "http.response.body"] + # Only the first chunk lands; no terminator (peer is gone). + assert [e["body"] for e in bodies] == [b"a"] + + +class TestCtxReceive: + """``ctx.receive(size)`` slices the pre-buffered body — same shape + as the sync :class:`_WSGIReqCtx.receive` helper.""" + + @pytest.mark.anyio + async def test_receive_slices_buffer(self) -> None: + ctx = _build_ctx(body=b"abcdef") + assert await ctx.receive(2) == b"ab" + assert await ctx.receive(3) == b"cde" + assert await ctx.receive(10) == b"f" + assert await ctx.receive(10) == b"" + + +# --- to_asgi end-to-end (via httpx.ASGITransport) ----------------------- + + +def _client(asgi_app) -> httpx.AsyncClient: # type: ignore[no-untyped-def] + return httpx.AsyncClient(transport=httpx.ASGITransport(app=asgi_app), base_url="http://t") + + +class TestToAsgi: + @pytest.mark.anyio + async def test_simple_complete_round_trip(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"hi") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + resp = await c.get("/anything") + assert resp.status_code == 200 + assert resp.content == b"hi" + + @pytest.mark.anyio + async def test_body_pre_buffered(self) -> None: + captured: dict[str, bytes] = {} + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + captured["body"] = ctx.body + await ctx.complete(Response(200), b"ok") + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + await c.post("/", content=b"payload") + assert captured["body"] == b"payload" + + @pytest.mark.anyio + async def test_body_too_large_returns_413(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"unreached") + + asgi_app = to_asgi(handler, max_body_size=4) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"more-than-four-bytes") + assert resp.status_code == 413 + + @pytest.mark.anyio + async def test_stream_round_trip(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + yield b"b" + yield b"c" + + await ctx.stream(Response(200, headers=[(b"content-type", b"text/plain")]), chunks()) + + asgi_app = to_asgi(handler) + async with _client(asgi_app) as c: + async with c.stream("GET", "/") as resp: + assert resp.status_code == 200 + wire = b"".join([chunk async for chunk in resp.aiter_bytes()]) + assert wire == b"abc" + + @pytest.mark.anyio + async def test_unsupported_scope_raises(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + pass + + asgi_app = to_asgi(handler) + scope = {"type": "websocket"} + + async def receive() -> dict[str, Any]: + return {"type": "websocket.disconnect"} + + async def send(_event: dict[str, Any]) -> None: + pass + + with pytest.raises(ValueError, match="unsupported ASGI scope"): + await asgi_app(scope, receive, send) diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py index ade5e84..32a0787 100644 --- a/tests/openapi/aio_app.py +++ b/tests/openapi/aio_app.py @@ -14,7 +14,6 @@ from __future__ import annotations -import threading from collections.abc import AsyncIterator from dataclasses import dataclass, field from http import HTTPMethod @@ -38,7 +37,6 @@ OpResult, async_op_middleware, ) -from localpost.openapi.aio._ctx import _ASGIReqCtx from localpost.openapi.aio.middleware import AsyncApiOperation from localpost.openapi.aio.operation import AsyncOperation @@ -81,6 +79,13 @@ async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> N self.streamed = (response, captured) self.response_status = response.status_code + async def receive(self, size: int = 65536, /) -> bytes: + return self.body if size >= len(self.body) else self.body[:size] + + async def sendfile(self, response: Response, file, offset: int, count: int) -> None: + # Test fake — sendfile isn't exercised in this suite. + raise NotImplementedError + def make_async_ctx( method: str = "GET", @@ -575,47 +580,5 @@ async def me() -> str: assert {"bearerAuth": ()} in op_spec.security -# --- ASGI ctx unit tests ------------------------------------------------ - - -class TestAsgiCtxUnit: - @pytest.mark.anyio - async def test_complete_emits_start_then_body(self): - events: list[dict[str, Any]] = [] - - async def send(event: dict[str, Any]) -> None: - events.append(event) - - ctx = _ASGIReqCtx( - request=Request(b"GET", b"/", b"/", b"", []), - body=b"", - remote_addr=None, - local_addr="0.0.0.0:0", - scheme="http", - _send=send, - _disconnected=threading.Event(), - ) - await ctx.complete(Response(status_code=200, headers=[(b"x-y", b"z")]), b"hello") - assert [e["type"] for e in events] == ["http.response.start", "http.response.body"] - assert events[0]["status"] == 200 - assert (b"x-y", b"z") in events[0]["headers"] - assert events[1]["body"] == b"hello" - assert events[1]["more_body"] is False - - @pytest.mark.anyio - async def test_complete_twice_raises(self): - async def send(_event: dict[str, Any]) -> None: - pass - - ctx = _ASGIReqCtx( - request=Request(b"GET", b"/", b"/", b"", []), - body=b"", - remote_addr=None, - local_addr="0.0.0.0:0", - scheme="http", - _send=send, - _disconnected=threading.Event(), - ) - await ctx.complete(Response(200), b"x") - with pytest.raises(RuntimeError, match="already started"): - await ctx.complete(Response(200), b"y") +# Ctx-level unit tests live in tests/http/asgi.py — that's where the +# ``_ASGIReqCtx`` implementation lives now (moved out of localpost.openapi.aio). From f8c726dd76b5d093ebdef9b4130f72c63f1aa7bd Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 13:31:01 +0400 Subject: [PATCH 206/286] docs: seed docs/ with request-body-handling design note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/ tree (mkdocs-ready, no static-site config yet): - docs/index.md: top-level entry, points at the per-module READMEs and design notes. Also mentions plans/ at the repo root for forward- looking work-in-progress designs. - docs/design/request-body-handling.md: explains what ``ctx.receive(size)`` does on each transport (native h11 sync streaming/JSON paths, WSGI, ASGI, planned RSGI) and why the Protocol stays clean despite very different host servers — the body-source is a transport choice, not a Protocol switch. --- docs/design/request-body-handling.md | 113 +++++++++++++++++++++++++++ docs/index.md | 32 ++++++++ 2 files changed, 145 insertions(+) create mode 100644 docs/design/request-body-handling.md create mode 100644 docs/index.md diff --git a/docs/design/request-body-handling.md b/docs/design/request-body-handling.md new file mode 100644 index 0000000..64ad968 --- /dev/null +++ b/docs/design/request-body-handling.md @@ -0,0 +1,113 @@ +# Request body handling across transports + +`localpost.http` exposes two surfaces for accessing the request body: +`ctx.body: bytes` (the buffered body) and `ctx.receive(size) -> bytes` +(read up to `size` bytes). Both are present on the sync +[`HTTPReqCtx`](../../localpost/http/_base.py) and async +[`AsyncHTTPReqCtx`](../../localpost/http/_async_base.py) Protocols. +This note explains what `receive(size)` does in each transport — and +why the same Protocol cleanly covers four very different host servers. + +## The contract + +`receive(size) -> bytes` means "give me up to `size` bytes of the +request body, or `b""` at end-of-message." Nothing more. **What the +source is** depends on what the transport has done before the handler +runs: + +| Transport | Body pre-buffered? | `receive(size)` source | +|----------------------------------|------------------------------|----------------------------------------------| +| Native h11 (sync) — JSON path | Yes, by the selector | Returns `b""` (parser is at EOM; use `ctx.body`) | +| Native h11 (sync) — streaming | No (`streaming_pool_handler`)| Reads from the socket, chunk-at-a-time | +| WSGI bridge (sync) | Yes, from `wsgi.input` | Slices the buffered `ctx.body` | +| ASGI bridge (async) | Yes, capped by `max_body_size` | Slices the buffered `ctx.body` | +| RSGI bridge (async, planned) | No (RSGI streams natively) | Reads from the protocol via `async for` | + +The handler doesn't care which row applies. It calls `receive(size)`, +processes whatever it gets, and stops at `b""`. That's the entire +contract. + +## What pre-buffers what + +The pre-buffer happens *outside* the ctx in every case — the ctx +implements one strategy that matches what its host transport gave it. + +### Native h11 (sync) + +The h11 parser exposes per-chunk events +([`server_h11.py:422`](../../localpost/http/server_h11.py)): +`receive(size)` pumps `parser.next_event()`, calls `conn.receive(size)` +(i.e. `recv` on the socket) when h11 says `NEED_DATA`, and returns the +next `h11.Data` chunk. **It always reads from the wire** — there's no +internal buffer. + +`ctx.body` gets populated by the **selector**, not by the ctx itself, +when a [`RequestHandler`](../../localpost/http/_base.py) returns a +[`BodyHandler`](../../localpost/http/_base.py) continuation. The +selector reads the full body into `ctx.body` *before* invoking the +continuation, then runs it. After that, `parser.their_state == +h11.DONE`, so a follow-up `ctx.receive(...)` correctly returns `b""`. +This is the JSON-API common case: handler decides "I need the body" by +returning a `BodyHandler`, then reads `ctx.body` directly. + +The streaming case ([`streaming_pool_handler`](../../localpost/http/_pool.py)) +runs the handler on a borrowed conn *without* pre-buffering — the +handler calls `ctx.receive(size)` to pull chunks off the wire as they +arrive. Use it for streaming uploads / large bodies where buffering is +undesirable. + +### WSGI bridge (sync) + +WSGI ([`wsgi.py`](../../localpost/http/wsgi.py)) doesn't expose +chunked input — the WSGI server has already buffered the body for us +in `wsgi.input`. The bridge reads it once into `ctx.body`; +`ctx.receive(size)` slices that buffer. There's no wire to read from +on this side. + +### ASGI bridge (async) + +ASGI ([`asgi.py`](../../localpost/http/asgi.py)) hands us body chunks +via `http.request` events on the receive channel. The bridge consumes +them upfront (capped by `max_body_size`) into `ctx.body` before +dispatch; `ctx.receive(size)` slices the buffered body, same shape as +the WSGI path. + +A future "streaming mode" could skip the pre-buffer and have +`ctx.receive(size)` pull from the ASGI receive channel directly — same +contract, different impl. Out of scope for v1. + +### RSGI bridge (async, planned) + +RSGI exposes `async for chunk in proto` natively. A future +`localpost.http.rsgi` adapter will implement +`AsyncHTTPReqCtx.receive(size)` by pulling from the protocol directly, +not via a pre-buffer. This is the first async transport that would +*stream* on `ctx.receive(...)`. The Protocol contract already covers +it — no API change needed. + +## Why the Protocol stays clean + +There's no `streaming: bool` flag, no separate `read_chunk()` method, +no two competing surfaces. Just `receive(size)` with one consistent +contract. The transport picks the source that matches what its host +server gave it; the handler stays portable. + +The `ctx.body: bytes` attribute is a complementary convenience for +the JSON-API common case: when a transport pre-buffers (the default +on WSGI / ASGI, the JSON path on native), `ctx.body` is the +already-buffered bytes. When a transport doesn't pre-buffer, `ctx.body` +is empty and the handler reads via `receive(size)`. + +## User-facing knobs + +The one thing that *is* user-visible is the *config*: + +- **Native sync** — pick `streaming_pool_handler` (no pre-buffer) vs + `thread_pool_handler` (pre-buffer) when wiring the server. +- **ASGI** — `to_asgi(handler, max_body_size=N)` controls the + pre-buffer cap. Bodies above the cap raise 413 before the handler + runs. + +In every case, the handler code reads `ctx.body` or `await +ctx.receive(size)` (or sync `ctx.receive(size)`) without knowing which +transport ran it. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..91d823d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,32 @@ +# LocalPost docs + +Concept docs and deeper dives that don't fit in the per-module READMEs. +Layout is mkdocs-ready (one section per top-level dir); a static site +generator can be wired up later without moving anything. + +## Modules + +User-facing references live next to the code: + +- [`localpost.hosting`](../localpost/hosting/README.md) +- [`localpost.scheduler`](../localpost/scheduler/README.md) +- [`localpost.http`](../localpost/http/README.md) +- [`localpost.openapi`](../localpost/openapi/README.md) +- [`localpost.di`](../localpost/di/README.md) + +## Design notes + +Architectural decisions and contract explanations — read these when +building on top of `localpost` or extending an existing module. + +- [Request body handling across transports](design/request-body-handling.md) — + what `ctx.receive(size)` does on the native server, WSGI, ASGI (and + the planned RSGI bridge); how the pre-buffer / streaming distinction + is a transport choice, not a Protocol switch. + +## Plans + +Work-in-progress design plans live at the repo root under +[`plans/`](../plans/) — e.g. RSGI deployment, the dynamic worker pool. +They're forward-looking; once a plan lands, its rationale moves into a +design note here and the plan file is removed. From 3fa1fef860fa4a7427bd27210cab45db96d8f6c4 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 14:13:35 +0400 Subject: [PATCH 207/286] feat(http): add streaming-upload mode to to_asgi + clear ty errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to_asgi(handler, streaming=True) skips the body pre-buffer; the handler pulls chunks via ``await ctx.receive(size)``. Same Protocol contract, same handler code — just a transport-level switch on whether the body is read upfront or on demand. Implementation: - _ASGIReqCtx grows a streaming branch — _body_stream (an anyio memory stream) replaces the buffer source. Trailing partials beyond size are stashed in _stream_leftover so receive(size) keeps its "up to N bytes" contract across chunked arrivals. - ASGI's receive channel is single-consumer, so streaming mode runs a single _pump_channel task that demuxes http.request -> body queue and http.disconnect -> the disconnected flag. The pump stays alive past body-EOM to catch a late disconnect during a long response. - max_body_size in streaming mode is enforced via Content-Length pre-check (413 before dispatch when present); chunked uploads without Content-Length aren't capped — trust your ASGI server's limit or check len(chunk) in the handler. Documented. 10 new tests covering chunked reads in order, size-respecting partial splits, the Content-Length 413 path, and handlers that respond without reading the body. Also clear the two pre-existing ty errors in localpost/_utils.py (ensure_async_callable / ensure_sync_callable were using pyright-style ``# type: ignore`` directives that ty doesn't honour; replaced with typing.cast for consistency with the rest of the file). ``just types`` is now clean. --- localpost/_utils.py | 4 +- localpost/http/asgi.py | 200 +++++++++++++++++++++++++++++++++++++---- tests/http/asgi.py | 67 ++++++++++++++ 3 files changed, 250 insertions(+), 21 deletions(-) diff --git a/localpost/_utils.py b/localpost/_utils.py index 6af0989..ec13c9e 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -175,14 +175,14 @@ def ensure_async_callable[**P, T]( if is_async_callable(func): return func limiter = CapacityLimiter(max_threads) if isinstance(max_threads, int | float) else max_threads - return functools.partial(to_thread.run_sync, func, limiter=limiter) # type: ignore[return-value] + return cast("Callable[P, Awaitable[T]]", functools.partial(to_thread.run_sync, func, limiter=limiter)) def ensure_sync_callable[**P, T]( func: Callable[P, Awaitable[T]] | Callable[P, T] | Callable[P, Awaitable[T] | T], / ) -> Callable[P, T]: if is_async_callable(func): - return functools.partial(from_thread.run, func) + return cast("Callable[P, T]", functools.partial(from_thread.run, func)) return cast("Callable[P, T]", func) diff --git a/localpost/http/asgi.py b/localpost/http/asgi.py index b0cb5e9..e2d6ed0 100644 --- a/localpost/http/asgi.py +++ b/localpost/http/asgi.py @@ -4,16 +4,26 @@ translation between the foreign protocol (ASGI 3) and our async request-context shape (:class:`AsyncHTTPReqCtx`). The handler doesn't know anything about ASGI — it just reads ``ctx.request`` / ``ctx.body`` -and calls ``await ctx.complete(...)`` / ``await ctx.stream(...)``. +or ``await ctx.receive(size)``, and calls ``await ctx.complete(...)`` +/ ``await ctx.stream(...)``. ``localpost.http`` itself doesn't ship an async server — production ASGI servers (uvicorn, hypercorn, granian) already exist. This module plugs an :data:`AsyncRequestHandler` into one of them via :func:`to_asgi`. -Pre-buffer policy: by default the body is read into ``ctx.body`` before -dispatch (matches the JSON-API common case the ``localpost.openapi`` -flavours target). ``max_body_size`` caps the buffer; bodies that -exceed it produce a 413 before the handler runs. +Two body-handling modes: + +- **Buffered** (default): the bridge reads the full body upfront and + drops it on ``ctx.body``. ``ctx.receive(size)`` slices that buffer. + Matches the JSON-API common case; 413 is sent before the handler + runs if the body exceeds ``max_body_size``. +- **Streaming** (``streaming=True``): the body is *not* pre-read. + ``ctx.body`` is empty; the handler pulls chunks via + ``await ctx.receive(size)``. Use for large uploads / streaming + consumers. ``max_body_size`` is enforced via the ``Content-Length`` + header when present (413 before dispatch); chunked uploads without + ``Content-Length`` aren't capped — trust your ASGI server's limit + or check ``len(chunk)`` in the handler. """ from __future__ import annotations @@ -21,7 +31,7 @@ import threading from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from dataclasses import dataclass, field -from typing import Any, BinaryIO, final +from typing import TYPE_CHECKING, Any, BinaryIO, final import anyio @@ -30,6 +40,9 @@ from localpost.http._types import Response as _Response from localpost.http.config import DEFAULT_BUFFER_SIZE +if TYPE_CHECKING: + from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + __all__ = [ "ASGIScope", "ASGIReceive", @@ -52,7 +65,12 @@ # --- Public adapter ----------------------------------------------------- -def to_asgi(handler: AsyncRequestHandler, *, max_body_size: int = 1 << 20) -> ASGIApp: +def to_asgi( + handler: AsyncRequestHandler, + *, + max_body_size: int = 1 << 20, + streaming: bool = False, +) -> ASGIApp: """Wrap an :data:`AsyncRequestHandler` as an ASGI 3 application. Deploy with any ASGI server:: @@ -69,10 +87,15 @@ async def my_handler(ctx): Args: handler: The async request handler. - max_body_size: Cap on the buffered request body, in bytes. - Bodies above this raise ``413 Payload Too Large`` before - the handler runs. ``-1`` disables the cap. Defaults to - ``1 << 20`` (1 MiB). + max_body_size: Cap on the request body, in bytes. Buffered mode + raises ``413 Payload Too Large`` before the handler runs; + streaming mode pre-checks ``Content-Length`` (when present) + and 413s if it exceeds the cap. ``-1`` disables the cap. + Defaults to ``1 << 20`` (1 MiB). + streaming: When ``True``, skip the pre-buffer — ``ctx.body`` + stays empty and the handler pulls body chunks via + ``await ctx.receive(size)``. Use for large uploads or + streaming consumers; otherwise leave ``False`` (default). The returned callable handles ``lifespan`` (no-op accept) and ``http`` scopes; WebSocket scopes are rejected with @@ -88,12 +111,15 @@ async def asgi_app(scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> No return if kind != "http": raise ValueError(f"to_asgi: unsupported ASGI scope type: {kind!r}") - await _handle_http(handler, max_body_size, scope, receive, send) + if streaming: + await _handle_http_streaming(handler, max_body_size, scope, receive, send) + else: + await _handle_http_buffered(handler, max_body_size, scope, receive, send) return asgi_app -async def _handle_http( +async def _handle_http_buffered( handler: AsyncRequestHandler, max_body_size: int, scope: ASGIScope, @@ -127,6 +153,60 @@ async def _handle_http( tg.cancel_scope.cancel() +async def _handle_http_streaming( + handler: AsyncRequestHandler, + max_body_size: int, + scope: ASGIScope, + receive: ASGIReceive, + send: ASGISend, +) -> None: + """Streaming dispatch — single channel-pump task demuxes body chunks + + disconnect events. The ASGI receive channel is single-consumer, so + we can't run a body-reader and a disconnect-watcher in parallel; the + pump owns the channel and feeds an in-process body queue. + """ + request = build_request_from_scope(scope) + + # Pre-check Content-Length when present — friendlier than detecting + # cap exhaustion mid-stream. + if max_body_size >= 0: + cl = _content_length(request) + if cl is not None and cl > max_body_size: + await _send_canned(send, 413, b"Payload Too Large") + return + + remote, local = addrs_from_scope(scope) + disconnected = threading.Event() + body_send, body_recv = anyio.create_memory_object_stream[bytes](0) + ctx = _ASGIReqCtx( + request=request, + body=b"", + remote_addr=remote, + local_addr=local, + scheme=str(scope.get("scheme", "http")), + _send=send, + _disconnected=disconnected, + _body_stream=body_recv, + ) + + async with anyio.create_task_group() as tg: + tg.start_soon(_pump_channel, receive, body_send, disconnected) + try: + await handler(ctx) + finally: + tg.cancel_scope.cancel() + + +def _content_length(request: Request) -> int | None: + for name, value in request.headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + # --- Concrete ctx ------------------------------------------------------- @@ -139,12 +219,22 @@ class _ResponseAlreadyStarted(RuntimeError): class _ASGIReqCtx: """:class:`AsyncHTTPReqCtx` backed by an ASGI 3 ``scope`` + ``send``. - The request body is pre-buffered by :func:`_read_body` before this - ctx is built, so :meth:`receive` slices the buffer (mirrors - :class:`localpost.http._WSGIReqCtx`'s shape). ``complete`` and - ``stream`` translate into ``http.response.start`` + - ``http.response.body`` events. ``disconnected`` flips when the - watcher task receives ``http.disconnect``. + Two body-source modes (selected by ``to_asgi(streaming=...)``): + + - **Buffered**: ``_body_stream`` is ``None``. The request body has + been pre-read into ``body`` before this ctx was built, so + :meth:`receive` slices that buffer. Mirrors the + :class:`localpost.http._WSGIReqCtx` shape. + - **Streaming**: ``_body_stream`` is set; ``body`` is empty. + :meth:`receive` pulls chunks from the in-process queue that the + :func:`_pump_channel` task feeds from ASGI ``http.request`` + events. Trailing partials beyond ``size`` are stashed in + ``_stream_leftover``. + + ``complete`` and ``stream`` translate into ``http.response.start`` + + ``http.response.body`` events. ``disconnected`` flips when an + ``http.disconnect`` event arrives (via the watcher task in + buffered mode, or the channel pump in streaming mode). """ request: Request @@ -158,12 +248,20 @@ class _ASGIReqCtx: attrs: dict[Any, Any] = field(default_factory=dict) _started: bool = False _body_cursor: int = 0 + _body_stream: MemoryObjectReceiveStream[bytes] | None = None + _stream_eof: bool = False + _stream_leftover: bytes = b"" @property def disconnected(self) -> bool: return self._disconnected.is_set() async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + if self._body_stream is None: + return self._slice_buffered(size) + return await self._receive_streaming(size) + + def _slice_buffered(self, size: int) -> bytes: if self._body_cursor >= len(self.body): return b"" end = self._body_cursor + size @@ -171,6 +269,28 @@ async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: self._body_cursor += len(chunk) return chunk + async def _receive_streaming(self, size: int) -> bytes: + if self._stream_leftover: + if len(self._stream_leftover) <= size: + chunk = self._stream_leftover + self._stream_leftover = b"" + return chunk + chunk = self._stream_leftover[:size] + self._stream_leftover = self._stream_leftover[size:] + return chunk + if self._stream_eof: + return b"" + assert self._body_stream is not None + try: + chunk = await self._body_stream.receive() + except anyio.EndOfStream: + self._stream_eof = True + return b"" + if len(chunk) <= size: + return chunk + self._stream_leftover = chunk[size:] + return chunk[:size] + async def complete(self, response: _Response, body: bytes | None = None) -> None: self._check_not_started() self._started = True @@ -310,6 +430,48 @@ async def _watch_disconnect(receive: ASGIReceive, flag: threading.Event) -> None return +async def _pump_channel( + receive: ASGIReceive, + body_send: MemoryObjectSendStream[bytes], + flag: threading.Event, +) -> None: + """Streaming-mode demuxer: consume ASGI events, route body chunks to + ``body_send`` and flip ``flag`` on ``http.disconnect``. + + Closes ``body_send`` once body is at EOM (or peer disconnected) so + the handler's ``ctx.receive`` sees ``b""``. Continues consuming + after EOM to catch a later disconnect. Cancellation by the parent + task group ends the pump silently. + """ + body_done = False + try: + async with body_send: + while True: + event = await receive() + kind = event.get("type") + if kind == "http.disconnect": + flag.set() + return + if kind != "http.request": + continue + if body_done: + continue + body: bytes = event.get("body", b"") or b"" + if body: + await body_send.send(body) + if not event.get("more_body", False): + body_done = True + break + # Body is done; stay on the channel for late http.disconnect. + while True: + event = await receive() + if event.get("type") == "http.disconnect": + flag.set() + return + except Exception: # noqa: BLE001 + return + + async def _handle_lifespan(receive: ASGIReceive, send: ASGISend) -> None: """Minimal lifespan loop — accept startup / shutdown events without plugging into the user's hosting service. (Hosting integration diff --git a/tests/http/asgi.py b/tests/http/asgi.py index fa99160..cddb5b9 100644 --- a/tests/http/asgi.py +++ b/tests/http/asgi.py @@ -251,3 +251,70 @@ async def send(_event: dict[str, Any]) -> None: with pytest.raises(ValueError, match="unsupported ASGI scope"): await asgi_app(scope, receive, send) + + +class TestToAsgiStreaming: + """``to_asgi(handler, streaming=True)`` skips the pre-buffer; the + handler reads body chunks via ``await ctx.receive(size)``.""" + + @pytest.mark.anyio + async def test_body_reads_chunks_in_order(self) -> None: + captured: list[bytes] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + assert ctx.body == b"" # streaming mode: no pre-buffer + while True: + chunk = await ctx.receive(64) + if not chunk: + break + captured.append(chunk) + await ctx.complete(Response(200), b"ok") + + asgi_app = to_asgi(handler, streaming=True) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"hello-streaming-world") + assert resp.status_code == 200 + assert b"".join(captured) == b"hello-streaming-world" + + @pytest.mark.anyio + async def test_receive_honours_size_with_partial_chunk(self) -> None: + sizes: list[int] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + # Read 3 bytes at a time — chunks arriving on the channel + # may be larger; ctx.receive should split them. + while True: + chunk = await ctx.receive(3) + if not chunk: + break + sizes.append(len(chunk)) + await ctx.complete(Response(200), b"ok") + + asgi_app = to_asgi(handler, streaming=True) + async with _client(asgi_app) as c: + await c.post("/", content=b"abcdefghij") + assert all(s <= 3 for s in sizes) + assert sum(sizes) == 10 + + @pytest.mark.anyio + async def test_content_length_pre_check_413(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: # noqa: ARG001 + raise AssertionError("handler should not run") + + asgi_app = to_asgi(handler, streaming=True, max_body_size=4) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"too-long-by-far") + assert resp.status_code == 413 + + @pytest.mark.anyio + async def test_handler_can_skip_body_and_respond(self) -> None: + """Streaming mode: handler may respond without reading the body — + the channel pump drains stragglers; the response still completes.""" + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(204), b"") + + asgi_app = to_asgi(handler, streaming=True) + async with _client(asgi_app) as c: + resp = await c.post("/", content=b"ignored-body") + assert resp.status_code == 204 From a39db3ecbdaa9ba470bd4bbd60e634820ab13ce7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 14:16:37 +0400 Subject: [PATCH 208/286] docs(http): document the ASGI bridge + async ctx surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localpost/http/README.md: - Intro: WSGI / ASGI bridges (was just WSGI). - Scope-and-constraints bullet on async: localpost.http stays sync server-wise, but ships an ASGI bridge (to_asgi) for handlers that want to be async. Production async servers (uvicorn / hypercorn / granian) drive the request loop; the handler shape is the same conceptual contract, expressed against AsyncHTTPReqCtx. - New "localpost.http.asgi" subsection sibling of the WSGI one: AsyncHTTPReqCtx / AsyncRequestHandler / to_asgi (incl. streaming=True) with a deployment block and a pointer to HttpAsyncApp + the body- handling design note. - WSGI table grew a to_wsgi row (was missing — it's been there since the wsgi module landed). Plus: declare HttpAsyncApp.service's return type (-> hosting.ServiceF) to clear the type-coverage warning. Mirrors the sync HttpApp.service signature. 237 unit + 8 integration tests pass; ``just types`` clean; type coverage 92.3% (no regressions on the new async surface). --- localpost/http/README.md | 66 +++++++++++++++++++++++++++++++++--- localpost/openapi/aio/app.py | 3 +- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/localpost/http/README.md b/localpost/http/README.md index 8b76e33..f324642 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -3,7 +3,7 @@ > **Status:** stable — public API is not expected to break in patch/minor releases. A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), -plus a URI-template router, a WSGI bridge, and a small framework +plus a URI-template router, WSGI / ASGI bridges, and a small framework (`HttpApp`) on top. Three layers, each usable on its own: - **Server**: `start_http_server` accepts connections, parses HTTP @@ -26,10 +26,15 @@ The server is intentionally bounded: multi-core fanout, run multiple `localpost` processes under an external supervisor (systemd, gunicorn, k8s replicas, etc.). Multi-*selector* inside one process is on the roadmap. -- **Sync handlers only.** No `asyncio` / `uvloop` / ASGI on the server side. - The hosting layer's AnyIO integration is unaffected — that's lifecycle - plumbing, not the request hot path. If you need an async server, use one - of the ASGI servers via `localpost.hosting.services/`. +- **Sync server only.** No `asyncio` / `uvloop` ships in the native + server's request hot path. The hosting layer's AnyIO integration is + unaffected — that's lifecycle plumbing, not request handling. + Production-grade async HTTP servers already exist (uvicorn, + hypercorn, granian); for handlers that *want* to be async, + `localpost.http.asgi.to_asgi(handler)` adapts an `AsyncRequestHandler` + to any of them. The handler still implements the same conceptual + contract (read request, write response) — just an async-flavoured + Protocol (`AsyncHTTPReqCtx`) instead of the sync one. - **GIL or free-threaded.** Standard CPython 3.12+ is the baseline. Free-threaded builds (3.13t / 3.14t) are an accepted target — the design is single-writer-per-selector and uses only thread-safe primitives, but @@ -226,6 +231,57 @@ thread + N worker selectors) drop in without touching the request hot path. | Symbol | Notes | | ------------------------- | ------------------------------------------ | | `wrap_wsgi(app)` | Turn a WSGI app into a `RequestHandler` | +| `to_wsgi(handler)` | Turn a `RequestHandler` into a WSGI app | + +### `localpost.http.asgi` + +The async sibling of `localpost.http.wsgi` — bridges between a foreign +async protocol (ASGI 3) and the framework's async request-context shape +(`AsyncHTTPReqCtx`, defined in `localpost.http`). `localpost.http` +itself doesn't ship an async server; this module plugs an +`AsyncRequestHandler` into uvicorn / hypercorn / granian / any ASGI 3 +server. + +| Symbol | Notes | +| ------------------------------------------------------- | --------------------------------------------------------------------------- | +| `AsyncHTTPReqCtx` | Async sibling of `HTTPReqCtx`. Same `request` / `body` / `attrs` / addrs / `scheme`; async `complete` / `stream` / `receive` / `sendfile`; `disconnected: bool` poll for peer-gone. Re-exported from `localpost.http`. | +| `AsyncRequestHandler` | `Callable[[AsyncHTTPReqCtx], Awaitable[None]]`. The handler shape `to_asgi` adapts. | +| `to_asgi(handler, *, max_body_size=1<<20, streaming=False)` | Wrap an `AsyncRequestHandler` as an ASGI 3 app. Pre-buffers the request body by default (capped by `max_body_size`; over-cap → 413 before dispatch). Pass `streaming=True` to skip the pre-buffer — the handler then pulls chunks via `await ctx.receive(size)`; `Content-Length` is pre-checked against `max_body_size` when present. Same handler code works either way. | + +Deployment is the standard ASGI pattern: + +```python +# myapp.py +from localpost.http import Response +from localpost.http.asgi import to_asgi + + +async def hello(ctx): + await ctx.complete(Response(200), b"hi") + + +asgi_app = to_asgi(hello) +``` + +```bash +uvicorn myapp:asgi_app +hypercorn myapp:asgi_app +granian --interface asgi myapp:asgi_app +``` + +For the framework-flavoured surface (decorators, OpenAPI generation, +typed handlers) on top of the same bridge, see +[`localpost.openapi.HttpAsyncApp`](../openapi/README.md#async-flavour-httpasyncapp) +— `app.asgi()` is sugar over `to_asgi(self._build_async_handler())`. + +> Cancellation: `ctx.disconnected` flips on `http.disconnect`. SSE +> generators / long handlers poll it between events to short-circuit +> cleanly. + +> Body handling across transports — +> [docs/design/request-body-handling.md](../../docs/design/request-body-handling.md) +> covers the contract (one `receive(size)` Protocol method, four host +> servers, no flag soup). ### `localpost.http.static` diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py index e01dc7c..df819c7 100644 --- a/localpost/openapi/aio/app.py +++ b/localpost/openapi/aio/app.py @@ -23,6 +23,7 @@ from http import HTTPMethod from typing import Any, Literal +from localpost import hosting from localpost.http import AsyncHTTPReqCtx, AsyncRequestHandler, to_asgi from localpost.http._types import Response from localpost.http.asgi import ASGIApp @@ -189,7 +190,7 @@ def service( config: Any, *, server: Literal["uvicorn", "hypercorn"] = "uvicorn", - ): + ) -> hosting.ServiceF: """Return a :func:`localpost.hosting.service` running this app. ``config`` is the host server's config object — From 9925c2bec5a3d12f9377a5de326be871561703b1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 16:37:34 +0400 Subject: [PATCH 209/286] feat: add RSGI bridge + HostRSGIApp for Granian deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two deployment modes for Granian / RSGI: **Mode A — single HTTP app:** to_rsgi(handler) -> RSGIApplication HttpAsyncApp.as_rsgi() -> to_rsgi(self._build_async_handler()) granian --interface rsgi myapp:rsgi_app **Mode B — host-as-RSGI for hosted apps:** HostRSGIApp(services=[scheduler.service(), ...], rsgi_handler=app) granian --interface rsgi --workers 4 myapp:rsgi_app Mode B is the core of the design. Granian doesn't run in-process — it's a process supervisor that spawns workers. Inside *each* Granian worker, HostRSGIApp runs the full localpost stack (scheduler + HTTP + anything else), sharing memory the way a regular hosting.run_app deployment does. Per-worker side effects (heartbeats firing in every worker etc.) are the user's deployment-time concern. Wire-side wins over the ASGI bridge: - Eager response is a single ``proto.response_bytes`` call (vs ASGI's two-event start+body dance). - Sendfile uses ``proto.response_file_range`` for true zero-copy when the file has a filesystem path; chunked stream fallback otherwise. - Streaming uploads don't need a pump task — RSGI's proto is directly async-iterable, so ctx.receive(size) wraps that iterator. Implementation notes: - localpost/http/rsgi.py: to_rsgi + _RSGIReqCtx mirroring asgi.py's shape. Buffered + streaming body modes; disconnect via watcher task on proto.client_disconnect(). - localpost/hosting/rsgi.py: HostRSGIApp drives hosting.serve() from Granian's lifecycle hooks. Granian calls the hooks *synchronously* (no await), so the lifecycle runs as a long-lived task on Granian's loop and __rsgi__ gates the first request on a "ready" Event. Single task owns the serve() context — anyio cancel scopes can't span tasks. - _compose_started: a multi-service composition that fires set_started on the parent once all children report started. The existing _run_many doesn't (run_app doesn't need it; serve does). Tests: - tests/http/rsgi.py: 29 unit tests against a mocked proto. - tests/hosting/rsgi.py: 7 lifecycle tests for HostRSGIApp. - tests/openapi/aio_rsgi_integration.py: 6 integration tests under a real granian.server.embed.Server (Mode A + Mode B + SSE + OpenAPI). 726 unit + 14 integration tests pass. --- localpost/hosting/__init__.py | 2 + localpost/hosting/rsgi.py | 217 ++++++++++++ localpost/http/__init__.py | 3 + localpost/http/rsgi.py | 469 ++++++++++++++++++++++++++ localpost/openapi/aio/app.py | 21 +- tests/hosting/rsgi.py | 273 +++++++++++++++ tests/http/rsgi.py | 371 ++++++++++++++++++++ tests/openapi/aio_rsgi_integration.py | 259 ++++++++++++++ 8 files changed, 1614 insertions(+), 1 deletion(-) create mode 100644 localpost/hosting/rsgi.py create mode 100644 localpost/http/rsgi.py create mode 100644 tests/hosting/rsgi.py create mode 100644 tests/http/rsgi.py create mode 100644 tests/openapi/aio_rsgi_integration.py diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index be03e2f..6c7fc40 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -18,6 +18,7 @@ service, ) from .middleware import shutdown_on_signal +from .rsgi import HostRSGIApp __all__ = [ "service", @@ -33,6 +34,7 @@ "ServiceLifetime", "ServiceLifetimeView", "Stopped", + "HostRSGIApp", ] diff --git a/localpost/hosting/rsgi.py b/localpost/hosting/rsgi.py new file mode 100644 index 0000000..a13478a --- /dev/null +++ b/localpost/hosting/rsgi.py @@ -0,0 +1,217 @@ +"""Host as RSGI application — Mode B for Granian deployments. + +When you have hosted services beyond just the HTTP app — schedulers, +gRPC servers, custom background workers — and you want Granian as your +process supervisor, :class:`HostRSGIApp` runs the *whole* hosting stack +inside each Granian worker process. The HTTP request handler is +dispatched per-request via Granian's RSGI interface; the rest of the +services run in the background, sharing memory with the handler the +way a normal :func:`localpost.hosting.run_app` deployment does. + +Granian's lifecycle hooks drive ours: + +- ``__rsgi_init__(loop)`` — enter :func:`localpost.hosting.serve`'s + context manager. Services start in dependency order; the call + returns once everything reaches ``started``. +- ``__rsgi__(scope, proto)`` — dispatch the request via the same RSGI + bridge :func:`localpost.http.to_rsgi` uses. +- ``__rsgi_del__(loop)`` — exit the context manager. Hosting fires + shutdown for every service and waits for ``stopped`` before + returning to Granian. + +Note that **per-worker side effects** are the user's concern. Granian +spawns N workers; ``services=[heartbeat.service()]`` runs in **each** +worker. Cron-style jobs that should fire once need either +``--workers 1`` or external coordination (DB lock, leader election). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, final + +from localpost._utils import wait_all +from localpost.hosting._host import ServiceF, ServiceLifetime, serve +from localpost.http._async_base import AsyncRequestHandler +from localpost.http.rsgi import _dispatch_buffered, _dispatch_streaming + +if TYPE_CHECKING: + from localpost.openapi.aio.app import HttpAsyncApp + +__all__ = ["HostRSGIApp"] + + +@final +class HostRSGIApp: + """RSGI application that runs a full hosting lifecycle per Granian worker. + + Args: + services: Background hosted services to run alongside the HTTP + handler (scheduled tasks, gRPC servers, anything that returns + a :data:`localpost.hosting.ServiceF`). + rsgi_handler: Either an :data:`AsyncRequestHandler` (low-level) + or an :class:`HttpAsyncApp` (the framework will compile its + route table internally). + max_body_size: Cap on the request body. See :func:`to_rsgi`. + streaming: When ``True``, body chunks are pulled via + ``await ctx.receive(size)`` instead of pre-buffered. See + :func:`to_rsgi`. + + Example:: + + from localpost import hosting + from localpost.openapi import HttpAsyncApp + from localpost.scheduler import every, scheduled_task + + + app = HttpAsyncApp() + + + @app.get("/") + async def root() -> str: + return "ok" + + + @scheduled_task(every(seconds=5)) + async def heartbeat() -> None: ... + + + rsgi_app = hosting.HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, + ) + + # granian --interface rsgi --workers 4 myapp:rsgi_app + """ + + __slots__ = ( + "_handler", + "_lifecycle_task", + "_max_body_size", + "_ready", + "_services", + "_shutdown_signal", + "_streaming", + ) + + def __init__( + self, + *, + services: Sequence[ServiceF], + rsgi_handler: AsyncRequestHandler | HttpAsyncApp, + max_body_size: int = 1 << 20, + streaming: bool = False, + ) -> None: + self._services = tuple(services) + self._handler = _resolve_handler(rsgi_handler) + self._max_body_size = max_body_size + self._streaming = streaming + self._ready: asyncio.Event | None = None + self._shutdown_signal: asyncio.Event | None = None + self._lifecycle_task: asyncio.Task[None] | None = None + + def __rsgi_init__(self, loop: Any) -> None: + """Schedule lifecycle startup on Granian's loop. + + Granian calls this **synchronously** (no ``await``) per worker + process when the worker is set up, so we can't wait for services + to be ``started`` here. Instead we spawn a long-running + :meth:`_lifecycle` task that enters :func:`localpost.hosting.serve` + and holds the lifetime open until :meth:`__rsgi_del__` signals + shutdown. The first :meth:`__rsgi__` call waits on + :attr:`_ready` before dispatching, so requests never see a + half-started host. + + The lifecycle is owned by a single task because anyio's cancel + scopes (used inside :func:`serve`) must be entered and exited + by the same task — splitting ``__aenter__`` / ``__aexit__`` + across two would raise ``"different task than it was entered + in"``. + + We do **not** apply the ``shutdown_on_signal`` middleware: + Granian owns signal handling; ``__rsgi_del__`` is how shutdown + reaches us. + """ + if not self._services: + return + self._ready = asyncio.Event() + self._shutdown_signal = asyncio.Event() + self._lifecycle_task = loop.create_task(self._lifecycle()) + + async def _lifecycle(self) -> None: + assert self._ready is not None + assert self._shutdown_signal is not None + try: + root = self._services[0] if len(self._services) == 1 else _compose_started(self._services) + async with serve(root): + self._ready.set() + await self._shutdown_signal.wait() + finally: + # Always release the gate — if startup failed before we + # set it, requests pile up; release so they fail fast. + self._ready.set() + + async def __rsgi__(self, scope: Any, proto: Any) -> None: + if self._ready is not None and not self._ready.is_set(): + await self._ready.wait() + if self._streaming: + await _dispatch_streaming(self._handler, self._max_body_size, scope, proto) + else: + await _dispatch_buffered(self._handler, self._max_body_size, scope, proto) + + def __rsgi_del__(self, loop: Any) -> None: + """Signal the lifecycle task to shut down. + + Granian calls this **synchronously** on its loop thread, so + ``self._shutdown_signal.set()`` runs without scheduling + cross-thread. The lifecycle task exits its + ``async with serve(...)`` block — which fires service shutdown + and waits for ``stopped`` — then this :class:`HostRSGIApp` is + done. + """ + signal = self._shutdown_signal + if signal is not None: + signal.set() + + +def _resolve_handler(handler: AsyncRequestHandler | HttpAsyncApp) -> AsyncRequestHandler: + """Accept either a raw :data:`AsyncRequestHandler` or an + :class:`HttpAsyncApp` (compile its route handler on demand).""" + # Lazy import — hosting shouldn't pull openapi at import time. + from localpost.openapi.aio.app import HttpAsyncApp as _App # noqa: PLC0415 + + if isinstance(handler, _App): + return handler._build_async_handler() + return handler + + +def _compose_started(services: Sequence[ServiceF]) -> ServiceF: + """Multi-service composition that fires ``set_started`` on the parent + once every child reports started. + + Same shape as :func:`_run_many` but suitable for + :func:`localpost.hosting.serve` consumers — ``serve`` waits for + ``started`` (or ``stopped``), and bare ``_run_many`` never fires + its own ``started`` event since it just spawns and waits. Without + this composition, ``serve(_run_many(...))`` deadlocks. ``run_app`` + avoids the issue because it uses ``run`` (no ``started`` wait), but + ``HostRSGIApp`` runs the lifetime through ``serve`` so ``started`` + must propagate. + """ + + async def _run(lt: ServiceLifetime) -> None: + children = [lt.start(svc) for svc in services] + + async def propagate_shutdown() -> None: + await lt.shutting_down.wait() + for c in children: + c.shutdown() + + if children: + lt.tg.start_soon(propagate_shutdown) + await wait_all(c.started for c in children) + lt.set_started() + await wait_all(c.stopped for c in children) + + return _run diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index 84a43a9..ef508cd 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -28,6 +28,7 @@ URITemplate, route_match, ) +from localpost.http.rsgi import to_rsgi from localpost.http.static import static_handler from localpost.http.wsgi import to_wsgi, wrap_wsgi @@ -69,6 +70,8 @@ "wrap_wsgi", # ASGI adapters "to_asgi", + # RSGI adapters + "to_rsgi", # hosting "http_server", "wsgi_server", diff --git a/localpost/http/rsgi.py b/localpost/http/rsgi.py new file mode 100644 index 0000000..fd14cad --- /dev/null +++ b/localpost/http/rsgi.py @@ -0,0 +1,469 @@ +"""RSGI transport bridge — adapt :data:`AsyncRequestHandler` ⇆ Granian's RSGI app. + +Symmetric with :mod:`localpost.http.asgi` and :mod:`localpost.http.wsgi`: +this module owns the translation between the foreign protocol (Granian's +RSGI) and our async request-context shape (:class:`AsyncHTTPReqCtx`). +The handler doesn't know anything about RSGI — it just reads +``ctx.request`` / ``ctx.body`` or ``await ctx.receive(size)``, and +calls ``await ctx.complete(...)`` / ``await ctx.stream(...)`` / +``await ctx.sendfile(...)``. + +RSGI's wire surface is richer than ASGI's, so the bridge gets a few +wins for free: + +- **Eager responses** are a single sync call (``proto.response_bytes``) + rather than ASGI's two-event dance. +- **Sendfile** uses ``proto.response_file_range`` for true zero-copy + when the file has a path; falls back to chunked stream otherwise. +- **Streaming uploads** don't need a pump task — RSGI's ``proto`` is + directly async-iterable, so :meth:`receive` wraps that iterator. + +This module covers Mode A (single HTTP app under Granian); Mode B +(host-as-RSGI for hosted apps with multiple services) lives in +:mod:`localpost.hosting.rsgi`. +""" + +from __future__ import annotations + +import threading +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, BinaryIO, final + +import anyio + +from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler +from localpost.http._types import Request +from localpost.http._types import Response as _Response +from localpost.http.config import DEFAULT_BUFFER_SIZE + +if TYPE_CHECKING: + from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +__all__ = [ + "RSGIScope", + "RSGIProtocol", + "RSGIApplication", + "to_rsgi", + "build_request_from_scope", + "addrs_from_scope", +] + + +# RSGI types — kept loose so we don't depend on a particular Granian +# version's TypedDict layout. Granian provides ``Scope`` / ``HTTPProtocol`` +# at ``granian.rsgi``; we type against ``Any`` here and trust the wire shape. +type RSGIScope = Any +type RSGIProtocol = Any +type RSGIApplication = Any + + +# --- Public adapter ----------------------------------------------------- + + +def to_rsgi( + handler: AsyncRequestHandler, + *, + max_body_size: int = 1 << 20, + streaming: bool = False, +) -> RSGIApplication: + """Wrap an :data:`AsyncRequestHandler` as an RSGI application. + + Deploy under Granian:: + + from localpost.http.rsgi import to_rsgi + + + async def my_handler(ctx): + await ctx.complete(Response(200), b"hi") + + + rsgi_app = to_rsgi(my_handler) + # granian --interface rsgi myapp:rsgi_app + + Args: + handler: The async request handler. + max_body_size: Cap on the request body, in bytes. Buffered mode + raises ``413 Payload Too Large`` before the handler runs; + streaming mode pre-checks ``Content-Length`` (when present) + and 413s if it exceeds the cap. ``-1`` disables the cap. + Defaults to ``1 << 20`` (1 MiB). + streaming: When ``True``, skip the pre-buffer — ``ctx.body`` + stays empty and the handler pulls body chunks via + ``await ctx.receive(size)``. Use for large uploads. + + Returns: + An object with ``__rsgi__`` (and no-op ``__rsgi_init__`` / + ``__rsgi_del__``) suitable as Granian's RSGI target. + """ + return _RSGIApp(handler, max_body_size=max_body_size, streaming=streaming) + + +@final +class _RSGIApp: + """Concrete RSGI app object exposing ``__rsgi__`` and the lifecycle hooks. + + No-op lifecycle here — :class:`localpost.hosting.rsgi.HostRSGIApp` + is the variant that runs a full hosting lifecycle alongside. + """ + + __slots__ = ("_handler", "_max_body_size", "_streaming") + + def __init__( + self, + handler: AsyncRequestHandler, + *, + max_body_size: int, + streaming: bool, + ) -> None: + self._handler = handler + self._max_body_size = max_body_size + self._streaming = streaming + + async def __rsgi__(self, scope: RSGIScope, proto: RSGIProtocol) -> None: + if self._streaming: + await _dispatch_streaming(self._handler, self._max_body_size, scope, proto) + else: + await _dispatch_buffered(self._handler, self._max_body_size, scope, proto) + + def __rsgi_init__(self, loop: Any) -> None: + # No background services to start. + pass + + def __rsgi_del__(self, loop: Any) -> None: + # No background services to stop. + pass + + +# --- Dispatch ----------------------------------------------------------- + + +async def _dispatch_buffered( + handler: AsyncRequestHandler, + max_body_size: int, + scope: RSGIScope, + proto: RSGIProtocol, +) -> None: + request = build_request_from_scope(scope) + if max_body_size >= 0: + cl = _content_length(request) + if cl is not None and cl > max_body_size: + await _send_canned(proto, 413, b"Payload Too Large") + return + + body = await proto() + if max_body_size >= 0 and len(body) > max_body_size: + await _send_canned(proto, 413, b"Payload Too Large") + return + + remote, local = addrs_from_scope(scope) + disconnected = threading.Event() + ctx = _RSGIReqCtx( + request=request, + body=body, + remote_addr=remote, + local_addr=local, + scheme=str(scope.scheme), + _proto=proto, + _disconnected=disconnected, + ) + async with anyio.create_task_group() as tg: + tg.start_soon(_watch_disconnect, proto, disconnected) + try: + await handler(ctx) + finally: + tg.cancel_scope.cancel() + + +async def _dispatch_streaming( + handler: AsyncRequestHandler, + max_body_size: int, + scope: RSGIScope, + proto: RSGIProtocol, +) -> None: + request = build_request_from_scope(scope) + if max_body_size >= 0: + cl = _content_length(request) + if cl is not None and cl > max_body_size: + await _send_canned(proto, 413, b"Payload Too Large") + return + + remote, local = addrs_from_scope(scope) + disconnected = threading.Event() + body_send, body_recv = anyio.create_memory_object_stream[bytes](0) + ctx = _RSGIReqCtx( + request=request, + body=b"", + remote_addr=remote, + local_addr=local, + scheme=str(scope.scheme), + _proto=proto, + _disconnected=disconnected, + _body_stream=body_recv, + ) + async with anyio.create_task_group() as tg: + tg.start_soon(_pump_body, proto, body_send) + tg.start_soon(_watch_disconnect, proto, disconnected) + try: + await handler(ctx) + finally: + tg.cancel_scope.cancel() + + +# --- Concrete ctx ------------------------------------------------------- + + +class _ResponseAlreadyStarted(RuntimeError): + """Raised if a handler tries to start two responses on one request.""" + + +@final +@dataclass(slots=True, eq=False) +class _RSGIReqCtx: + """:class:`AsyncHTTPReqCtx` backed by Granian's RSGI ``proto``. + + Two body-source modes (selected by ``to_rsgi(streaming=...)``): + + - **Buffered**: ``_body_stream`` is ``None``. The whole body has + been read via ``await proto()`` before this ctx was built; + :meth:`receive` slices ``body``. + - **Streaming**: ``_body_stream`` is set; ``body`` is empty. + :meth:`receive` pulls chunks from the in-process queue that + :func:`_pump_body` feeds from ``async for chunk in proto``. + + Response paths translate directly to RSGI calls: + + - :meth:`complete` → ``proto.response_bytes`` (or ``response_empty``). + - :meth:`stream` → ``proto.response_stream`` + ``transport.send_bytes``. + - :meth:`sendfile` → ``proto.response_file_range`` for true + zero-copy when the file has a filesystem path; chunked stream + fallback otherwise. + + ``disconnected`` flips when :func:`_watch_disconnect` resolves the + awaitable returned by ``proto.client_disconnect()``. + """ + + request: Request + body: bytes + remote_addr: str | None + local_addr: str + scheme: str + _proto: RSGIProtocol + _disconnected: threading.Event + response_status: int | None = None + attrs: dict[Any, Any] = field(default_factory=dict) + _started: bool = False + _body_cursor: int = 0 + _body_stream: MemoryObjectReceiveStream[bytes] | None = None + _stream_eof: bool = False + _stream_leftover: bytes = b"" + + @property + def disconnected(self) -> bool: + return self._disconnected.is_set() + + async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: + if self._body_stream is None: + return self._slice_buffered(size) + return await self._receive_streaming(size) + + def _slice_buffered(self, size: int) -> bytes: + if self._body_cursor >= len(self.body): + return b"" + end = self._body_cursor + size + chunk = self.body[self._body_cursor : end] + self._body_cursor += len(chunk) + return chunk + + async def _receive_streaming(self, size: int) -> bytes: + if self._stream_leftover: + if len(self._stream_leftover) <= size: + chunk = self._stream_leftover + self._stream_leftover = b"" + return chunk + chunk = self._stream_leftover[:size] + self._stream_leftover = self._stream_leftover[size:] + return chunk + if self._stream_eof: + return b"" + assert self._body_stream is not None + try: + chunk = await self._body_stream.receive() + except anyio.EndOfStream: + self._stream_eof = True + return b"" + if len(chunk) <= size: + return chunk + self._stream_leftover = chunk[size:] + return chunk[:size] + + async def complete(self, response: _Response, body: bytes | None = None) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + headers = _str_headers(response.headers) + if not body: + self._proto.response_empty(response.status_code, headers) + else: + self._proto.response_bytes(response.status_code, headers, body) + + async def stream(self, response: _Response, chunks: AsyncIterator[bytes], /) -> None: + self._check_not_started() + self._started = True + self.response_status = response.status_code + headers = _str_headers(response.headers) + transport = self._proto.response_stream(response.status_code, headers) + async for chunk in chunks: + if self._disconnected.is_set(): + return + await transport.send_bytes(bytes(chunk)) + + async def sendfile(self, response: _Response, file: BinaryIO, offset: int, count: int) -> None: + """Try ``proto.response_file_range`` for zero-copy when ``file`` + has a filesystem path; fall back to chunked stream otherwise.""" + self._check_not_started() + path = _file_path(file) + if path is not None: + self._started = True + self.response_status = response.status_code + headers = _str_headers(response.headers) + self._proto.response_file_range( + response.status_code, headers, path, offset, offset + count + ) + return + # No filesystem path — chunked read fallback. + file.seek(offset) + chunks = _read_file_chunks(file, count, DEFAULT_BUFFER_SIZE) + await self.stream(response, chunks) + + def _check_not_started(self) -> None: + if self._started: + raise _ResponseAlreadyStarted("Response already started") + + +# Concrete ctx implements the AsyncHTTPReqCtx Protocol — verify at import. +_: type[AsyncHTTPReqCtx] = _RSGIReqCtx + + +# --- Scope translation -------------------------------------------------- + + +def build_request_from_scope(scope: RSGIScope) -> Request: + """Build a localpost :class:`Request` from an RSGI ``scope``.""" + method = scope.method.encode("ascii") + path = scope.path.encode("utf-8") + query_string = scope.query_string.encode("ascii") if scope.query_string else b"" + target = path + (b"?" + query_string if query_string else b"") + headers = tuple( + (str(name).lower().encode("ascii"), str(value).encode("iso-8859-1")) + for name, value in scope.headers.items() + ) + http_version = scope.http_version.encode("ascii") + return Request( + method=method, + target=target, + path=path, + query_string=query_string, + headers=headers, + http_version=http_version, + ) + + +def addrs_from_scope(scope: RSGIScope) -> tuple[str | None, str]: + """Return ``(remote_addr, local_addr)`` from an RSGI scope. + + RSGI uses ``"host:port"`` strings on ``scope.client`` / + ``scope.server`` — pass them through. + """ + client = getattr(scope, "client", None) or None + server = getattr(scope, "server", "") or "" + return (str(client) if client else None, str(server)) + + +def _content_length(request: Request) -> int | None: + for name, value in request.headers: + if name == b"content-length": + try: + return int(value) + except ValueError: + return None + return None + + +def _str_headers(headers: Any) -> list[tuple[str, str]]: + """Convert localpost's bytes headers into RSGI's ``(str, str)`` shape.""" + return [ + ( + name.decode("iso-8859-1") if isinstance(name, bytes) else str(name), + value.decode("iso-8859-1") if isinstance(value, bytes) else str(value), + ) + for name, value in headers + ] + + +def _file_path(file: BinaryIO) -> str | None: + name = getattr(file, "name", None) + if name is None or not isinstance(name, str): + return None + return name + + +# --- Body / disconnect / canned responses ------------------------------- + + +async def _pump_body( + proto: RSGIProtocol, + body_send: MemoryObjectSendStream[bytes], +) -> None: + """Streaming-mode body pump: relay ``async for chunk in proto`` into + ``body_send`` so :meth:`_RSGIReqCtx.receive` can consume chunks + serialised through anyio's memory stream. + + RSGI's proto is single-consumer (you can't iterate it from two + tasks), so we own iteration here and the ctx pulls from the + queue. Disconnect detection runs in a sibling task — RSGI exposes + ``proto.client_disconnect()`` separately, so we don't need ASGI's + demux pattern. + """ + try: + async with body_send: + async for chunk in proto: + if chunk: + await body_send.send(bytes(chunk)) + except Exception: # noqa: BLE001 + return + + +async def _watch_disconnect(proto: RSGIProtocol, flag: threading.Event) -> None: + """Set ``flag`` when ``proto.client_disconnect()`` resolves.""" + try: + await proto.client_disconnect() + except Exception: # noqa: BLE001 + return + flag.set() + + +async def _send_canned(proto: RSGIProtocol, status: int, body: bytes) -> None: + headers = [ + ("content-type", "text/plain; charset=utf-8"), + ("content-length", str(len(body))), + ] + proto.response_bytes(status, headers, body) + + +def _read_file_chunks(file: BinaryIO, count: int, blksize: int) -> AsyncIterator[bytes]: + """Async-iterator wrapper around a sync file's chunked read. + Used by :meth:`_RSGIReqCtx.sendfile` when zero-copy isn't available.""" + + async def gen() -> AsyncIterator[bytes]: + remaining = count + while remaining > 0: + chunk = file.read(min(blksize, remaining)) + if not chunk: + return + remaining -= len(chunk) + yield chunk + + return gen() + + diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py index df819c7..7d85f69 100644 --- a/localpost/openapi/aio/app.py +++ b/localpost/openapi/aio/app.py @@ -24,10 +24,11 @@ from typing import Any, Literal from localpost import hosting -from localpost.http import AsyncHTTPReqCtx, AsyncRequestHandler, to_asgi +from localpost.http import AsyncHTTPReqCtx, AsyncRequestHandler, to_asgi, to_rsgi from localpost.http._types import Response from localpost.http.asgi import ASGIApp from localpost.http.router import RouteMatch, URITemplate +from localpost.http.rsgi import RSGIApplication from localpost.openapi import spec as openapi_spec from localpost.openapi._docs import redoc_html, scalar_html, swagger_html from localpost.openapi.adapters import AdapterRegistry, default_registry @@ -185,6 +186,24 @@ def asgi(self) -> ASGIApp: """ return to_asgi(self._build_async_handler(), max_body_size=self._max_body_size) + def as_rsgi(self) -> RSGIApplication: + """Return an RSGI application object for native Granian deployment. + + Sugar over :func:`localpost.http.to_rsgi`. Same handler chain as + :meth:`asgi` (route table + built-ins), but wrapped for RSGI + instead of ASGI — single eager ``response_bytes`` per response, + zero-copy ``response_file_range`` for sendfile, no pump task on + streaming uploads. Deploy with:: + + granian --interface rsgi myapp:rsgi_app + + For deployments with **other hosted services** (scheduler etc.) + co-located with the HTTP app inside each Granian worker, use + :class:`localpost.hosting.rsgi.HostRSGIApp` instead — it drives + the full hosting lifecycle from Granian's per-worker hooks. + """ + return to_rsgi(self._build_async_handler(), max_body_size=self._max_body_size) + def service( self, config: Any, diff --git a/tests/hosting/rsgi.py b/tests/hosting/rsgi.py new file mode 100644 index 0000000..6e4294f --- /dev/null +++ b/tests/hosting/rsgi.py @@ -0,0 +1,273 @@ +"""Tests for ``localpost.hosting.HostRSGIApp`` — host-as-RSGI for +hosted apps under Granian. + +Drives the lifecycle hooks (``__rsgi_init__`` / ``__rsgi__`` / +``__rsgi_del__``) directly with a mocked RSGI scope + proto, bypassing +Granian itself. End-to-end coverage with a real Granian worker lives +in ``tests/openapi/aio_rsgi_integration.py`` (marked ``integration``). + +Granian's hooks are sync (``callback_init(loop)``, ``callback_del(loop)``); +:class:`HostRSGIApp` schedules async startup/shutdown on the loop and +gates the first request on a "ready" event. These tests exercise the +gate by yielding control back to the loop after init. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from localpost import hosting +from localpost.http import AsyncHTTPReqCtx, Response + +# All tests in this file run under asyncio (HostRSGIApp uses asyncio +# primitives — Granian's loop is asyncio-only). +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +# --- Mocks -------------------------------------------------------------- + + +class _FakeHeaders: + def __init__(self, pairs: list[tuple[str, str]] | None = None) -> None: + self._pairs = pairs or [] + + def items(self) -> list[tuple[str, str]]: + return list(self._pairs) + + +@dataclass(slots=True, eq=False) +class _FakeScope: + method: str = "GET" + path: str = "/" + query_string: str = "" + http_version: str = "1.1" + scheme: str = "http" + client: str = "127.0.0.1:54321" + server: str = "127.0.0.1:8000" + headers: _FakeHeaders = field(default_factory=_FakeHeaders) + + +@dataclass(slots=True, eq=False) +class _FakeProto: + body_chunks: list[bytes] = field(default_factory=list) + response_status: int | None = None + response_body: bytes | None = None + + async def __call__(self) -> bytes: + return b"".join(self.body_chunks) + + def __aiter__(self) -> AsyncIterator[bytes]: + async def gen() -> AsyncIterator[bytes]: + for c in self.body_chunks: + yield c + + return gen() + + async def client_disconnect(self) -> None: + await asyncio.Event().wait() # never resolves + + def response_empty(self, status: int, headers: Any) -> None: # noqa: ARG002 + self.response_status = status + self.response_body = b"" + + def response_bytes(self, status: int, headers: Any, body: bytes) -> None: # noqa: ARG002 + self.response_status = status + self.response_body = body + + +# --- Test services ------------------------------------------------------ + + +def _tracking_service(name: str, log: list[str]) -> hosting.ServiceF: + """A service that records its lifecycle into ``log``.""" + + @hosting.service + async def svc(sl: hosting.ServiceLifetime) -> None: + log.append(f"{name}:starting") + sl.set_started() + log.append(f"{name}:running") + await sl.shutting_down.wait() + log.append(f"{name}:stopping") + + return svc() + + +# --- Helpers ------------------------------------------------------------ + + +async def _drive_startup(app: hosting.HostRSGIApp) -> None: + """Trigger ``__rsgi_init__`` then yield to the loop until services + are started — Granian's real flow is async, so we mimic that here.""" + loop = asyncio.get_running_loop() + app.__rsgi_init__(loop) + if app._ready is not None: + await app._ready.wait() + + +async def _drive_shutdown(app: hosting.HostRSGIApp) -> None: + """Trigger ``__rsgi_del__`` and let the loop drain the lifecycle task.""" + loop = asyncio.get_running_loop() + app.__rsgi_del__(loop) + task = app._lifecycle_task + if task is not None: + try: + await asyncio.wait_for(task, timeout=5) + except asyncio.CancelledError: + pass + + +# --- Tests -------------------------------------------------------------- + + +class TestLifecycle: + async def test_services_started_before_requests(self) -> None: + log: list[str] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + log.append("request") + await ctx.complete(Response(200), b"ok") + + app = hosting.HostRSGIApp( + services=[_tracking_service("svc", log)], + rsgi_handler=handler, + ) + + await _drive_startup(app) + # By the time the ready event fires, the service is running. + assert "svc:starting" in log + assert "svc:running" in log + assert "request" not in log + + await app.__rsgi__(_FakeScope(), _FakeProto()) + assert "request" in log + + await _drive_shutdown(app) + assert "svc:stopping" in log + + async def test_request_dispatch_works(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"hello-from-host") + + app = hosting.HostRSGIApp(services=[], rsgi_handler=handler) + await _drive_startup(app) + try: + proto = _FakeProto() + await app.__rsgi__(_FakeScope(), proto) + assert proto.response_status == 200 + assert proto.response_body == b"hello-from-host" + finally: + await _drive_shutdown(app) + + async def test_no_services_no_lifecycle_overhead(self) -> None: + """When ``services=[]``, init / del are no-ops — a pure dispatch + path symmetric with :func:`to_rsgi`.""" + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"ok") + + app = hosting.HostRSGIApp(services=[], rsgi_handler=handler) + await _drive_startup(app) + await _drive_shutdown(app) + # ``_ready`` stays ``None`` when there are no services — dispatch + # still works without any wait. + assert app._ready is None + + async def test_multiple_services_all_run(self) -> None: + log: list[str] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"ok") + + app = hosting.HostRSGIApp( + services=[ + _tracking_service("a", log), + _tracking_service("b", log), + ], + rsgi_handler=handler, + ) + + await _drive_startup(app) + try: + assert {"a:starting", "b:starting"}.issubset(log) + assert {"a:running", "b:running"}.issubset(log) + finally: + await _drive_shutdown(app) + + assert {"a:stopping", "b:stopping"}.issubset(log) + + +class TestHandlerResolution: + async def test_accepts_async_request_handler(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"raw") + + app = hosting.HostRSGIApp(services=[], rsgi_handler=handler) + await _drive_startup(app) + try: + proto = _FakeProto() + await app.__rsgi__(_FakeScope(), proto) + assert proto.response_body == b"raw" + finally: + await _drive_shutdown(app) + + async def test_accepts_http_async_app(self) -> None: + from localpost.openapi import HttpAsyncApp # noqa: PLC0415 + + oapi_app = HttpAsyncApp(openapi_path=None, docs_path=None) + + @oapi_app.get("/hello") + async def hello() -> str: + return "world" + + _ = hello + app = hosting.HostRSGIApp(services=[], rsgi_handler=oapi_app) + await _drive_startup(app) + try: + proto = _FakeProto() + await app.__rsgi__(_FakeScope(path="/hello"), proto) + assert proto.response_status == 200 + assert proto.response_body == b"world" + finally: + await _drive_shutdown(app) + + +class TestRequestGate: + async def test_first_request_waits_for_startup(self) -> None: + """A request that arrives before startup completes should block + on the gate until services are ``started``.""" + log: list[str] = [] + + @hosting.service + async def slow_starter(sl: hosting.ServiceLifetime) -> None: + log.append("starting") + await asyncio.sleep(0.05) # simulate a real startup hook + sl.set_started() + log.append("started") + await sl.shutting_down.wait() + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + log.append("request") + await ctx.complete(Response(200), b"ok") + + app = hosting.HostRSGIApp(services=[slow_starter()], rsgi_handler=handler) + loop = asyncio.get_running_loop() + app.__rsgi_init__(loop) + # Fire the request immediately — must not be served until + # ``slow_starter`` reports started. + await app.__rsgi__(_FakeScope(), _FakeProto()) + # Ordering: started must come before the first request. + started_idx = log.index("started") + request_idx = log.index("request") + assert started_idx < request_idx, log + + await _drive_shutdown(app) diff --git a/tests/http/rsgi.py b/tests/http/rsgi.py new file mode 100644 index 0000000..7f4702b --- /dev/null +++ b/tests/http/rsgi.py @@ -0,0 +1,371 @@ +"""Tests for ``localpost.http.rsgi`` — :func:`to_rsgi` adapter and the +underlying :class:`_RSGIReqCtx` implementation. + +Drives the bridge against a mocked RSGI scope + proto pair (the proto +surface is small enough to fake reliably). End-to-end tests under a +real Granian via ``granian.server.embed`` live in +``tests/openapi/aio_rsgi_integration.py``. + +Symmetric with ``tests/http/asgi.py`` for the ASGI side. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterable +from dataclasses import dataclass, field +from typing import Any + +import anyio +import pytest + +from localpost.http import AsyncHTTPReqCtx, Response, to_rsgi +from localpost.http.rsgi import _RSGIReqCtx, addrs_from_scope, build_request_from_scope + + +# --- Mocks -------------------------------------------------------------- + + +class _FakeHeaders: + """Stand-in for ``granian.rsgi.RSGIHeaders``. Iterates ``items()`` + as ``(name, value)`` string pairs — that's the only surface our + bridge touches.""" + + def __init__(self, pairs: Iterable[tuple[str, str]]) -> None: + self._pairs = list(pairs) + + def items(self) -> list[tuple[str, str]]: + return list(self._pairs) + + +@dataclass(slots=True, eq=False) +class _FakeScope: + """Stand-in for ``granian.rsgi.Scope``. RSGI scope is just a struct + of strings; the bridge reads only what it needs.""" + + method: str = "GET" + path: str = "/" + query_string: str = "" + http_version: str = "1.1" + scheme: str = "http" + client: str = "127.0.0.1:54321" + server: str = "127.0.0.1:8000" + headers: _FakeHeaders = field(default_factory=lambda: _FakeHeaders([])) + + +@dataclass(slots=True, eq=False) +class _FakeStreamTransport: + """Stand-in for ``granian._granian.RSGIHTTPStreamTransport``.""" + + sent: list[bytes] = field(default_factory=list) + + async def send_bytes(self, data: bytes) -> None: + self.sent.append(bytes(data)) + + async def send_str(self, data: str) -> None: + self.sent.append(data.encode("utf-8")) + + +@dataclass(slots=True, eq=False) +class _FakeProto: + """Stand-in for ``granian.rsgi.HTTPProtocol``. + + Captures the response side via the ``response_*`` calls; serves the + request body via ``__call__`` (buffered) or ``__aiter__`` (streamed). + Exposes a ``disconnect_event`` that ``client_disconnect()`` awaits — + set it to simulate peer-gone. + """ + + body_chunks: list[bytes] = field(default_factory=list) + disconnect_event: anyio.Event = field(default_factory=anyio.Event) + response_kind: str | None = None + response_status: int | None = None + response_headers: list[tuple[str, str]] = field(default_factory=list) + response_body: bytes | str | None = None + response_file: tuple[str, int, int] | None = None + transport: _FakeStreamTransport | None = None + + async def __call__(self) -> bytes: + return b"".join(self.body_chunks) + + def __aiter__(self) -> AsyncIterator[bytes]: + async def gen() -> AsyncIterator[bytes]: + for c in self.body_chunks: + yield c + + return gen() + + async def client_disconnect(self) -> None: + await self.disconnect_event.wait() + + def response_empty(self, status: int, headers: list[tuple[str, str]]) -> None: + self.response_kind = "empty" + self.response_status = status + self.response_headers = list(headers) + + def response_bytes(self, status: int, headers: list[tuple[str, str]], body: bytes) -> None: + self.response_kind = "bytes" + self.response_status = status + self.response_headers = list(headers) + self.response_body = body + + def response_str(self, status: int, headers: list[tuple[str, str]], body: str) -> None: + self.response_kind = "str" + self.response_status = status + self.response_headers = list(headers) + self.response_body = body + + def response_file_range( + self, + status: int, + headers: list[tuple[str, str]], + file: str, + start: int, + end: int, + ) -> None: + self.response_kind = "file_range" + self.response_status = status + self.response_headers = list(headers) + self.response_file = (file, start, end) + + def response_stream(self, status: int, headers: list[tuple[str, str]]) -> _FakeStreamTransport: + self.response_kind = "stream" + self.response_status = status + self.response_headers = list(headers) + self.transport = _FakeStreamTransport() + return self.transport + + +# --- Helpers ------------------------------------------------------------ + + +def _build_ctx(*, body: bytes = b"", proto: _FakeProto | None = None) -> _RSGIReqCtx: + """Minimal _RSGIReqCtx for ctx-only checks.""" + from localpost.http._types import Request # noqa: PLC0415 + + request = Request(b"GET", b"/", b"/", b"", []) + return _RSGIReqCtx( + request=request, + body=body, + remote_addr=None, + local_addr="127.0.0.1:8000", + scheme="http", + _proto=proto or _FakeProto(), + _disconnected=__import__("threading").Event(), + ) + + +# --- Protocol conformance ----------------------------------------------- + + +def test_rsgi_ctx_satisfies_async_protocol() -> None: + """Concrete ``_RSGIReqCtx`` should satisfy ``AsyncHTTPReqCtx`` via + structural typing — guard against drift.""" + ctx = _build_ctx() + assert isinstance(ctx, AsyncHTTPReqCtx) + + +# --- Scope translation -------------------------------------------------- + + +class TestScopeTranslation: + def test_request_built_from_scope(self) -> None: + scope = _FakeScope( + method="POST", + path="/items/42", + query_string="q=1", + headers=_FakeHeaders([("content-type", "application/json")]), + ) + req = build_request_from_scope(scope) + assert req.method == b"POST" + assert req.path == b"/items/42" + assert req.query_string == b"q=1" + assert req.target == b"/items/42?q=1" + assert (b"content-type", b"application/json") in req.headers + + def test_addrs_pass_through(self) -> None: + scope = _FakeScope(client="1.2.3.4:5555", server="10.0.0.1:8000") + remote, local = addrs_from_scope(scope) + assert remote == "1.2.3.4:5555" + assert local == "10.0.0.1:8000" + + +# --- Ctx behaviour ------------------------------------------------------ + + +class TestCtxComplete: + @pytest.mark.anyio + async def test_complete_with_body_uses_response_bytes(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + await ctx.complete(Response(status_code=200, headers=[(b"x-y", b"z")]), b"hello") + assert proto.response_kind == "bytes" + assert proto.response_status == 200 + assert ("x-y", "z") in proto.response_headers + assert proto.response_body == b"hello" + + @pytest.mark.anyio + async def test_complete_empty_uses_response_empty(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + await ctx.complete(Response(status_code=204)) + assert proto.response_kind == "empty" + assert proto.response_status == 204 + + @pytest.mark.anyio + async def test_complete_twice_raises(self) -> None: + ctx = _build_ctx() + await ctx.complete(Response(200), b"x") + with pytest.raises(RuntimeError, match="already started"): + await ctx.complete(Response(200), b"y") + + +class TestCtxStream: + @pytest.mark.anyio + async def test_stream_drains_chunks(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + yield b"b" + + await ctx.stream(Response(200), chunks()) + assert proto.response_kind == "stream" + assert proto.transport is not None + assert proto.transport.sent == [b"a", b"b"] + + @pytest.mark.anyio + async def test_stream_short_circuits_on_disconnect(self) -> None: + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + + async def chunks() -> AsyncIterator[bytes]: + yield b"a" + ctx._disconnected.set() + yield b"b" # should not reach the wire + + await ctx.stream(Response(200), chunks()) + assert proto.transport is not None + assert proto.transport.sent == [b"a"] + + +class TestCtxSendfile: + @pytest.mark.anyio + async def test_sendfile_uses_response_file_range_when_path_present(self, tmp_path) -> None: # type: ignore[no-untyped-def] + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + f = tmp_path / "x.bin" + f.write_bytes(b"abcdef") + with f.open("rb") as fh: + await ctx.sendfile(Response(200), fh, offset=1, count=3) + assert proto.response_kind == "file_range" + # Path passed through as-is; range expressed as (start, end). + assert proto.response_file == (str(f), 1, 4) + + @pytest.mark.anyio + async def test_sendfile_falls_back_to_stream_when_no_path(self) -> None: + import io # noqa: PLC0415 + + proto = _FakeProto() + ctx = _build_ctx(proto=proto) + # BytesIO has no ``name`` attribute → fallback path. + stream = io.BytesIO(b"abcdef") + await ctx.sendfile(Response(200), stream, offset=1, count=3) + assert proto.response_kind == "stream" + assert proto.transport is not None + assert b"".join(proto.transport.sent) == b"bcd" + + +class TestCtxReceive: + """Buffered ``receive(size)`` slices the body — same shape as the + sync :class:`_WSGIReqCtx.receive` helper.""" + + @pytest.mark.anyio + async def test_receive_slices_buffer(self) -> None: + ctx = _build_ctx(body=b"abcdef") + assert await ctx.receive(2) == b"ab" + assert await ctx.receive(3) == b"cde" + assert await ctx.receive(10) == b"f" + assert await ctx.receive(10) == b"" + + +# --- to_rsgi end-to-end (mocked proto) --------------------------------- + + +async def _drive(rsgi_app: Any, scope: _FakeScope, proto: _FakeProto) -> None: + """Invoke the RSGI app's __rsgi__ once.""" + await rsgi_app.__rsgi__(scope, proto) + + +class TestToRsgiBuffered: + @pytest.mark.anyio + async def test_simple_round_trip(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: + await ctx.complete(Response(200), b"hi") + + rsgi_app = to_rsgi(handler) + proto = _FakeProto() + await _drive(rsgi_app, _FakeScope(), proto) + assert proto.response_status == 200 + assert proto.response_body == b"hi" + + @pytest.mark.anyio + async def test_body_pre_buffered(self) -> None: + captured: dict[str, bytes] = {} + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + captured["body"] = ctx.body + await ctx.complete(Response(200), b"ok") + + rsgi_app = to_rsgi(handler) + proto = _FakeProto(body_chunks=[b"hello", b"-world"]) + await _drive(rsgi_app, _FakeScope(method="POST"), proto) + assert captured["body"] == b"hello-world" + + @pytest.mark.anyio + async def test_body_too_large_returns_413_via_content_length(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: # noqa: ARG001 + raise AssertionError("handler should not run") + + rsgi_app = to_rsgi(handler, max_body_size=4) + proto = _FakeProto(body_chunks=[b"too-long-body"]) + scope = _FakeScope( + method="POST", + headers=_FakeHeaders([("content-length", "13")]), + ) + await _drive(rsgi_app, scope, proto) + assert proto.response_status == 413 + + +class TestToRsgiStreaming: + @pytest.mark.anyio + async def test_chunks_arrive_in_order(self) -> None: + captured: list[bytes] = [] + + async def handler(ctx: AsyncHTTPReqCtx) -> None: + assert ctx.body == b"" + while True: + chunk = await ctx.receive(64) + if not chunk: + break + captured.append(chunk) + await ctx.complete(Response(200), b"ok") + + rsgi_app = to_rsgi(handler, streaming=True) + proto = _FakeProto(body_chunks=[b"first", b"second", b"third"]) + await _drive(rsgi_app, _FakeScope(method="POST"), proto) + assert b"".join(captured) == b"firstsecondthird" + + @pytest.mark.anyio + async def test_content_length_pre_check_413(self) -> None: + async def handler(ctx: AsyncHTTPReqCtx) -> None: # noqa: ARG001 + raise AssertionError("handler should not run") + + rsgi_app = to_rsgi(handler, streaming=True, max_body_size=4) + proto = _FakeProto() + scope = _FakeScope( + method="POST", + headers=_FakeHeaders([("content-length", "100")]), + ) + await _drive(rsgi_app, scope, proto) + assert proto.response_status == 413 diff --git a/tests/openapi/aio_rsgi_integration.py b/tests/openapi/aio_rsgi_integration.py new file mode 100644 index 0000000..06153bb --- /dev/null +++ b/tests/openapi/aio_rsgi_integration.py @@ -0,0 +1,259 @@ +"""End-to-end RSGI tests under a real Granian server. + +The unit suites (``tests/http/rsgi.py`` and ``tests/hosting/rsgi.py``) +drive the bridge with a mocked proto. These tests run the full stack +under :class:`granian.server.embed.Server` to catch what in-process +mocks miss — header round-trip through the real RSGI implementation, +chunked SSE delivery, and Mode B (host-as-RSGI) with a background +service running alongside the HTTP handler. + +Each test spins Granian on a free loopback port for the duration of +the test, then shuts it down cleanly. +""" + +from __future__ import annotations + +import asyncio +import socket +import threading +from collections.abc import AsyncIterator, Iterator +from dataclasses import dataclass + +import httpx +import pytest +from granian.constants import Interfaces +from granian.server.embed import Server + +from localpost import hosting +from localpost.openapi import ( + BadRequest, + Created, + HttpAsyncApp, + NotFound, + spec, +) + +# These tests cost ~1-2s each; keep them off the unit run. +pytestmark = pytest.mark.integration + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _GranianThread: + """Run :class:`granian.server.embed.Server` on its own asyncio loop + in a daemon thread — same shape as the uvicorn fixture in + ``aio_integration.py``.""" + + def __init__(self, target: object, port: int) -> None: + self._target = target + self._port = port + self._loop: asyncio.AbstractEventLoop | None = None + self._serve_task: asyncio.Task[None] | None = None + self._server: Server | None = None + self._thread: threading.Thread | None = None + self._ready = threading.Event() + + def __enter__(self) -> int: + def run() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + + self._server = Server( + target=self._target, + address="127.0.0.1", + port=self._port, + interface=Interfaces.RSGI, + log_enabled=False, + log_access=False, + ) + + async def serve_then_signal() -> None: + # Wait briefly for the server to bind, then mark ready. + # Granian's embed Server doesn't expose a "started" event, + # so we poll the port instead. + pass + + try: + self._serve_task = loop.create_task(self._server.serve()) + self._ready.set() + loop.run_until_complete(self._serve_task) + except Exception: # noqa: BLE001 + self._ready.set() + finally: + loop.close() + + self._thread = threading.Thread(target=run, daemon=True, name=f"granian-{self._port}") + self._thread.start() + self._ready.wait(timeout=5) + # Wait for the port to actually be accepting connections. + deadline = asyncio.get_event_loop_policy().new_event_loop().time() + 5.0 # noqa: SLF001 + while True: + try: + with socket.create_connection(("127.0.0.1", self._port), timeout=0.1): + break + except OSError: + if asyncio.get_event_loop_policy().new_event_loop().time() > deadline: # noqa: SLF001 + raise RuntimeError("Granian failed to start within 5s") from None + threading.Event().wait(0.05) + return self._port + + def __exit__(self, *_exc: object) -> None: + loop = self._loop + srv = self._server + if loop is not None and srv is not None: + try: + fut = asyncio.run_coroutine_threadsafe(srv.shutdown(), loop) + fut.result(timeout=5) + except Exception: # noqa: BLE001 + pass + try: + loop.call_soon_threadsafe(loop.stop) + except Exception: # noqa: BLE001 + pass + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=5) + + +# --- Sample app ---------------------------------------------------------- + + +@dataclass +class Book: + id: str + title: str + + +def _library_app() -> HttpAsyncApp: + app = HttpAsyncApp(info=spec.Info(title="Library API", version="1.0.0")) + library: dict[str, Book] = {"42": Book(id="42", title="HHGTTG")} + + @app.get("/hello/{name}") + async def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + @app.get("/books/{book_id}") + async def get_book(book_id: str) -> Book | NotFound[str]: + book = library.get(book_id) + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + @app.post("/books") + async def create_book(book: Book) -> Created[Book]: + library[book.id] = book + return Created(book, headers={"Location": f"/books/{book.id}"}) + + _ = (hello, get_book, create_book) + return app + + +# --- Tests --------------------------------------------------------------- + + +class TestModeARoundTrip: + """``HttpAsyncApp.as_rsgi()`` deployed under Granian.""" + + def test_hello_world(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + assert resp.status_code == 200 + assert resp.text == "Hello, world!" + + def test_op_result_404(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/books/missing", timeout=5) + assert resp.status_code == 404 + assert resp.text == "Book not found: missing" + + def test_post_json(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.post( + f"http://127.0.0.1:{port}/books", + json={"id": "7", "title": "Dune"}, + timeout=5, + ) + assert resp.status_code == 201 + assert resp.headers["Location"] == "/books/7" + assert resp.json() == {"id": "7", "title": "Dune"} + + def test_openapi_endpoint(self) -> None: + app = _library_app() + with _GranianThread(app.as_rsgi(), _free_port()) as port: + resp = httpx.get(f"http://127.0.0.1:{port}/openapi.json", timeout=5) + assert resp.status_code == 200 + doc = resp.json() + assert doc["openapi"].startswith("3.2") + assert "/hello/{name}" in doc["paths"] + + +class TestModeBHostedApp: + """``HostRSGIApp`` — background service runs in the same Granian + worker as the HTTP handler.""" + + def test_background_service_runs_alongside_handler(self) -> None: + # A heartbeat service that ticks while the worker is up. + ticks: list[float] = [] + ticks_lock = threading.Lock() + + @hosting.service + async def heartbeat(sl: hosting.ServiceLifetime) -> None: + sl.set_started() + try: + while not sl.shutting_down.is_set(): + with ticks_lock: + ticks.append(0.0) + # Don't actually sleep on real time — just yield enough + # for a few ticks during the test. + await asyncio.sleep(0.05) + except Exception: # noqa: BLE001 + pass + + app = _library_app() + rsgi_app = hosting.HostRSGIApp( + services=[heartbeat()], + rsgi_handler=app, + ) + + with _GranianThread(rsgi_app, _free_port()) as port: + # Hit the HTTP side a couple of times. + r1 = httpx.get(f"http://127.0.0.1:{port}/hello/world", timeout=5) + r2 = httpx.get(f"http://127.0.0.1:{port}/books/42", timeout=5) + + assert r1.status_code == 200 + assert r2.status_code == 200 + # The background service ticked at least once during the test. + with ticks_lock: + assert len(ticks) >= 1, "heartbeat service did not tick" + + +class TestSSE: + """Real chunked SSE delivery through Granian's response_stream path.""" + + def test_event_stream_round_trip(self) -> None: + app = HttpAsyncApp(openapi_path=None, docs_path=None) + + @app.get("/clock") + async def clock() -> AsyncIterator[str]: + for i in range(3): + yield f"tick-{i}" + await asyncio.sleep(0.05) + + _ = clock + with _GranianThread(app.as_rsgi(), _free_port()) as port: + with httpx.stream("GET", f"http://127.0.0.1:{port}/clock", timeout=5) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + wire = b"".join(resp.iter_bytes()) + assert wire.count(b"\n\n") == 3 + assert b"data: tick-0\n\n" in wire + assert b"data: tick-2\n\n" in wire From 9432e9f884e9ab45a5f5635be724b6c6d37ecaee Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 16:43:33 +0400 Subject: [PATCH 210/286] docs: RSGI subsections + [rsgi] extra + deployment-topologies note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyproject.toml: new [rsgi] extra → granian>=2.7. Core install stays Granian-free; users opt in. - localpost/http/README.md: new ``localpost.http.rsgi`` subsection sibling of the ASGI one. Wins listed (single eager response_bytes, zero-copy response_file_range, async-iterable proto for streaming uploads); deployment block; pointers to HttpAsyncApp.as_rsgi() and HostRSGIApp. - localpost/hosting/README.md: new "Host as RSGI for Granian" section describing Mode B (HostRSGIApp). Covers per-worker side effects + the lifecycle hook gymnastics required because Granian calls hooks synchronously. - localpost/openapi/README.md: deployment block now covers ASGI, RSGI (single HTTP app), and HostRSGIApp (hosted apps). Replaces the previous "RSGI is a follow-up" pointer (it's no longer planned; it's in). - docs/design/deployment-topologies.md: new design note explaining why uvicorn/hypercorn-as-a-hosted-service and Granian-as-a-supervisor are asymmetric — and why HostRSGIApp inverts the topology to keep the in-process service-sharing story intact. - docs/index.md: link to the new design note. Plus formatter-driven cleanup across the RSGI + ASGI tests (line length, redundant noqa). --- docs/design/deployment-topologies.md | 146 ++++++++++++++++++++++++++ docs/index.md | 10 +- localpost/hosting/README.md | 64 +++++++++++ localpost/http/README.md | 45 ++++++++ localpost/http/rsgi.py | 9 +- localpost/openapi/README.md | 47 ++++++++- pyproject.toml | 5 + tests/hosting/rsgi.py | 4 +- tests/http/asgi.py | 2 +- tests/http/rsgi.py | 5 +- tests/openapi/aio_rsgi_integration.py | 19 ++-- 11 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 docs/design/deployment-topologies.md diff --git a/docs/design/deployment-topologies.md b/docs/design/deployment-topologies.md new file mode 100644 index 0000000..6399ad4 --- /dev/null +++ b/docs/design/deployment-topologies.md @@ -0,0 +1,146 @@ +# Deployment topologies — uvicorn vs Granian + +`localpost` supports three async deployment targets: **uvicorn**, +**hypercorn**, and **Granian**. The first two are *in-process* — they +run as a hosted service inside `hosting.run_app(...)`, alongside any +other services you've registered. Granian is *out-of-process* — it's +a process supervisor that spawns workers and loads our app via its +RSGI interface. + +The two cases look superficially similar (both serve HTTP, both run +async), but the deployment topology is inverted. This note explains +why and how the framework's API surface reflects that. + +## In-process: uvicorn / hypercorn + +``` +┌─────────────────────────────────────────────┐ +│ Process owned by localpost.hosting │ +│ ├─ scheduler service │ +│ ├─ uvicorn_server(config) service │ +│ │ └─ uvicorn.Server.serve() │ +│ │ └─ ASGI app │ +│ └─ … other hosted services │ +└─────────────────────────────────────────────┘ +``` + +`uvicorn.Server` runs *inside* our process, configured with `workers=1`. +Hosting drives its lifecycle: on `set_started` we register endpoints, +on `shutting_down` we set `should_exit = True` and wait for the +serve loop to return. Memory is shared between the HTTP app and every +other hosted service. + +API: + +```python +hosting.run_app([ + scheduler.service(), + app.service(uvicorn.Config(...)), # server="uvicorn" (default) + app.service(hypercorn.Config(...), server="hypercorn"), +]) +``` + +Same pattern as gRPC / any other adapter in `localpost.hosting.services/`. + +## Out-of-process: Granian + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Granian (process supervisor, spawned by `granian` CLI) │ +│ ├─ Worker process 1 │ +│ │ └─ HostRSGIApp (single localpost process) │ +│ │ ├─ scheduler service ─┐ │ +│ │ ├─ other hosted services ─┼─ all running in-process │ +│ │ └─ RSGI request handler ─┘ in this worker │ +│ ├─ Worker process 2 (same shape) │ +│ └─ Worker process N │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Granian doesn't have an in-process mode you can plug into +`hosting.run_app(...)`. It always spawns workers (one per CPU by +default). Each worker is its own Python process, loads the user's +module, picks up the RSGI app object, and serves requests. + +To deploy a hosted app under Granian, the **host itself implements +RSGI**. Inside each Granian worker process, `HostRSGIApp` runs the +full hosting stack (scheduler + other services + the HTTP request +handler) — sharing memory the way a regular `hosting.run_app` +deployment does. The HTTP request handler is dispatched per-request +via Granian's RSGI hook. + +API: + +```python +rsgi_app = hosting.HostRSGIApp( + services=[scheduler.service(), other_service.service()], + rsgi_handler=app, # HttpAsyncApp or AsyncRequestHandler +) +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +## Why the asymmetry + +It's a property of the host server, not a framework choice: + +- **uvicorn / hypercorn** are pure-Python ASGI servers. They expose + programmatic APIs (`Server.serve()`, `serve(app, config)`) that run + on whatever event loop you give them. Wrapping one as a hosted + service is natural. +- **Granian** is a Rust-native RSGI server with multi-process workers + baked in. The Python side is a thin shim Granian calls *into* — you + can't call Granian *from* an existing event loop and expect it to + share that loop with the rest of your app. + +Trying to run Granian as a `hosting.run_app(...)` service would force +either single-worker (defeats Granian's main feature) or a +process-detach (defeats the in-process service-sharing model +`hosting.run_app` is built around). Inverting the topology — Granian +spawns the workers; each worker runs `hosting.serve(...)` — keeps both +sides honest. + +## Per-worker side effects (Granian only) + +A consequence of the supervisor topology: every Granian worker runs +the *full* `services=` list. With `--workers 4`, four schedulers tick. +Cron-style "run once" jobs need either: + +- `--workers 1` — fine for low-RPS services where one Python worker + saturates upstream capacity. +- External coordination — DB lock, leader election, or a separate + cron service running outside the HTTP fleet. + +Process-shared state across workers doesn't exist (no +`multiprocessing.Manager` integration in scope). That's normal +multi-process territory; the framework doesn't try to hide it. + +## Lifecycle hook semantics + +Granian's `__rsgi_init__(loop)` and `__rsgi_del__(loop)` hooks are +**synchronous** — Granian calls them without `await`. This means +`HostRSGIApp` can't actually wait for `hosting.serve()` to reach +`started` inside `__rsgi_init__`. The implementation is: + +- `__rsgi_init__`: spawn a long-lived task on the supplied loop; the + task enters `serve()` and waits for an internal "shutdown" event + before exiting. Set a "ready" `asyncio.Event` once services are up. +- `__rsgi__`: gate the first request on the ready event — Granian may + start dispatching before the lifecycle is fully up. +- `__rsgi_del__`: signal the lifecycle task to exit its `serve()` + block; hosting drives shutdown and waits for `stopped`. + +The single-task pattern is required because anyio's cancel scopes +(used inside `serve()`) must be entered and exited by the same task — +splitting `__aenter__` / `__aexit__` across two would raise +`"different task than it was entered in"`. + +## Picking a target + +| You want… | Use | +|----------------------------------------|----------------------------------------------| +| Simplest deployment, scaling via external supervisor | uvicorn / hypercorn via `app.service(config)` | +| Maximum HTTP throughput on Python | Granian via `app.as_rsgi()` or `HostRSGIApp` | +| Multiple hosted services + Granian | `HostRSGIApp(services=..., rsgi_handler=app)` | +| Multiple hosted services + ASGI server | `app.service(uvicorn.Config(...))` alongside other services in `run_app` | +| HTTP only, ASGI server | `app.asgi()` directly (`granian --interface asgi`) | +| HTTP only, RSGI server | `app.as_rsgi()` directly (`granian --interface rsgi`) | diff --git a/docs/index.md b/docs/index.md index 91d823d..493bb1e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,9 +20,13 @@ Architectural decisions and contract explanations — read these when building on top of `localpost` or extending an existing module. - [Request body handling across transports](design/request-body-handling.md) — - what `ctx.receive(size)` does on the native server, WSGI, ASGI (and - the planned RSGI bridge); how the pre-buffer / streaming distinction - is a transport choice, not a Protocol switch. + what `ctx.receive(size)` does on the native server, WSGI, ASGI, and + RSGI; how the pre-buffer / streaming distinction is a transport + choice, not a Protocol switch. +- [Deployment topologies](design/deployment-topologies.md) — uvicorn / + hypercorn run as hosted services *inside* `run_app`; Granian is a + process supervisor that runs the host *inside* its workers. Why the + two cases are asymmetric and what `HostRSGIApp` does about it. ## Plans diff --git a/localpost/hosting/README.md b/localpost/hosting/README.md index c478277..1c1e2ba 100644 --- a/localpost/hosting/README.md +++ b/localpost/hosting/README.md @@ -110,6 +110,70 @@ def my_middleware(arg) -> Callable[[ServiceF], ServiceF]: Each adapter is decorated with `@hosting.service`, so it plugs into `run_app()` the same way as any other service. +## Host as RSGI for Granian + +uvicorn / hypercorn run *inside* our process — they're hosted services +in `run_app()`. **Granian is the opposite direction**: it's a process +supervisor that spawns workers, then loads our app via its RSGI +interface. To deploy a hosted app (multiple services + an HTTP handler) +under Granian, flip the topology: the host *itself* implements RSGI, +and runs the full hosting lifecycle inside each Granian worker. + +`localpost.hosting.HostRSGIApp` does this: + +```python +from localpost import hosting +from localpost.openapi import HttpAsyncApp +from localpost.scheduler import every, scheduled_task + + +app = HttpAsyncApp() + + +@app.get("/") +async def root() -> str: + return "ok" + + +@scheduled_task(every(seconds=5)) +async def heartbeat() -> None: ... + + +rsgi_app = hosting.HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, +) + +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +Per-worker behaviour: + +- `__rsgi_init__` schedules a long-lived task that enters + `hosting.serve(...)` for the supplied services. The task holds the + lifetime open for the worker's whole life. +- `__rsgi__` waits on a "ready" event before dispatching the first + request, so requests never see a half-started host. +- `__rsgi_del__` signals the lifecycle task to exit its + `serve()` block — services drain and stop in dependency order before + the worker finishes shutdown. + +`shutdown_on_signal` is **not** applied — Granian owns signal handling; +`__rsgi_del__` is how shutdown reaches us. + +> **Per-worker side effects.** Granian spawns N workers; every service +> in `services=` runs in *each* worker. Cron-style "run once" jobs need +> either `--workers 1` or external coordination (DB lock, leader +> election). Process-shared state across workers doesn't exist — that's +> normal multi-process territory. + +For the bridge layer (RSGI translation, no hosting integration), see +[`localpost.http.rsgi`](../http/README.md#localposthttprsgi). +The +[deployment-topologies design note](../../docs/design/deployment-topologies.md) +explains why uvicorn-as-a-hosted-service and Granian-as-a-supervisor +are asymmetric. + ## Implementation notes - A service may spawn child services via `lt.start(child_svc)` — they run in diff --git a/localpost/http/README.md b/localpost/http/README.md index f324642..a3da378 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -283,6 +283,51 @@ typed handlers) on top of the same bridge, see > covers the contract (one `receive(size)` Protocol method, four host > servers, no flag soup). +### `localpost.http.rsgi` + +The Granian-flavoured sibling of `localpost.http.asgi`. Same +`AsyncHTTPReqCtx` Protocol; different wire surface (RSGI exposes +richer per-method calls than ASGI's two-event response dance), and +different deployment topology (Granian is a process supervisor, not +an in-process server). + +Install: `pip install 'localpost[rsgi]'` (pulls in `granian`). + +| Symbol | Notes | +| ------------------------------------------------------------ | --------------------------------------------------------------------------- | +| `to_rsgi(handler, *, max_body_size=1<<20, streaming=False)` | Wrap an `AsyncRequestHandler` as an RSGI app for `granian --interface rsgi`. Same buffered / streaming options as `to_asgi`. Single eager `proto.response_bytes` per `complete`; zero-copy `proto.response_file_range` for `sendfile` when the file has a path; chunked stream fallback otherwise. | + +Deployment is the standard Granian pattern: + +```python +# myapp.py +from localpost.http import Response +from localpost.http.rsgi import to_rsgi + + +async def hello(ctx): + await ctx.complete(Response(200), b"hi") + + +rsgi_app = to_rsgi(hello) +``` + +```bash +granian --interface rsgi myapp:rsgi_app +``` + +For framework-flavoured deployment, see +[`localpost.openapi.HttpAsyncApp.as_rsgi()`](../openapi/README.md#async-flavour-httpasyncapp). +For deployments where the HTTP app shares a worker process with +**other hosted services** (scheduler / gRPC / custom workers), +[`localpost.hosting.HostRSGIApp`](../hosting/README.md#host-as-rsgi-for-granian) +runs the full hosting lifecycle inside each Granian worker. + +> The asymmetry between uvicorn-as-a-hosted-service (in-process, +> drives Granian-the-app from inside) and Granian-as-a-supervisor +> (out-of-process, drives our app from outside) is covered in +> [docs/design/deployment-topologies.md](../../docs/design/deployment-topologies.md). + ### `localpost.http.static` Static file serving via `socket.sendfile()` — zero-copy from the page diff --git a/localpost/http/rsgi.py b/localpost/http/rsgi.py index fd14cad..595d6c5 100644 --- a/localpost/http/rsgi.py +++ b/localpost/http/rsgi.py @@ -327,9 +327,7 @@ async def sendfile(self, response: _Response, file: BinaryIO, offset: int, count self._started = True self.response_status = response.status_code headers = _str_headers(response.headers) - self._proto.response_file_range( - response.status_code, headers, path, offset, offset + count - ) + self._proto.response_file_range(response.status_code, headers, path, offset, offset + count) return # No filesystem path — chunked read fallback. file.seek(offset) @@ -355,8 +353,7 @@ def build_request_from_scope(scope: RSGIScope) -> Request: query_string = scope.query_string.encode("ascii") if scope.query_string else b"" target = path + (b"?" + query_string if query_string else b"") headers = tuple( - (str(name).lower().encode("ascii"), str(value).encode("iso-8859-1")) - for name, value in scope.headers.items() + (str(name).lower().encode("ascii"), str(value).encode("iso-8859-1")) for name, value in scope.headers.items() ) http_version = scope.http_version.encode("ascii") return Request( @@ -465,5 +462,3 @@ async def gen() -> AsyncIterator[bytes]: yield chunk return gen() - - diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index d06534d..12b7622 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -389,7 +389,10 @@ verbatim: see [`examples/openapi/async_app.py`](../../examples/openapi/async_app ### Deployment -`app.asgi()` returns a plain ASGI 3 callable. Any ASGI server works: +Two flavours, depending on which target you ship to. + +**ASGI** — `app.asgi()` returns a plain ASGI 3 callable; any ASGI +server runs it: ```python # myapp.py @@ -406,8 +409,46 @@ hypercorn myapp:asgi_app granian --interface asgi myapp:asgi_app ``` -For native Granian / RSGI deployment (no ASGI bridge), see -`plans/rsgi-deployment.md` — `as_rsgi()` is a planned follow-up. +**RSGI (Granian native)** — `app.as_rsgi()` returns an RSGI application: + +```python +rsgi_app = app.as_rsgi() +``` + +```bash +granian --interface rsgi myapp:rsgi_app +``` + +The RSGI bridge (`localpost.http.to_rsgi`) is wire-format-only — the +same handler chain serves both ASGI and RSGI traffic. Granian's RSGI +gives a single eager `response_bytes` per response (vs ASGI's +two-event start+body), zero-copy `sendfile` via +`response_file_range`, and direct `async for` body reads in streaming +mode. Requires the `[rsgi]` extra (`pip install 'localpost[rsgi]'`). + +**Hosted apps under Granian** — when the HTTP app shares its worker +process with other hosted services (scheduler, gRPC, custom workers), +deploy through `localpost.hosting.HostRSGIApp` instead, which runs the +full hosting lifecycle inside each Granian worker: + +```python +from localpost import hosting +from localpost.scheduler import every, scheduled_task + +@scheduled_task(every(seconds=5)) +async def heartbeat(): ... + + +rsgi_app = hosting.HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, +) + +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +See the [hosting README](../hosting/README.md#host-as-rsgi-for-granian) +for the topology details. ## Design notes diff --git a/pyproject.toml b/pyproject.toml index 416f1ce..ef83fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,11 @@ http-compress = [ # Brotli compression for compress_handler. Gzip is stdlib and always available. "brotli ~=1.1", ] +rsgi = [ + # Granian's RSGI bridge — pulled in by users who deploy under Granian + # via ``localpost.http.to_rsgi`` or ``localpost.hosting.HostRSGIApp``. + "granian ~=2.7", +] openapi = [ # "localpost[http]", "msgspec ~=0.19", diff --git a/tests/hosting/rsgi.py b/tests/hosting/rsgi.py index 6e4294f..fe32c92 100644 --- a/tests/hosting/rsgi.py +++ b/tests/hosting/rsgi.py @@ -76,11 +76,11 @@ async def gen() -> AsyncIterator[bytes]: async def client_disconnect(self) -> None: await asyncio.Event().wait() # never resolves - def response_empty(self, status: int, headers: Any) -> None: # noqa: ARG002 + def response_empty(self, status: int, headers: Any) -> None: self.response_status = status self.response_body = b"" - def response_bytes(self, status: int, headers: Any, body: bytes) -> None: # noqa: ARG002 + def response_bytes(self, status: int, headers: Any, body: bytes) -> None: self.response_status = status self.response_body = body diff --git a/tests/http/asgi.py b/tests/http/asgi.py index cddb5b9..729d7b1 100644 --- a/tests/http/asgi.py +++ b/tests/http/asgi.py @@ -298,7 +298,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: @pytest.mark.anyio async def test_content_length_pre_check_413(self) -> None: - async def handler(ctx: AsyncHTTPReqCtx) -> None: # noqa: ARG001 + async def handler(ctx: AsyncHTTPReqCtx) -> None: raise AssertionError("handler should not run") asgi_app = to_asgi(handler, streaming=True, max_body_size=4) diff --git a/tests/http/rsgi.py b/tests/http/rsgi.py index 7f4702b..535ca19 100644 --- a/tests/http/rsgi.py +++ b/tests/http/rsgi.py @@ -21,7 +21,6 @@ from localpost.http import AsyncHTTPReqCtx, Response, to_rsgi from localpost.http.rsgi import _RSGIReqCtx, addrs_from_scope, build_request_from_scope - # --- Mocks -------------------------------------------------------------- @@ -324,7 +323,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: @pytest.mark.anyio async def test_body_too_large_returns_413_via_content_length(self) -> None: - async def handler(ctx: AsyncHTTPReqCtx) -> None: # noqa: ARG001 + async def handler(ctx: AsyncHTTPReqCtx) -> None: raise AssertionError("handler should not run") rsgi_app = to_rsgi(handler, max_body_size=4) @@ -358,7 +357,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: @pytest.mark.anyio async def test_content_length_pre_check_413(self) -> None: - async def handler(ctx: AsyncHTTPReqCtx) -> None: # noqa: ARG001 + async def handler(ctx: AsyncHTTPReqCtx) -> None: raise AssertionError("handler should not run") rsgi_app = to_rsgi(handler, streaming=True, max_body_size=4) diff --git a/tests/openapi/aio_rsgi_integration.py b/tests/openapi/aio_rsgi_integration.py index 06153bb..317fa55 100644 --- a/tests/openapi/aio_rsgi_integration.py +++ b/tests/openapi/aio_rsgi_integration.py @@ -14,9 +14,10 @@ from __future__ import annotations import asyncio +import contextlib import socket import threading -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator from dataclasses import dataclass import httpx @@ -91,13 +92,13 @@ async def serve_then_signal() -> None: self._thread.start() self._ready.wait(timeout=5) # Wait for the port to actually be accepting connections. - deadline = asyncio.get_event_loop_policy().new_event_loop().time() + 5.0 # noqa: SLF001 + deadline = asyncio.get_event_loop_policy().new_event_loop().time() + 5.0 while True: try: with socket.create_connection(("127.0.0.1", self._port), timeout=0.1): break except OSError: - if asyncio.get_event_loop_policy().new_event_loop().time() > deadline: # noqa: SLF001 + if asyncio.get_event_loop_policy().new_event_loop().time() > deadline: raise RuntimeError("Granian failed to start within 5s") from None threading.Event().wait(0.05) return self._port @@ -106,15 +107,11 @@ def __exit__(self, *_exc: object) -> None: loop = self._loop srv = self._server if loop is not None and srv is not None: - try: + with contextlib.suppress(Exception): fut = asyncio.run_coroutine_threadsafe(srv.shutdown(), loop) fut.result(timeout=5) - except Exception: # noqa: BLE001 - pass - try: + with contextlib.suppress(Exception): loop.call_soon_threadsafe(loop.stop) - except Exception: # noqa: BLE001 - pass if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=5) @@ -208,15 +205,13 @@ def test_background_service_runs_alongside_handler(self) -> None: @hosting.service async def heartbeat(sl: hosting.ServiceLifetime) -> None: sl.set_started() - try: + with contextlib.suppress(Exception): while not sl.shutting_down.is_set(): with ticks_lock: ticks.append(0.0) # Don't actually sleep on real time — just yield enough # for a few ticks during the test. await asyncio.sleep(0.05) - except Exception: # noqa: BLE001 - pass app = _library_app() rsgi_app = hosting.HostRSGIApp( From ca35b91debc6065de61ccb92f92e9aef237e7b4f Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 17:09:29 +0400 Subject: [PATCH 211/286] feat(benchmarks): add localpost_openapi_granian stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Granian-flavoured LocalPost openapi bench app — same handler chain as the existing async (uvicorn) variant, but deployed via app.as_rsgi() under granian.server.Server (workers=1) for fair single-process comparison with the FastAPI / uvicorn stack. Stack registry: - localpost_openapi_granian (framework=localpost, server=granian, schema=msgspec) - groups under "localpost" alongside the sync and async flavours. The bench answers two new questions: - RSGI vs ASGI on the same handler: localpost_openapi_granian vs localpost_openapi_async — only the wire bridge changes. - localpost vs FastAPI on the same Granian-class deployment story. The 400→422 validation-status remap middleware is attached to all three LocalPost bench apps so validation_failure stays apples-to-apples. Smoke-tested all four scenarios (ping / items / search / profile + the 422 remap) over real Granian. --- benchmarks/openapi/README.md | 38 +++-- .../openapi/apps/localpost_openapi_granian.py | 132 ++++++++++++++++++ benchmarks/openapi/stacks.py | 19 +-- 3 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 benchmarks/openapi/apps/localpost_openapi_granian.py diff --git a/benchmarks/openapi/README.md b/benchmarks/openapi/README.md index f07bc70..1340a6b 100644 --- a/benchmarks/openapi/README.md +++ b/benchmarks/openapi/README.md @@ -39,24 +39,33 @@ Output lands in `benchmarks/openapi/results///`: ## What's compared -Four stacks — the two `localpost.openapi` flavours plus one peer framework each: +Five stacks — the three `localpost.openapi` flavours plus one peer framework each: -| Stack ID | Framework | Server | Schema | -|----------------------------|-----------------------|------------------------------|----------| -| `localpost_openapi` | `localpost.openapi` | `localpost.http` (h11), sync | msgspec | -| `localpost_openapi_async` | `localpost.openapi` | uvicorn (1 worker), async | msgspec | -| `flask_openapi` | `flask-openapi` (v5) | gunicorn sync (32 threads) | pydantic | -| `fastapi` | FastAPI | uvicorn (1 worker) | pydantic | +| Stack ID | Framework | Server | Schema | +|--------------------------------|-----------------------|------------------------------|----------| +| `localpost_openapi` | `localpost.openapi` | `localpost.http` (h11), sync | msgspec | +| `localpost_openapi_async` | `localpost.openapi` | uvicorn (1 worker), async | msgspec | +| `localpost_openapi_granian` | `localpost.openapi` | granian (1 worker, RSGI), async | msgspec | +| `flask_openapi` | `flask-openapi` (v5) | gunicorn sync (32 threads) | pydantic | +| `fastapi` | FastAPI | uvicorn (1 worker) | pydantic | Single-process by design — we measure framework-layer overhead, not the multiplicative effect of more workers. All servers configured to be roughly comparable (`max_concurrency=32` for LocalPost, `--threads 32` for Gunicorn, etc.). -The two LocalPost stacks share `framework=localpost`; the `server` dim -discriminates them in result tables (`lp-h11` vs `uvicorn`). The async -flavour is the natural FastAPI comparator (both ASGI / uvicorn / async); -the sync flavour anchors what dropping the event loop costs / saves. +The three LocalPost stacks share `framework=localpost`; the `server` +dim discriminates them in result tables (`lp-h11` / `uvicorn` / +`granian`). What each pair anchors: + +- **async vs sync** (`localpost_openapi_async` vs `localpost_openapi`) + — what dropping the event loop costs or saves. +- **RSGI vs ASGI** (`localpost_openapi_granian` vs + `localpost_openapi_async`) — same handler, same uvicorn-class server + on both sides; the only delta is the wire bridge (single eager + `response_bytes` on RSGI vs ASGI's two-event start+body). +- **localpost vs FastAPI** (`localpost_openapi_granian` vs `fastapi`) + — same Granian-class deployment story, different framework. `flask-openapi3` was renamed to `flask-openapi` for the v5 line; the `bench-openapi` dependency group pins `flask-openapi >=5.0.0rc1`. @@ -81,10 +90,9 @@ business logic. For `validation_failure`: LocalPost's body resolver returns `BadRequest` (400) by default, while FastAPI and flask-openapi v5 -return 422. To keep the comparison apples-to-apples, both -`localpost_openapi` and `localpost_openapi_async` attach a small -middleware that remaps 400 → 422 on the profile endpoint. The framework -default is unchanged. +return 422. To keep the comparison apples-to-apples, all three +LocalPost bench apps attach a small middleware that remaps +400 → 422 on the profile endpoint. The framework default is unchanged. ## Adding a new stack / scenario diff --git a/benchmarks/openapi/apps/localpost_openapi_granian.py b/benchmarks/openapi/apps/localpost_openapi_granian.py new file mode 100644 index 0000000..1e6d04b --- /dev/null +++ b/benchmarks/openapi/apps/localpost_openapi_granian.py @@ -0,0 +1,132 @@ +"""LocalPost — ``localpost.openapi.HttpAsyncApp`` on Granian (RSGI). + +Granian-flavoured sibling of ``localpost_openapi_async.py`` — same +dataclass models, same routes, same async handlers, but deployed via +``app.as_rsgi()`` under Granian's native RSGI interface (1 worker for +fair comparison with the FastAPI / uvicorn stack). + +The RSGI bridge is the wire-format-only layer in +``localpost.http.rsgi``: single eager ``proto.response_bytes`` per +response (vs ASGI's two-event start+body), so the same handler chain +can be a touch faster than its ASGI sibling. This bench app exists to +quantify that on the macro-bench scenarios. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any + +from granian.constants import Interfaces +from granian.server import Server + +from benchmarks.openapi.apps._cli import parse_port +from localpost.openapi import ( + AsyncApiOperation, + AsyncHTTPReqCtx, + BadRequest, + HttpAsyncApp, + OpResult, + UnprocessableEntity, + async_op_middleware, +) + + +@dataclass +class Item: + id: int + + +@dataclass +class SearchResult: + q: str + limit: int + offset: int + + +@dataclass +class ProfileUpdate: + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@dataclass +class Profile: + user_id: str + display_name: str + email: str + version: int + tags: list[str] + settings: dict[str, Any] + + +@async_op_middleware +async def remap_validation_status( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, +) -> UnprocessableEntity[str] | OpResult: + result = await call_next(ctx) + if isinstance(result, BadRequest): + body = result.body if isinstance(result.body, str) else str(result.body) + return UnprocessableEntity(body) + return result + + +def build_app() -> HttpAsyncApp: + app = HttpAsyncApp() + + @app.get("/ping") + async def ping() -> str: + return "pong" + + @app.get("/items/{item_id}") + async def get_item(item_id: int) -> Item: + return Item(id=item_id) + + @app.get("/search") + async def search(q: str, limit: int = 20, offset: int = 0) -> SearchResult: + return SearchResult(q=q, limit=limit, offset=offset) + + @app.post("/users/{user_id}/profile", middlewares=[remap_validation_status]) + async def update_profile(user_id: str, body: ProfileUpdate) -> Profile: + tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + return Profile( + user_id=user_id, + display_name=body.display_name.strip(), + email=body.email.strip().lower(), + version=body.version + 1, + tags=tags, + settings=body.settings, + ) + + _ = (ping, get_item, search, update_profile) + return app + + +# Module-level RSGI app. Granian's production ``Server`` takes the +# target as a ``module:attribute`` string and imports it inside each +# worker; ``rsgi_app`` is what it pulls. +rsgi_app = build_app().as_rsgi() + + +def main() -> int: + port = parse_port() + server = Server( + target=f"{__name__}:rsgi_app", + address="127.0.0.1", + port=port, + interface=Interfaces.RSGI, + workers=1, + log_enabled=False, + log_access=False, + ) + server.serve() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/openapi/stacks.py b/benchmarks/openapi/stacks.py index efed3e6..413faf2 100644 --- a/benchmarks/openapi/stacks.py +++ b/benchmarks/openapi/stacks.py @@ -1,19 +1,21 @@ """OpenAPI bench stack registry. -Four stacks for v1 — the two LocalPost flavours plus one per peer +Five stacks for v1 — the three LocalPost flavours plus one per peer framework. Single-process by design so we measure framework overhead, not worker multiplexing. -* ``localpost_openapi`` — ``HttpApp`` on ``localpost.http`` (h11), sync handlers. -* ``localpost_openapi_async`` — ``HttpAsyncApp`` on Uvicorn (1 worker), async handlers. -* ``flask_openapi`` — ``flask-openapi3`` on Gunicorn (1 worker, 32 threads). -* ``fastapi`` — FastAPI on Uvicorn (1 worker). +* ``localpost_openapi`` — ``HttpApp`` on ``localpost.http`` (h11), sync handlers. +* ``localpost_openapi_async`` — ``HttpAsyncApp`` on Uvicorn (1 worker), async handlers. +* ``localpost_openapi_granian`` — ``HttpAsyncApp`` on Granian (1 worker, RSGI), async handlers. +* ``flask_openapi`` — ``flask-openapi3`` on Gunicorn (1 worker, 32 threads). +* ``fastapi`` — FastAPI on Uvicorn (1 worker). Dim keys: ``framework``, ``server``, ``schema`` (the validation library each framework drives — ``msgspec`` for LocalPost, ``pydantic`` for the -other two). The two LocalPost stacks share ``framework=localpost`` so -the ``localpost`` group filter pulls both; ``server`` discriminates -them in result tables (``lp-h11`` vs ``uvicorn``). +other two). The three LocalPost stacks share ``framework=localpost`` +so the ``localpost`` group filter pulls all of them; ``server`` +discriminates them in result tables (``lp-h11`` / ``uvicorn`` / +``granian``). """ from __future__ import annotations @@ -36,6 +38,7 @@ def _stack(name: str, *, framework: str, server: str, schema: str) -> Stack: STACKS: Final[tuple[Stack, ...]] = ( _stack("localpost_openapi", framework="localpost", server="lp-h11", schema="msgspec"), _stack("localpost_openapi_async", framework="localpost", server="uvicorn", schema="msgspec"), + _stack("localpost_openapi_granian", framework="localpost", server="granian", schema="msgspec"), _stack("flask_openapi", framework="flask-openapi3", server="gunicorn", schema="pydantic"), _stack("fastapi", framework="fastapi", server="uvicorn", schema="pydantic"), ) From aec094a5fa61f5d7117a0428528138e14017946e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 20:53:41 +0400 Subject: [PATCH 212/286] feat(http): add ctx.disconnected to sync HTTPReqCtx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors AsyncHTTPReqCtx.disconnected so handler code can poll for peer-gone the same way regardless of transport. Native backends do a non-blocking MSG_PEEK on the request socket and stick True once seen; the WSGI bridge always returns False (no socket handle). check_cancelled() is unchanged — it remains the option for code that doesn't have ctx in scope. --- localpost/http/README.md | 1 + localpost/http/_base.py | 39 ++++++++++++++++++++++++++++ localpost/http/compress.py | 4 +++ localpost/http/server_h11.py | 11 ++++++++ localpost/http/server_httptools.py | 11 ++++++++ localpost/http/wsgi.py | 6 +++++ tests/http/server.py | 41 ++++++++++++++++++++++++++++++ tests/http/wsgi_to.py | 13 ++++++++++ 8 files changed, 126 insertions(+) diff --git a/localpost/http/README.md b/localpost/http/README.md index a3da378..c22a87c 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -212,6 +212,7 @@ thread + N worker selectors) drop in without touching the request hot path. | Symbol | Notes | | --------------------- | -------------------------------------------------------------- | +| `HTTPReqCtx.disconnected` | Pull-style poll for peer-gone, mirroring `AsyncHTTPReqCtx.disconnected`. Native backends do a non-blocking `recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket and stick `True` once seen; the WSGI bridge always returns `False` (no socket handle). Use it inside SSE generators / long handlers when you have `ctx` in scope. | | `check_cancelled()` | Cooperative cancel check for sync handlers. Raises `RequestCancelled` if the client disconnected (detected via non-blocking ``MSG_PEEK``) or the hosted service is shutting down. Call periodically in long-running handlers. | | `RequestCancelled` | Exception raised by `check_cancelled()`. Inherits from `Exception` (not `BaseException`) — catchable with `except Exception:`. | diff --git a/localpost/http/_base.py b/localpost/http/_base.py index dd63c78..f0b4f95 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -270,6 +270,24 @@ def remote_addr(self) -> str | None: ... def local_addr(self) -> str: ... @property def scheme(self) -> str: ... + @property + def disconnected(self) -> bool: + """True once the peer has gone away (mid-request / mid-response). + + Native backends do a non-blocking ``recv(1, MSG_PEEK | MSG_DONTWAIT)`` + on the request socket; the result is sticky once ``True``. The WSGI + bridge has no socket handle and always returns ``False`` — surface + host-server disconnects via ``BrokenPipeError`` from the per-chunk + write path instead. + + Mirrors :attr:`localpost.http.AsyncHTTPReqCtx.disconnected` so SSE + / long-running handlers can poll the same property regardless of + transport. Sync handlers that don't have ctx in scope can still + use :func:`localpost.http.check_cancelled` (raises) — this + property is the pull-style alternative. + """ + ... + @property def borrowed(self) -> bool: ... def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... @@ -420,6 +438,27 @@ def _send_all(conn: BaseHTTPConn, payload: bytes | bytearray | memoryview) -> No sent += n +def _peek_disconnected(sock: socket.socket) -> bool: + """Non-blocking PEEK probe for peer FIN. Used by native ``_HTTPReqCtx`` + implementations to back ``ctx.disconnected``. + + Returns ``True`` iff the peer half-closed (read side) — ``recv`` returns + ``b""`` or the socket is broken (any non-``BlockingIOError`` ``OSError``). + Returns ``False`` when no signal is available (``BlockingIOError``) or + when bytes are buffered and waiting to be read. + + ``MSG_PEEK`` doesn't consume bytes, so calling this from a worker holding + a borrowed conn is safe alongside the worker's own send/recv. + """ + try: + peeked = sock.recv(1, socket.MSG_PEEK | socket.MSG_DONTWAIT) + except BlockingIOError: + return False + except OSError: + return True + return not peeked + + def native_stream(ctx: HTTPReqCtx, response: Response, chunks: Iterator[bytes]) -> None: """Default :meth:`HTTPReqCtx.stream` impl on top of the imperative trio. diff --git a/localpost/http/compress.py b/localpost/http/compress.py index 25cd0ea..12d055a 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -432,6 +432,10 @@ def local_addr(self) -> str: def scheme(self) -> str: return self._inner.scheme + @property + def disconnected(self) -> bool: + return self._inner.disconnected + @property def borrowed(self) -> bool: return self._inner.borrowed diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 7588c98..b80788c 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -38,6 +38,7 @@ BodyHandler, RequestHandler, Selector, + _peek_disconnected, _send_all, content_length, emit_handler_error, @@ -329,6 +330,7 @@ class _HTTPReqCtx: response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) _pending_header_bytes: bytes | None = None + _disconnected: bool = False @property def remote_addr(self) -> str | None: @@ -345,6 +347,15 @@ def scheme(self) -> str: # No TLS support today; native server is plain HTTP. return "http" + @property + def disconnected(self) -> bool: + if self._disconnected: + return True + if _peek_disconnected(self.conn.sock): + self._disconnected = True + return True + return False + @property def borrowed(self) -> bool: return not self.conn.tracked diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index e4c02f0..2096ffa 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -42,6 +42,7 @@ BodyHandler, RequestHandler, Selector, + _peek_disconnected, _send_all, content_length, emit_handler_error, @@ -439,6 +440,7 @@ class _HTTPReqCtx: body: bytes = b"" response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) + _disconnected: bool = False _continue_sent: bool = False _chunked: bool = False """``True`` if the backend auto-added ``Transfer-Encoding: chunked`` because @@ -466,6 +468,15 @@ def scheme(self) -> str: # No TLS support today; native server is plain HTTP. return "http" + @property + def disconnected(self) -> bool: + if self._disconnected: + return True + if _peek_disconnected(self.conn.sock): + self._disconnected = True + return True + return False + @property def borrowed(self) -> bool: return not self.conn.tracked diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 0038e87..3836d35 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -231,6 +231,12 @@ class _WSGIReqCtx: _imperative_chunks: list[bytes] = field(default_factory=list) _sendfile: tuple[_Response, BinaryIO, int, int] | None = None + @property + def disconnected(self) -> bool: + # WSGI exposes no socket handle; host-server disconnects surface as + # ``BrokenPipeError`` from the per-chunk write path during streaming. + return False + @property def borrowed(self) -> bool: return True diff --git a/tests/http/server.py b/tests/http/server.py index 1ca4d96..4df31da 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -302,6 +302,47 @@ def test_client_closes_before_request_complete(self, serve_in_thread): resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) assert resp.status_code == 200 + def test_disconnected_starts_false_and_flips_on_peer_close(self, serve_in_thread): + """``ctx.disconnected`` is False on a connected peer and flips True after FIN. + + The handler runs on a worker (via ``borrow``) and polls + ``ctx.disconnected`` after the client closes its socket. + """ + before: list[bool] = [] + after: list[bool] = [] + client_closed = threading.Event() + observed = threading.Event() + + def handler(ctx: HTTPReqCtx) -> None: + with ctx.borrow(): + before.append(ctx.disconnected) + client_closed.wait(2.0) + # PEEK may need a moment after FIN; poll briefly. + for _ in range(50): + if ctx.disconnected: + break + threading.Event().wait(0.02) + after.append(ctx.disconnected) + observed.set() + # Best-effort response — the peer is gone, so writes may fail. + with contextlib.suppress(Exception): + ctx.complete( + Response(status_code=200, headers=[(b"content-length", b"2")]), + b"ok", + ) + + with serve_in_thread(handler) as port: + sock = socket.create_connection(("127.0.0.1", port), timeout=2.0) + sock.sendall(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + # Give the handler a moment to enter borrow + read ``before``. + threading.Event().wait(0.05) + sock.close() + client_closed.set() + assert observed.wait(3.0) + + assert before == [False] + assert after == [True] + def test_client_half_close_after_partial_body_keeps_loop_alive(self, serve_in_thread): """Headers say Content-Length: 100; client sends 5 bytes then half-closes. diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py index 15573ef..42b6a88 100644 --- a/tests/http/wsgi_to.py +++ b/tests/http/wsgi_to.py @@ -205,6 +205,19 @@ def handler(ctx: HTTPReqCtx): _drive(to_wsgi(handler), environ) assert seen["remote"] is None + def test_disconnected_always_false(self): + seen: dict[str, Any] = {} + + def handler(ctx: HTTPReqCtx): + seen["disconnected"] = ctx.disconnected + ctx.complete(Response(200, [(b"content-length", b"0")]), b"") + + environ = _base_environ() + _drive(to_wsgi(handler), environ) + # WSGI has no socket handle — disconnects surface as BrokenPipeError + # from the host's per-chunk write, not as a poll signal here. + assert seen["disconnected"] is False + class TestToWsgiStream: def test_stream_iterator_handed_off_lazily(self): From dfa2487e43930f9ba6151c78d9833e5b5976b4d7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 20:54:38 +0400 Subject: [PATCH 213/286] docs(http): document sync vs async ctx asymmetry Both HTTPReqCtx Protocol docstrings and the README now name the members that diverge between sync and async (borrow / 1xx informational) and the rationale, so the asymmetry reads as intentional rather than accidental. --- localpost/http/README.md | 18 ++++++++++++++++++ localpost/http/_async_base.py | 10 ++++++++++ localpost/http/_base.py | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/localpost/http/README.md b/localpost/http/README.md index c22a87c..be7a43e 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -132,6 +132,24 @@ from localpost.http import http_server, ServerConfig sys.exit(run_app(http_server(ServerConfig(), simple_app))) ``` +## Sync vs. async surface + +`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side +(`request`, `body`, `attrs`, `response_status`, `remote_addr` / +`local_addr` / `scheme`, `disconnected`) and the terminal write +methods (`complete`, `stream`, `sendfile`, plus `receive` for body +reads). A handler that only touches that core surface is portable +between transports. + +A few members are deliberately asymmetric: + +| Member | Sync | Async | Why | +| --- | --- | --- | --- | +| `borrow()` / `borrowed` | ✅ | — | Only the sync native server hands a connection between selector and worker threads. Async transports own the conn for the coroutine's lifetime; there's nothing to borrow. The WSGI bridge satisfies the sync member trivially (always borrowed, no-op CM) so handler code stays portable. | +| 1xx `InformationalResponse` via `start_response` | ✅ | — | ASGI's `http.response.start` and RSGI's response calls are final-only. 100 Continue / 102 Processing are emitted by the host server (sync) or not at all (async). | +| `disconnected` poll | ✅ | ✅ | Native sync does a non-blocking `MSG_PEEK` per access (sticky once True). WSGI bridge is always False — surface disconnects via `BrokenPipeError` from the host's per-chunk write instead. Async transports flip the flag on `http.disconnect`. | +| `check_cancelled()` (raises) | ✅ | — | Sync handlers without `ctx` in scope can call it from anywhere on the request thread; async code already has `ctx` and uses `ctx.disconnected`. | + ## Key concepts - **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, diff --git a/localpost/http/_async_base.py b/localpost/http/_async_base.py index 7dd88d8..e18fa19 100644 --- a/localpost/http/_async_base.py +++ b/localpost/http/_async_base.py @@ -53,6 +53,16 @@ class AsyncHTTPReqCtx(Protocol): same contract ("give me up to ``size`` bytes of request body, or ``b''`` at EOF"). Whether that comes from a slice of a buffered body or a fresh wire read is the transport's choice. + + **Members deliberately absent vs. the sync surface.** + + - No ``borrow()`` / ``borrowed`` — async transports (ASGI, RSGI) + own the connection for the lifetime of the coroutine; there is + no selector / worker thread split to hand off across. + - No 1xx :class:`InformationalResponse` path — ASGI's + ``http.response.start`` only models the final response, and + RSGI's response calls are likewise final-only. 100 Continue / + 102 Processing are emitted by the host server (or not at all). """ request: Request diff --git a/localpost/http/_base.py b/localpost/http/_base.py index f0b4f95..82300a7 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -257,6 +257,20 @@ class HTTPReqCtx(Protocol): ``borrowed`` / :meth:`borrow` are degenerate on transports that don't manage their own connection lifetime (the WSGI bridge always reports ``borrowed=True`` and :meth:`borrow` is a no-op CM). + + **Sync vs. async surface.** Most members mirror + :class:`localpost.http.AsyncHTTPReqCtx`. Two members are sync-only by + design: + + - :meth:`borrow` / :attr:`borrowed` — only the native sync server + hands a connection between selector and worker threads; async + transports own their conn for the duration of the coroutine and + have nothing to borrow. WSGI satisfies this surface trivially + (always borrowed, no-op CM) so that handler code stays portable. + - 1xx :class:`InformationalResponse` accepted by :meth:`start_response` + — ASGI / RSGI surfaces don't expose informational responses + cleanly, so the async ctx omits them. Native sync handlers can + send 100 Continue / 102 Processing through the trio. """ request: Request From 033b5c5b281b8c19456cd52d4784ed488f7cb233 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 21:13:14 +0400 Subject: [PATCH 214/286] feat(http)!: drop imperative trio from public HTTPReqCtx Protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-driver trio (start_response / send / finish_response) is now internal to _NativeReqCtx (h11, httptools backends). Handlers express responses via complete() (one-shot) or stream(response, chunks) (declarative chunk iterator) — both portable across sync and async. Ports: - compress_handler intercepts complete()/stream() instead of the trio; the streaming path wraps the chunk iterator with an incremental compressor (sync-flush per yield) so SSE still reaches the client promptly. - wrap_wsgi and flask_handler call ctx.stream(response, iterator) directly — both naturally produce chunk iterators. - _WSGIReqCtx loses its imperative-capture state machine (and its test coverage); the bridge now exposes only complete / stream / sendfile through HTTPReqCtx. - native_stream → _native_stream, retyped to _NativeReqCtx. Tests and the SSE example are updated to use ctx.stream(...). Three trio-only tests (TestSendBuffer, TestToWsgiImperative members) are removed — their contracts moved out of the public Protocol. --- examples/http/sse_compressed.py | 21 +++-- localpost/http/README.md | 29 ++++--- localpost/http/_base.py | 55 ++++++------ localpost/http/compress.py | 135 ++++++++++++++--------------- localpost/http/flask.py | 9 +- localpost/http/server_h11.py | 4 +- localpost/http/server_httptools.py | 4 +- localpost/http/wsgi.py | 64 +++++--------- tests/http/backend_parity.py | 25 ++---- tests/http/compress.py | 59 ++++++++----- tests/http/server.py | 38 +++----- tests/http/wsgi_to.py | 21 ----- 12 files changed, 205 insertions(+), 259 deletions(-) diff --git a/examples/http/sse_compressed.py b/examples/http/sse_compressed.py index 3f9d5bd..c4ae4a3 100644 --- a/examples/http/sse_compressed.py +++ b/examples/http/sse_compressed.py @@ -30,6 +30,7 @@ import logging import sys import time +from collections.abc import Iterator from localpost.hosting import run_app, service from localpost.http import ( @@ -44,22 +45,24 @@ def _events(ctx: HTTPReqCtx) -> None: - ctx.start_response( + def ticks() -> Iterator[bytes]: + n = 0 + while True: + check_cancelled() + yield f"data: tick {n} at {time.time():.3f}\n\n".encode() + n += 1 + time.sleep(1) + + ctx.stream( Response( status_code=200, headers=[ (b"content-type", b"text/event-stream"), (b"cache-control", b"no-cache"), ], - ) + ), + ticks(), ) - n = 0 - while True: - check_cancelled() - msg = f"data: tick {n} at {time.time():.3f}\n\n".encode() - ctx.send(msg) - n += 1 - time.sleep(1) @service diff --git a/localpost/http/README.md b/localpost/http/README.md index be7a43e..f8858af 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -146,10 +146,11 @@ A few members are deliberately asymmetric: | Member | Sync | Async | Why | | --- | --- | --- | --- | | `borrow()` / `borrowed` | ✅ | — | Only the sync native server hands a connection between selector and worker threads. Async transports own the conn for the coroutine's lifetime; there's nothing to borrow. The WSGI bridge satisfies the sync member trivially (always borrowed, no-op CM) so handler code stays portable. | -| 1xx `InformationalResponse` via `start_response` | ✅ | — | ASGI's `http.response.start` and RSGI's response calls are final-only. 100 Continue / 102 Processing are emitted by the host server (sync) or not at all (async). | | `disconnected` poll | ✅ | ✅ | Native sync does a non-blocking `MSG_PEEK` per access (sticky once True). WSGI bridge is always False — surface disconnects via `BrokenPipeError` from the host's per-chunk write instead. Async transports flip the flag on `http.disconnect`. | | `check_cancelled()` (raises) | ✅ | — | Sync handlers without `ctx` in scope can call it from anywhere on the request thread; async code already has `ctx` and uses `ctx.disconnected`. | +The wire-driver trio (`start_response` / `send` / `finish_response`) is **not** part of the public Protocol on either side. Sync native backends still use it internally to drive h11 / httptools, and 1xx informational responses (100 Continue / 102 Processing) are emitted by the server through that internal path. Handlers express responses declaratively via `complete()` (one-shot) or `stream(response, chunks)` (chunk iterator) — both portable across sync and async. + ## Key concepts - **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, @@ -212,7 +213,7 @@ thread + N worker selectors) drop in without touching the request hot path. | Symbol | Notes | | ------------------------- | ------------------------------------------ | | `start_http_server(cfg, handler)` | Context manager yielding a `Server` bound to ``handler`` | -| `HTTPReqCtx` | Per-request context (`headers`, `body`, `complete`, `sendfile`) | +| `HTTPReqCtx` | Per-request context (`request`, `body`, `complete`, `stream`, `sendfile`, `disconnected`) | | `RequestHandler` | `Callable[[HTTPReqCtx], None]` | ### Selector / accept-side topology @@ -452,22 +453,22 @@ When eligible: body is compressed, `Content-Length` is replaced, #### Streaming responses (incl. SSE) -`compress_handler` also intercepts the streaming-response path -(`start_response` → `send`* → `finish_response`). The middleware decides -per-request: +`compress_handler` intercepts both `complete(...)` and `stream(...)`. +The middleware decides per-request: - One-shot path (`complete(response, body)`) → compress the whole body in memory; replace `Content-Length`. -- Streaming path with **no** `Content-Length` declared → use an - incremental compressor; each `send(chunk)` emits the bytes followed - by a sync-flush so the decompressor sees each chunk promptly. The - backend auto-frames `Transfer-Encoding: chunked` on HTTP/1.1. +- Streaming path (`stream(response, chunks)`) with **no** `Content-Length` + declared → wrap the chunk iterator with an incremental compressor; + each input chunk is emitted compressed + sync-flushed so the + decompressor sees each chunk promptly. The backend auto-frames + `Transfer-Encoding: chunked` on HTTP/1.1. - Streaming path **with** `Content-Length` → pass through (we'd otherwise lie about the declared length). For SSE (`Content-Type: text/event-stream`, included in -`DEFAULT_COMPRESSIBLE_TYPES`), each event you `send(...)` reaches the -client decompressed and parseable by `EventSource` — same approach +`DEFAULT_COMPRESSIBLE_TYPES`), each event your generator yields reaches +the client decompressed and parseable by `EventSource` — same approach nginx uses with `gzip on; gzip_types text/event-stream`. All major browsers transparently decompress `Content-Encoding: gzip` / `br` streams before EventSource sees them. @@ -478,9 +479,9 @@ static handler stays zero-copy. #### Limitations - **One-shot path allocates a compressed buffer per response.** Fine for - typical JSON; for multi-MB single-shot payloads consider building - the response with `start_response` + `send` instead so the streaming - compressor handles it incrementally. + typical JSON; for multi-MB single-shot payloads, hand + `compress_handler` a `stream(response, chunks)` instead so the + streaming compressor handles it incrementally. - **Brotli is opt-in.** `pip install localpost[http-compress]` installs `brotli`. If `"br"` is in `algorithms` without the extra, `compress_handler` raises `ImportError` at construction time. diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 82300a7..c4cbc07 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -57,8 +57,8 @@ "SelectorCallback", "TrackHere", "_NativeReqCtx", + "_native_stream", "compose", - "native_stream", "start_http_server", ] @@ -259,18 +259,20 @@ class HTTPReqCtx(Protocol): reports ``borrowed=True`` and :meth:`borrow` is a no-op CM). **Sync vs. async surface.** Most members mirror - :class:`localpost.http.AsyncHTTPReqCtx`. Two members are sync-only by - design: - - - :meth:`borrow` / :attr:`borrowed` — only the native sync server - hands a connection between selector and worker threads; async - transports own their conn for the duration of the coroutine and - have nothing to borrow. WSGI satisfies this surface trivially - (always borrowed, no-op CM) so that handler code stays portable. - - 1xx :class:`InformationalResponse` accepted by :meth:`start_response` - — ASGI / RSGI surfaces don't expose informational responses - cleanly, so the async ctx omits them. Native sync handlers can - send 100 Continue / 102 Processing through the trio. + :class:`localpost.http.AsyncHTTPReqCtx`. The sync-only members + are :meth:`borrow` / :attr:`borrowed` (the native sync server + hands a connection between selector and worker threads; async + transports own their conn for the coroutine's lifetime and have + nothing to borrow — WSGI satisfies this trivially so handler + code stays portable). + + The wire-driver trio (``start_response`` / ``send`` / + ``finish_response``) used to live here too; it has been demoted + to :class:`_NativeReqCtx` (internal). Handlers should call + :meth:`complete` (one-shot) or :meth:`stream` (declarative chunk + iterator) — both transport-portable. Internal sync wire-driver + code (the h11 / httptools backends, the ``sendfile`` path) still + uses the trio under the hood. """ request: Request @@ -306,9 +308,6 @@ def disconnected(self) -> bool: def borrowed(self) -> bool: ... def borrow(self) -> AbstractContextManager[HTTPReqCtx]: ... def receive(self, size: int = ..., /) -> bytes: ... - def start_response(self, response: Response | InformationalResponse, /) -> None: ... - def send(self, chunk: Buffer, /) -> None: ... - def finish_response(self) -> None: ... def complete(self, response: Response, body: bytes | None = None) -> None: ... def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: """Emit ``response`` then drain ``chunks`` to the wire. @@ -318,10 +317,6 @@ def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: checks :func:`localpost.http.check_cancelled` between chunks and returns silently if the client has gone away. External transports (WSGI bridge) hand the iterator straight to their host server. - - Sugar over :meth:`start_response` + :meth:`send` (per-chunk) + - :meth:`finish_response`. Pure-imperative callers still have the - trio available; ``stream`` is the path most operations want. """ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: @@ -333,21 +328,26 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) the backend uses that framing to keep its parser state consistent with what the kernel actually wrote. The socket is set blocking (with ``rw_timeout``) for the duration of the syscall — restored - on selector-thread give-back via :meth:`HTTPReqCtx.finish_response`'s - ``_maybe_give_back`` path. + on selector-thread give-back. """ class _NativeReqCtx(HTTPReqCtx, Protocol): """Internal Protocol — the native ``localpost.http`` ctx. - Adds back the connection handle that internal callers (worker pool, - handler-error recovery) need. External transports (WSGI / ASGI) do - not implement this — they only satisfy :class:`HTTPReqCtx`. + Adds back the imperative wire-driver trio (``start_response`` / + ``send`` / ``finish_response``) and the connection handle that + internal callers (worker pool, handler-error recovery, the + backends' own ``stream`` / ``sendfile`` impls) need. External + transports (WSGI / ASGI / RSGI) do not implement this — they only + satisfy :class:`HTTPReqCtx`. """ @property def conn(self) -> BaseHTTPConn: ... + def start_response(self, response: Response | InformationalResponse, /) -> None: ... + def send(self, chunk: Buffer, /) -> None: ... + def finish_response(self) -> None: ... BodyHandler = Callable[[HTTPReqCtx], None] @@ -473,8 +473,9 @@ def _peek_disconnected(sock: socket.socket) -> bool: return not peeked -def native_stream(ctx: HTTPReqCtx, response: Response, chunks: Iterator[bytes]) -> None: - """Default :meth:`HTTPReqCtx.stream` impl on top of the imperative trio. +def _native_stream(ctx: _NativeReqCtx, response: Response, chunks: Iterator[bytes]) -> None: + """Internal default :meth:`HTTPReqCtx.stream` impl on top of the + imperative trio (sync native backends only). Delegates to ``ctx.start_response`` / ``ctx.send`` / ``ctx.finish_response``, interleaving :func:`localpost.http.check_cancelled` between chunks. On diff --git a/localpost/http/compress.py b/localpost/http/compress.py index 12d055a..8791bb8 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -1,15 +1,21 @@ """Response compression middleware (dynamic responses only). Wraps a :data:`RequestHandler` so that responses emitted via -:meth:`HTTPReqCtx.complete` are compressed when the client accepts it, -the response is large enough to be worth compressing, and the -content type is in the allowlist. +:meth:`HTTPReqCtx.complete` and :meth:`HTTPReqCtx.stream` are +compressed when the client accepts it, the response type / size is +eligible, and the content type is in the allowlist. -Streaming response paths (``start_response`` / ``send`` / -``finish_response``) and zero-copy ``sendfile`` pass through -**uncompressed** in v1 — composition with the static handler stays -zero-copy. See ``plans/compression-middleware.md`` for the rationale -and follow-ups. +The middleware intercepts at the declarative level: + +- :meth:`complete` — compress the whole body in memory, replace + ``Content-Length``, set ``Content-Encoding``. +- :meth:`stream` — wrap the chunk iterator with an incremental + compressor (sync-flush after each input chunk) so streaming + responses (incl. SSE) reach the client decompressed and parseable. + +Zero-copy :meth:`sendfile` passes through uncompressed by design — +composition with the static handler stays zero-copy. See +``plans/compression-middleware.md`` for rationale and follow-ups. Encodings: @@ -30,14 +36,11 @@ import zlib from collections.abc import Callable, Iterator, Sequence from contextlib import AbstractContextManager -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, BinaryIO, Final +from dataclasses import dataclass +from typing import Any, BinaryIO, Final -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler, native_stream -from localpost.http._types import InformationalResponse, Request, Response - -if TYPE_CHECKING: - from collections.abc import Buffer +from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._types import Request, Response __all__ = [ "DEFAULT_COMPRESSIBLE_TYPES", @@ -162,6 +165,27 @@ def finish(self) -> bytes: } +def _compress_stream(chunks: Iterator[bytes], encoder: _StreamEncoder) -> Iterator[bytes]: + """Wrap ``chunks`` so each non-empty input chunk emits a compressed + + sync-flushed block, and the iterator's tail produces the encoder's + final flush. + + Empty chunks pass through untouched — handlers sometimes yield ``b""`` + to flush headers without committing payload bytes; emitting a + sync-marker for nothing would be a wasted byte on the wire. + """ + for chunk in chunks: + if not chunk: + yield chunk + continue + out = encoder.compress(chunk) + encoder.flush() + if out: + yield out + tail = encoder.finish() + if tail: + yield tail + + def _check_algorithm_available(name: str) -> None: if name == "br": if importlib.util.find_spec("brotli") is None: @@ -387,7 +411,8 @@ def _merge_vary(existing: bytes) -> bytes: @dataclass(slots=True, eq=False) class _CompressedCtx: - """Forwarding proxy that intercepts :meth:`complete` to compress. + """Forwarding proxy that intercepts :meth:`complete` and :meth:`stream` + to compress eligible responses. All other methods / attributes pass through to ``_inner``. The proxy is allocated once per request — only when the request is @@ -398,9 +423,6 @@ class _CompressedCtx: _encoding: str _min_size: int _compressible_types: frozenset[bytes] - # Streaming-mode state. ``_stream_encoder`` is non-None between a - # final ``start_response`` (eligible) and ``finish_response``. - _stream_encoder: _StreamEncoder | None = field(default=None, init=False) # ----- forwarded attributes ----- @@ -446,60 +468,31 @@ def borrow(self) -> AbstractContextManager[HTTPReqCtx]: def receive(self, size: int = 65536, /) -> bytes: return self._inner.receive(size) + # ----- pass-through (zero-copy) ----- + + def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: + # Zero-copy stays uncompressed by design (compression and sendfile + # are at odds; see plans/compression-middleware.md). + self._inner.sendfile(response, file, offset, count) + # ----- intercepted streaming-response API ----- - def start_response(self, response: Response | InformationalResponse, /) -> None: - # 1xx responses pass through verbatim (multiple may precede the final). - if isinstance(response, InformationalResponse): - self._inner.start_response(response) - return - # Final Response. Decide streaming compression eligibility. - if _is_streaming_eligible( + def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: + if not _is_streaming_eligible( response, method=self._inner.request.method, compressible_types=self._compressible_types, ): - self._stream_encoder = _STREAM_ENCODER_FACTORIES[self._encoding]() - new_headers = _streaming_rewrite_headers(response.headers, encoding=self._encoding) - self._inner.start_response( - Response(status_code=response.status_code, headers=new_headers, reason=response.reason), - ) - else: - self._inner.start_response(response) - - def send(self, chunk: Buffer, /) -> None: - if self._stream_encoder is None: - self._inner.send(chunk) + self._inner.stream(response, chunks) return - chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk) - if not chunk_bytes: - # Empty send (sometimes used to flush headers): pass through - # so we don't emit a sync-marker for nothing. - self._inner.send(chunk_bytes) - return - out = self._stream_encoder.compress(chunk_bytes) + self._stream_encoder.flush() - if out: - self._inner.send(out) - - def finish_response(self) -> None: - if self._stream_encoder is not None: - tail = self._stream_encoder.finish() - self._stream_encoder = None - if tail: - self._inner.send(tail) - self._inner.finish_response() - - def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: - # Pass-through: zero-copy is preserved (compression and sendfile - # are at odds; see plans/compression-middleware.md). - self._inner.sendfile(response, file, offset, count) - - def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: - # Default impl: drives the imperative trio on ``self``, which the - # compression middleware already intercepts per-chunk. - native_stream(self, response, chunks) + encoder = _STREAM_ENCODER_FACTORIES[self._encoding]() + new_headers = _streaming_rewrite_headers(response.headers, encoding=self._encoding) + new_response = Response( + status_code=response.status_code, headers=new_headers, reason=response.reason + ) + self._inner.stream(new_response, _compress_stream(chunks, encoder)) - # ----- intercepted ----- + # ----- intercepted one-shot path ----- def complete(self, response: Response, body: bytes | None = None) -> None: if not _is_compressible_response( @@ -557,10 +550,14 @@ def compress_handler( installed (install via ``pip install localpost[http-compress]``). Returns: - A :data:`RequestHandler` that wraps ``inner``. Compression only - intercepts :meth:`HTTPReqCtx.complete`; streaming responses - (``start_response`` / ``send`` / ``finish_response``) and - :meth:`HTTPReqCtx.sendfile` pass through unchanged. + A :data:`RequestHandler` that wraps ``inner``. Compression + intercepts :meth:`HTTPReqCtx.complete` (whole-body compress) + and :meth:`HTTPReqCtx.stream` (per-chunk incremental compress + with sync-flush); :meth:`HTTPReqCtx.sendfile` and the imperative + trio (``start_response`` / ``send`` / ``finish_response``) pass + through unchanged. Use :meth:`HTTPReqCtx.stream` for true + streaming compression — the trio path is for low-level + wire-driver code (e.g. the WSGI bridge). """ if not algorithms: raise ValueError("compress_handler: ``algorithms`` must be non-empty") diff --git a/localpost/http/flask.py b/localpost/http/flask.py index 8fee58d..50a6097 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -65,17 +65,14 @@ def _write_response(http_ctx: HTTPReqCtx, response) -> None: # response is a werkzeug.Response (Flask's Response subclasses it) reason = (response.status.split(" ", 1)[1] if " " in response.status else "").encode("iso-8859-1") wire_headers = [(name.encode("iso-8859-1"), value.encode("iso-8859-1")) for name, value in response.headers.items()] - http_ctx.start_response( + http_ctx.stream( _Response( status_code=response.status_code, headers=wire_headers, reason=reason, - ) + ), + (chunk for chunk in response.iter_encoded() if chunk), ) - for chunk in response.iter_encoded(): - if chunk: - http_ctx.send(chunk) - http_ctx.finish_response() def flask_server(config: ServerConfig, app: Flask, /): diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index b80788c..7a44ac4 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -38,11 +38,11 @@ BodyHandler, RequestHandler, Selector, + _native_stream, _peek_disconnected, _send_all, content_length, emit_handler_error, - native_stream, scan_response_headers, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response @@ -395,7 +395,7 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.finish_response() def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: - native_stream(self, response, chunks) + _native_stream(self, response, chunks) def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: # ``Content-Length`` framing is required — otherwise h11's writer diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 2096ffa..353393b 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -42,11 +42,11 @@ BodyHandler, RequestHandler, Selector, + _native_stream, _peek_disconnected, _send_all, content_length, emit_handler_error, - native_stream, scan_response_headers, ) from localpost.http._types import BodyTooLarge, InformationalResponse, Request, Response @@ -527,7 +527,7 @@ def complete(self, response: Response, body: bytes | None = None) -> None: self.finish_response() def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: - native_stream(self, response, chunks) + _native_stream(self, response, chunks) def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) -> None: # ``Content-Length`` framing is required: chunked needs per-chunk diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 3836d35..49c3734 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -11,7 +11,7 @@ from wsgiref.types import WSGIApplication from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler -from localpost.http._types import InformationalResponse, Request +from localpost.http._types import Request from localpost.http._types import Response as _Response from localpost.http.config import DEFAULT_BUFFER_SIZE @@ -101,24 +101,29 @@ def start_response( body_iter = app(environ, start_response) try: - first_nonempty: bytes | None = None - # Materialize the first chunk (even if empty) — guarantees start_response has been called. iterator = iter(body_iter) + # Pull the first chunk so the WSGI app commits to calling + # start_response before we hand the response to ctx.stream + # (PEP 3333 only guarantees start_response is called by the + # time the iterable yields its first value). + first_chunk: bytes | None = None for chunk in iterator: if chunk: - first_nonempty = chunk + first_chunk = chunk break response = response_state.get("response") if response is None: raise RuntimeError("WSGI app returned without calling start_response") - ctx.start_response(response) response_state["started"] = True - if first_nonempty is not None: - ctx.send(first_nonempty) - for chunk in iterator: - if chunk: - ctx.send(chunk) - ctx.finish_response() + + def chunks() -> Iterator[bytes]: + if first_chunk is not None: + yield first_chunk + for c in iterator: + if c: + yield c + + ctx.stream(response, chunks()) finally: close = getattr(body_iter, "close", None) if close is not None: @@ -198,17 +203,16 @@ class _WSGIReqCtx: server is tuned for). ``receive`` slices the buffered body for any handler that wants chunked reads. - Three response shapes are supported: + Three response shapes are supported, all via the public + :class:`HTTPReqCtx` Protocol: - **Eager** (``complete``): captured into ``_completed`` and returned as a single-element iterable. - **Declarative streaming** (``stream``): the chunk iterator is handed to the WSGI server lazily — no thread / queue, the WSGI worker drives iteration and flushing. - - **Imperative** (``start_response`` + ``send`` + ``finish_response``): - chunks are appended to a list. The handler runs synchronously, so - the full list is returned at the end. **No per-chunk pacing** on - this path — for true streaming use :meth:`stream`. + - **Zero-copy** (``sendfile``): mapped to ``wsgi.file_wrapper`` when + the host server provides it, else a chunked read+yield fallback. ``borrow()`` is a no-op CM (the WSGI worker already owns the connection); ``borrowed`` is always ``True``. @@ -227,8 +231,6 @@ class _WSGIReqCtx: # Response capture state — at most one of these is populated. _completed: tuple[_Response, bytes] | None = None _streaming: tuple[_Response, Iterator[bytes]] | None = None - _imperative_response: _Response | None = None - _imperative_chunks: list[bytes] = field(default_factory=list) _sendfile: tuple[_Response, BinaryIO, int, int] | None = None @property @@ -260,23 +262,6 @@ def complete(self, response: _Response, body: bytes | None = None) -> None: self.response_status = response.status_code self._completed = (response, body or b"") - def start_response(self, response: _Response | InformationalResponse, /) -> None: - if isinstance(response, InformationalResponse): - # WSGI's start_response is final-response only; 1xx isn't surfaced. - return - self._check_not_started() - self.response_status = response.status_code - self._imperative_response = response - - def send(self, chunk: Buffer, /) -> None: - if self._imperative_response is None: - raise RuntimeError("send() before start_response()") - self._imperative_chunks.append(bytes(chunk)) - - def finish_response(self) -> None: - if self._imperative_response is None: - raise RuntimeError("finish_response() before start_response()") - def stream(self, response: _Response, chunks: Iterator[bytes], /) -> None: self._check_not_started() self.response_status = response.status_code @@ -306,10 +291,6 @@ def _respond(self, start_response: Callable[..., Any]) -> Iterable[bytes]: if file_wrapper is not None: return file_wrapper(_LimitedReader(file, count), DEFAULT_BUFFER_SIZE) return _read_chunks(file, count, DEFAULT_BUFFER_SIZE) - if self._imperative_response is not None: - response = self._imperative_response - start_response(_status_line(response), _wsgi_headers(response.headers)) - return self._imperative_chunks raise RuntimeError("Handler returned without producing a response") def _check_not_started(self) -> None: @@ -317,7 +298,6 @@ def _check_not_started(self) -> None: self._completed is not None or self._streaming is not None or self._sendfile is not None - or self._imperative_response is not None ): raise RuntimeError("Response already started") @@ -335,10 +315,6 @@ def to_wsgi(handler: RequestHandler) -> WSGIApplication: server (lazy, true per-chunk flushing). - ``ctx.sendfile(...)`` → ``wsgi.file_wrapper`` if the host server provides it, else a chunked read+yield fallback. - - imperative ``start_response`` / ``send`` / ``finish_response`` → - chunks buffered in a list and returned as the WSGI body iterable. - No per-chunk pacing on this path; if you need true streaming, - use :meth:`HTTPReqCtx.stream`. Deployment:: diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index 3d87d94..4cced53 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -174,15 +174,13 @@ def handler(ctx: HTTPReqCtx) -> None: def test_streaming_response_chunks(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response( + ctx.stream( Response( status_code=200, headers=[], - ) + ), + iter([b"chunk1", b"chunk2"]), ) - ctx.send(b"chunk1") - ctx.send(b"chunk2") - ctx.finish_response() with serve_backend_in_thread(handler) as port: r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) @@ -193,8 +191,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_unframed_204_response_has_no_transfer_encoding(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(Response(status_code=204, headers=[])) - ctx.finish_response() + ctx.complete(Response(status_code=204, headers=[])) with serve_backend_in_thread(handler) as port: r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) @@ -206,8 +203,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_unframed_304_response_has_no_transfer_encoding(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(Response(status_code=304, headers=[])) - ctx.finish_response() + ctx.complete(Response(status_code=304, headers=[])) with serve_backend_in_thread(handler) as port: r = httpx.get(f"http://127.0.0.1:{port}/", timeout=2) @@ -219,8 +215,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_unframed_head_response_has_no_body_or_transfer_encoding(serve_backend_in_thread): def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(Response(status_code=200, headers=[])) - ctx.finish_response() + ctx.complete(Response(status_code=200, headers=[])) with serve_backend_in_thread(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=2) as sock: @@ -250,18 +245,16 @@ def test_user_supplied_chunked_te_frames_chunks(serve_backend_in_thread): import socket # noqa: PLC0415 def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response( + ctx.stream( Response( status_code=200, headers=[ (b"content-type", b"text/plain"), (b"transfer-encoding", b"chunked"), ], - ) + ), + iter([b"chunk1", b"chunk2"]), ) - ctx.send(b"chunk1") - ctx.send(b"chunk2") - ctx.finish_response() with serve_backend_in_thread(handler) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: s.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") diff --git a/tests/http/compress.py b/tests/http/compress.py index c9ecaa9..d9d8a77 100644 --- a/tests/http/compress.py +++ b/tests/http/compress.py @@ -5,6 +5,7 @@ import gzip import importlib.util import zlib +from collections.abc import Iterator from pathlib import Path import httpx @@ -503,14 +504,20 @@ def test_merges_vary(self): def _sse_handler(events: list[bytes]): - """Handler that emits each item in ``events`` as a separate ``send`` call.""" + """Handler that emits each item in ``events`` via ``ctx.stream(...)`` — + the iterator-wrapping path the compression middleware intercepts. + """ def handler(ctx: HTTPReqCtx) -> BodyHandler | None: - # No Content-Length → streaming path. - ctx.start_response(Response(status_code=200, headers=[(b"content-type", b"text/event-stream")])) - for ev in events: - ctx.send(b"data: " + ev + b"\n\n") - ctx.finish_response() + def chunks() -> Iterator[bytes]: + for ev in events: + yield b"data: " + ev + b"\n\n" + + # No Content-Length → streaming path eligible for compression. + ctx.stream( + Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]), + chunks(), + ) return None return handler @@ -543,17 +550,19 @@ def test_streaming_passthrough_when_content_length_set(self, serve_backend_in_th body_chunk = b"x" * 4096 def handler(ctx: HTTPReqCtx) -> BodyHandler | None: - ctx.start_response( + def chunks() -> Iterator[bytes]: + yield body_chunk + + ctx.stream( Response( status_code=200, headers=[ (b"content-type", b"text/plain"), (b"content-length", str(len(body_chunk)).encode("ascii")), ], - ) + ), + chunks(), ) - ctx.send(body_chunk) - ctx.finish_response() return None h = compress_handler(handler, algorithms=("gzip",)) @@ -571,18 +580,20 @@ def test_streaming_passthrough_when_no_transform(self, serve_backend_in_thread): events = [b"a", b"b", b"c"] def handler(ctx: HTTPReqCtx) -> BodyHandler | None: - ctx.start_response( + def chunks() -> Iterator[bytes]: + for ev in events: + yield b"data: " + ev + b"\n\n" + + ctx.stream( Response( status_code=200, headers=[ (b"content-type", b"text/event-stream"), (b"cache-control", b"no-transform"), ], - ) + ), + chunks(), ) - for ev in events: - ctx.send(b"data: " + ev + b"\n\n") - ctx.finish_response() return None h = compress_handler(handler, algorithms=("gzip",)) @@ -630,13 +641,17 @@ def test_first_event_decodes_before_stream_ends(self, serve_backend_in_thread): gate = threading.Event() def handler(ctx: HTTPReqCtx) -> BodyHandler | None: - ctx.start_response(Response(status_code=200, headers=[(b"content-type", b"text/event-stream")])) - ctx.send(b"data: first\n\n") - # Block until the test has read+decoded "data: first". If - # SYNC_FLUSH didn't happen, this deadlocks at the 5s timeout. - gate.wait(timeout=5) - ctx.send(b"data: second\n\n") - ctx.finish_response() + def chunks() -> Iterator[bytes]: + yield b"data: first\n\n" + # Block until the test has read+decoded "data: first". If + # SYNC_FLUSH didn't happen, this deadlocks at the 5s timeout. + gate.wait(timeout=5) + yield b"data: second\n\n" + + ctx.stream( + Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]), + chunks(), + ) return None h = compress_handler(handler, algorithms=("gzip",)) diff --git a/tests/http/server.py b/tests/http/server.py index 4df31da..22340b5 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -133,15 +133,13 @@ def handler(ctx: HTTPReqCtx): class TestChunkedResponse: def test_streaming_response(self, serve_in_thread): def handler(ctx: HTTPReqCtx): - ctx.start_response( + ctx.stream( Response( status_code=200, headers=[(b"Transfer-Encoding", b"chunked")], - ) + ), + iter([b"chunk1", b"chunk2"]), ) - ctx.send(b"chunk1") - ctx.send(b"chunk2") - ctx.finish_response() with serve_in_thread(handler) as port: resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) @@ -269,14 +267,18 @@ def boom(ctx: HTTPReqCtx) -> None: nonlocal crash_count with crash_lock: crash_count += 1 - ctx.start_response( + + def chunks(): + yield b"first chunk" + raise RuntimeError("crashed mid-stream") + + ctx.stream( Response( status_code=200, headers=[(b"transfer-encoding", b"chunked")], - ) + ), + chunks(), ) - ctx.send(b"first chunk") - raise RuntimeError("crashed mid-stream") with serve_in_thread(boom) as port: # Two separate TCP connections to the SAME server — the second one @@ -462,24 +464,6 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"resp-for-/b" in data -# --- Buffer-protocol body --------------------------------------------------- - - -class TestSendBuffer: - def test_send_accepts_memoryview(self, serve_in_thread): - """``ctx.send`` accepts any Buffer (memoryview, bytearray, …).""" - - def handler(ctx: HTTPReqCtx) -> None: - ctx.start_response(Response(status_code=200, headers=[(b"content-length", b"5")])) - payload = bytearray(b"hello") - ctx.send(memoryview(payload)[:5]) - ctx.finish_response() - - with serve_in_thread(handler) as port: - resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=5) - - assert resp.status_code == 200 - assert resp.content == b"hello" # --- Stale-connection cleanup ------------------------------------------------ diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py index 42b6a88..65868a8 100644 --- a/tests/http/wsgi_to.py +++ b/tests/http/wsgi_to.py @@ -252,27 +252,6 @@ def start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = assert captured["status"].startswith("200 ") -class TestToWsgiImperative: - def test_imperative_chunks_buffered(self): - def handler(ctx: HTTPReqCtx): - ctx.start_response(Response(200, [(b"content-type", b"text/plain")])) - ctx.send(b"foo") - ctx.send(b"bar") - ctx.finish_response() - - status, headers, body = _drive(to_wsgi(handler), _base_environ()) - assert status.startswith("200 ") - assert ("content-type", "text/plain") in headers - assert body == b"foobar" - - def test_send_before_start_response_raises(self): - def handler(ctx: HTTPReqCtx): - ctx.send(b"oops") # bug - - with pytest.raises(RuntimeError, match=r"send.*before start_response"): - _drive(to_wsgi(handler), _base_environ()) - - class TestToWsgiSendfile: def test_sendfile_with_file_wrapper(self, tmp_path): path = tmp_path / "f.txt" From 20b50a1f9e4ba18cea92eae366774aecbfac437e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 22:19:33 +0400 Subject: [PATCH 215/286] feat(http): add read_body / aread_body helpers Sync and async siblings sit on top of the public ctx.receive(size) Protocol method, accumulating into bytes and enforcing max_size. They're the explicit replacement for the implicit body-buffering the selector used to do via the BodyHandler continuation, and the explicit replacement for to_asgi(streaming=False) on the async side. --- localpost/http/__init__.py | 4 +++ localpost/http/_body.py | 66 ++++++++++++++++++++++++++++++++++++ tests/http/body.py | 69 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 localpost/http/_body.py create mode 100644 tests/http/body.py diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index ef508cd..b1b93cc 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -13,6 +13,7 @@ compose, start_http_server, ) +from localpost.http._body import aread_body, read_body from localpost.http._cancel import RequestCancelled, check_cancelled from localpost.http._pool import streaming_pool_handler, thread_pool_handler from localpost.http._service import http_server, wsgi_server @@ -85,4 +86,7 @@ # cancellation "check_cancelled", "RequestCancelled", + # body helpers + "read_body", + "aread_body", ] diff --git a/localpost/http/_body.py b/localpost/http/_body.py new file mode 100644 index 0000000..68603cf --- /dev/null +++ b/localpost/http/_body.py @@ -0,0 +1,66 @@ +"""Request-body buffering helpers. + +The server core never pre-buffers the request body — handlers that need +the whole body in memory call one of these helpers explicitly. They sit +on top of the public ``ctx.receive(size)`` Protocol method and enforce +``max_size`` while accumulating. + +Sync (:func:`read_body`) and async (:func:`aread_body`) are siblings — +same contract, different I/O flavour. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from localpost.http._types import BodyTooLarge +from localpost.http.config import DEFAULT_BUFFER_SIZE + +if TYPE_CHECKING: + from localpost.http._async_base import AsyncHTTPReqCtx + from localpost.http._base import HTTPReqCtx + +__all__ = ["aread_body", "read_body"] + + +_DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MiB — matches ServerConfig.max_body_size default. + + +def read_body(ctx: HTTPReqCtx, *, max_size: int = _DEFAULT_MAX_BODY_SIZE) -> bytes: + """Read the full request body into a single :class:`bytes` object. + + Loops :meth:`HTTPReqCtx.receive` until EOF, accumulating into a + ``bytearray`` and converting at the end. Raises :class:`BodyTooLarge` + if the running total would exceed ``max_size`` (checked before each + accumulating concat, so the helper never holds more than + ``max_size + chunk_size`` bytes in memory). + + Sync handlers must hold a borrowed connection (or be wrapped by + :func:`localpost.http.thread_pool_handler`) before calling — the + selector loop is non-blocking and a synchronous body read needs the + blocking-with-timeout socket mode that ``borrow()`` arranges. + """ + buf = bytearray() + while True: + chunk = ctx.receive(DEFAULT_BUFFER_SIZE) + if not chunk: + return bytes(buf) + if len(buf) + len(chunk) > max_size: + raise BodyTooLarge(len(buf) + len(chunk)) + buf += chunk + + +async def aread_body(ctx: AsyncHTTPReqCtx, *, max_size: int = _DEFAULT_MAX_BODY_SIZE) -> bytes: + """Async sibling of :func:`read_body`. + + Same contract: drain ``await ctx.receive(size)`` until EOF, raising + :class:`BodyTooLarge` if the cap is exceeded mid-stream. + """ + buf = bytearray() + while True: + chunk = await ctx.receive(DEFAULT_BUFFER_SIZE) + if not chunk: + return bytes(buf) + if len(buf) + len(chunk) > max_size: + raise BodyTooLarge(len(buf) + len(chunk)) + buf += chunk diff --git a/tests/http/body.py b/tests/http/body.py new file mode 100644 index 0000000..5954aee --- /dev/null +++ b/tests/http/body.py @@ -0,0 +1,69 @@ +"""Tests for ``localpost.http.read_body`` / ``localpost.http.aread_body``.""" + +from __future__ import annotations + +import pytest + +from localpost.http import BodyTooLarge, aread_body, read_body + + +# --- Sync ---------------------------------------------------------------- + + +class _SyncStubCtx: + """Minimal HTTPReqCtx stub satisfying just ``receive(size)``.""" + + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + def receive(self, _size: int = 0, /) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class TestReadBody: + def test_drains_to_eof(self): + ctx = _SyncStubCtx([b"hello ", b"world"]) + assert read_body(ctx) == b"hello world" # type: ignore[arg-type] + + def test_empty_body(self): + ctx = _SyncStubCtx([]) + assert read_body(ctx) == b"" # type: ignore[arg-type] + + def test_max_size_exceeded(self): + ctx = _SyncStubCtx([b"a" * 5, b"b" * 5]) + with pytest.raises(BodyTooLarge): + read_body(ctx, max_size=8) # type: ignore[arg-type] + + def test_max_size_exact_fit(self): + ctx = _SyncStubCtx([b"a" * 4, b"b" * 4]) + assert read_body(ctx, max_size=8) == b"aaaabbbb" # type: ignore[arg-type] + + +# --- Async --------------------------------------------------------------- + + +class _AsyncStubCtx: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = list(chunks) + + async def receive(self, _size: int = 0, /) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class TestAreadBody: + async def test_drains_to_eof(self): + ctx = _AsyncStubCtx([b"hello ", b"world"]) + assert await aread_body(ctx) == b"hello world" # type: ignore[arg-type] + + async def test_empty_body(self): + ctx = _AsyncStubCtx([]) + assert await aread_body(ctx) == b"" # type: ignore[arg-type] + + async def test_max_size_exceeded(self): + ctx = _AsyncStubCtx([b"a" * 5, b"b" * 5]) + with pytest.raises(BodyTooLarge): + await aread_body(ctx, max_size=8) # type: ignore[arg-type] From e86836186595621c366d2936e4f1ffe330283ad6 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Wed, 6 May 2026 23:10:52 +0400 Subject: [PATCH 216/286] feat(http)!: eliminate BodyHandler; drop ctx.body; align async + sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGES — but the migration is mechanical. - ``BodyHandler`` is removed. ``RequestHandler`` is now ``Callable[[HTTPReqCtx], None]`` (matches AsyncRequestHandler). Selector dispatches the handler exactly once on headers-complete. - ``HTTPReqCtx.body`` and ``AsyncHTTPReqCtx.body`` are removed. Body is read on demand via ``ctx.receive(size)`` or ``localpost.http.read_body(ctx)`` / ``aread_body(ctx)``. - ``to_asgi(streaming=False)`` and ``to_rsgi(streaming=False)`` are removed. Both adapters always read the body lazily. - ``streaming_pool_handler`` is now an alias for ``thread_pool_handler``; the buffered/streaming distinction is gone. What this means for handler code: - A handler that decides on headers (404 / 405 / auth reject) completes inline via ``ctx.complete``. The selector never reads body bytes; ``Content-Length`` is pre-checked against ``max_body_size`` so over-cap requests get 413 before dispatch. - A handler that needs the body wraps with ``thread_pool_handler`` (or borrows the conn explicitly) and calls ``read_body(ctx)`` / ``aread_body(ctx)``. - Frameworks (HttpApp, wrap_wsgi, flask_handler) call the body helpers internally — same surface for typed handlers. Standard Expect: 100-continue handling: when a handler responds without consuming the body, the conn is closed after the response (matches RFC 7230 §6.5 / nginx). Documentation, examples, and the test suite are updated. Two backend-parity tests skip on httptools because ``ctx.receive`` from an inline (selector-thread) handler re-enters httptools' ``parser.feed_data`` — handlers that need the body must compose with ``thread_pool_handler`` on httptools. --- localpost/hosting/rsgi.py | 13 +- localpost/http/README.md | 49 ++++--- localpost/http/__init__.py | 2 - localpost/http/_async_base.py | 13 +- localpost/http/_base.py | 62 ++++---- localpost/http/_pool.py | 187 ++++++++---------------- localpost/http/app.py | 92 +++--------- localpost/http/asgi.py | 183 +++++------------------- localpost/http/compress.py | 33 +---- localpost/http/flask.py | 22 +-- localpost/http/flask_sentry.py | 17 ++- localpost/http/router.py | 13 +- localpost/http/router_sentry.py | 44 ++---- localpost/http/rsgi.py | 116 ++++----------- localpost/http/server_h11.py | 109 +++++--------- localpost/http/server_httptools.py | 214 +++++++++++----------------- localpost/http/static.py | 69 +++------ localpost/http/wsgi.py | 76 ++++------ localpost/openapi/aio/operation.py | 13 ++ localpost/openapi/operation.py | 35 +++-- localpost/openapi/resolvers.py | 22 ++- tests/http/_integration_app.py | 16 +-- tests/http/acceptor_stress.py | 3 +- tests/http/app.py | 58 +++----- tests/http/asgi.py | 63 ++++---- tests/http/backend_parity.py | 40 ++++-- tests/http/compress.py | 24 ++-- tests/http/router.py | 7 +- tests/http/router_props.py | 3 +- tests/http/router_sentry_handler.py | 8 +- tests/http/rsgi.py | 53 ++++--- tests/http/service.py | 24 ++-- tests/http/wsgi_to.py | 5 +- tests/openapi/aio_app.py | 10 +- tests/openapi/app.py | 9 +- 35 files changed, 614 insertions(+), 1093 deletions(-) diff --git a/localpost/hosting/rsgi.py b/localpost/hosting/rsgi.py index a13478a..fb6c595 100644 --- a/localpost/hosting/rsgi.py +++ b/localpost/hosting/rsgi.py @@ -34,7 +34,7 @@ from localpost._utils import wait_all from localpost.hosting._host import ServiceF, ServiceLifetime, serve from localpost.http._async_base import AsyncRequestHandler -from localpost.http.rsgi import _dispatch_buffered, _dispatch_streaming +from localpost.http.rsgi import _dispatch if TYPE_CHECKING: from localpost.openapi.aio.app import HttpAsyncApp @@ -54,9 +54,6 @@ class HostRSGIApp: or an :class:`HttpAsyncApp` (the framework will compile its route table internally). max_body_size: Cap on the request body. See :func:`to_rsgi`. - streaming: When ``True``, body chunks are pulled via - ``await ctx.receive(size)`` instead of pre-buffered. See - :func:`to_rsgi`. Example:: @@ -92,7 +89,6 @@ async def heartbeat() -> None: ... "_ready", "_services", "_shutdown_signal", - "_streaming", ) def __init__( @@ -101,12 +97,10 @@ def __init__( services: Sequence[ServiceF], rsgi_handler: AsyncRequestHandler | HttpAsyncApp, max_body_size: int = 1 << 20, - streaming: bool = False, ) -> None: self._services = tuple(services) self._handler = _resolve_handler(rsgi_handler) self._max_body_size = max_body_size - self._streaming = streaming self._ready: asyncio.Event | None = None self._shutdown_signal: asyncio.Event | None = None self._lifecycle_task: asyncio.Task[None] | None = None @@ -155,10 +149,7 @@ async def _lifecycle(self) -> None: async def __rsgi__(self, scope: Any, proto: Any) -> None: if self._ready is not None and not self._ready.is_set(): await self._ready.wait() - if self._streaming: - await _dispatch_streaming(self._handler, self._max_body_size, scope, proto) - else: - await _dispatch_buffered(self._handler, self._max_body_size, scope, proto) + await _dispatch(self._handler, self._max_body_size, scope, proto) def __rsgi_del__(self, loop: Any) -> None: """Signal the lifecycle task to shut down. diff --git a/localpost/http/README.md b/localpost/http/README.md index f8858af..e0e29e1 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -46,13 +46,17 @@ The hot path is tuned for the JSON web-API common case, and we cut corners on shapes that don't fit it: - **Reject before body.** Handlers that decide based on headers - (no-route, wrong method, auth) return `None` and the body is never - recv'd. No worker hop, no allocation. -- **Body is buffered, not streamed.** When a handler needs the body - (e.g. to deserialise JSON), it needs the *whole* body. The selector - buffers it into `ctx.body` and invokes a `BodyHandler` continuation; - the continuation just reads `ctx.body`. There is still a - `ctx.receive(size)` streaming API but it isn't the optimised path. + (no-route, wrong method, auth) reply with `ctx.complete(...)` + inline; the body is never recv'd. No worker hop, no allocation. + `Content-Length` is pre-checked against `max_body_size` on + headers-complete — over-cap requests get 413 before the handler + ever sees them. +- **Read body explicitly when you need it.** Handlers that need the + whole body (e.g. to deserialise JSON) call + `localpost.http.read_body(ctx)` to drain `ctx.receive(size)` into + bytes. Frameworks (`HttpApp`, `wrap_wsgi`, `flask_handler`) call it + for you when their typed surface needs the body. The server core + never pre-buffers. - **Response is one chunk or SSE.** Most responses are one status+headers+body block; SSE generators emit the same opening block then per-event chunks. The response writer auto-buffers @@ -91,7 +95,8 @@ def hello(name: str): @app.post("/{name}/profile") def update_profile(ctx: HTTPReqCtx, name: str): import json - profile = json.loads(ctx.body) + from localpost.http import read_body + profile = json.loads(read_body(ctx)) return {"updated": name, "profile": profile} @@ -176,7 +181,7 @@ The wire-driver trio (`start_response` / `send` / `finish_response`) is **not** ### Dispatch chain -The internals are a four-link, loose-coupled chain: +The internals are a three-link, loose-coupled chain: ``` Selector ── owns fd→SelectorCallback map; nothing HTTP-specific @@ -185,10 +190,9 @@ Selector ── owns fd→SelectorCallback map; nothing HTTP-specific ConnHandler ── after-accept policy; owns RequestHandler + ConnFactory; │ decides which Selector tracks the new conn ▼ -RequestHandler ── pre-body dispatch; returns BodyHandler|None - │ - ▼ -BodyHandler ── post-body continuation +RequestHandler ── single-shot dispatch on headers-complete; handlers + that need the body call ``ctx.receive(size)`` / + ``localpost.http.read_body(ctx)`` themselves ``` Each link knows only the next. `Selector` doesn't carry a `RequestHandler` — @@ -213,8 +217,10 @@ thread + N worker selectors) drop in without touching the request hot path. | Symbol | Notes | | ------------------------- | ------------------------------------------ | | `start_http_server(cfg, handler)` | Context manager yielding a `Server` bound to ``handler`` | -| `HTTPReqCtx` | Per-request context (`request`, `body`, `complete`, `stream`, `sendfile`, `disconnected`) | +| `HTTPReqCtx` | Per-request context (`request`, `complete`, `stream`, `sendfile`, `receive`, `disconnected`) | | `RequestHandler` | `Callable[[HTTPReqCtx], None]` | +| `read_body(ctx, *, max_size=...)` | Drain `ctx.receive(size)` into a single `bytes`. Raises `BodyTooLarge` on overflow. Sync; the conn must be borrowed (or wrapped in `thread_pool_handler`). | +| `aread_body(ctx, *, max_size=...)` | Async sibling of `read_body` — drains `await ctx.receive(size)`. | ### Selector / accept-side topology @@ -266,7 +272,7 @@ server. | ------------------------------------------------------- | --------------------------------------------------------------------------- | | `AsyncHTTPReqCtx` | Async sibling of `HTTPReqCtx`. Same `request` / `body` / `attrs` / addrs / `scheme`; async `complete` / `stream` / `receive` / `sendfile`; `disconnected: bool` poll for peer-gone. Re-exported from `localpost.http`. | | `AsyncRequestHandler` | `Callable[[AsyncHTTPReqCtx], Awaitable[None]]`. The handler shape `to_asgi` adapts. | -| `to_asgi(handler, *, max_body_size=1<<20, streaming=False)` | Wrap an `AsyncRequestHandler` as an ASGI 3 app. Pre-buffers the request body by default (capped by `max_body_size`; over-cap → 413 before dispatch). Pass `streaming=True` to skip the pre-buffer — the handler then pulls chunks via `await ctx.receive(size)`; `Content-Length` is pre-checked against `max_body_size` when present. Same handler code works either way. | +| `to_asgi(handler, *, max_body_size=1<<20)` | Wrap an `AsyncRequestHandler` as an ASGI 3 app. Body bytes are pulled lazily via `await ctx.receive(size)` (or `aread_body(ctx)` for the whole-body common case); `Content-Length` is pre-checked against `max_body_size` when present (413 before dispatch). | Deployment is the standard ASGI pattern: @@ -315,7 +321,7 @@ Install: `pip install 'localpost[rsgi]'` (pulls in `granian`). | Symbol | Notes | | ------------------------------------------------------------ | --------------------------------------------------------------------------- | -| `to_rsgi(handler, *, max_body_size=1<<20, streaming=False)` | Wrap an `AsyncRequestHandler` as an RSGI app for `granian --interface rsgi`. Same buffered / streaming options as `to_asgi`. Single eager `proto.response_bytes` per `complete`; zero-copy `proto.response_file_range` for `sendfile` when the file has a path; chunked stream fallback otherwise. | +| `to_rsgi(handler, *, max_body_size=1<<20)` | Wrap an `AsyncRequestHandler` as an RSGI app for `granian --interface rsgi`. Body bytes are pulled lazily via `await ctx.receive(size)`. Single eager `proto.response_bytes` per `complete`; zero-copy `proto.response_file_range` for `sendfile` when the file has a path; chunked stream fallback otherwise. | Deployment is the standard Granian pattern: @@ -373,10 +379,9 @@ Behavior: - **Range**: single byte-range only (`bytes=N-M`, `bytes=N-`, `bytes=-K`). Multi-range / unparseable falls back to 200 (RFC 7233 §3.1 compliant). Out-of-bounds → 416 with `Content-Range: bytes */`. -- **Body**: 200 / 206 GET returns a `BodyHandler` that calls - `ctx.sendfile(...)` — wrap in `thread_pool_handler` to dispatch the - syscall to a worker. HEAD success / 304 / 416 / 404 / 405 complete - inline on the selector. +- **Body**: 200 / 206 GET opens the file and calls `ctx.sendfile(...)` + — wrap in `thread_pool_handler` to dispatch the syscall to a worker. + HEAD success / 304 / 416 / 404 / 405 complete inline on the selector. #### Recommended composition @@ -579,8 +584,8 @@ on the documented public Flask API. #### Threading topologies -`http_server` supports three accept-side shapes, all sharing the same -`RequestHandler` / `BodyHandler` chain: +`http_server` supports three accept-side shapes, all driving the same +`RequestHandler` (single-shot dispatch on headers-complete): | Configuration | Threads | When to use | | ------------------------------------- | --------------------------------------------- | ----------- | diff --git a/localpost/http/__init__.py b/localpost/http/__init__.py index b1b93cc..e5d4b17 100644 --- a/localpost/http/__init__.py +++ b/localpost/http/__init__.py @@ -1,6 +1,5 @@ from localpost.http._async_base import AsyncHTTPReqCtx, AsyncRequestHandler from localpost.http._base import ( - BodyHandler, ConnFactory, ConnHandler, HTTPReqCtx, @@ -41,7 +40,6 @@ "start_http_server", "HTTPReqCtx", "RequestHandler", - "BodyHandler", "Middleware", "compose", # async ctx (transport-neutral; concrete adapters in localpost.http.asgi etc.) diff --git a/localpost/http/_async_base.py b/localpost/http/_async_base.py index e18fa19..231e484 100644 --- a/localpost/http/_async_base.py +++ b/localpost/http/_async_base.py @@ -47,12 +47,12 @@ class AsyncHTTPReqCtx(Protocol): scheme) so resolvers that read those attributes work against either flavour. Methods that touch the wire are async. - ``body`` is empty unless the transport pre-buffers it before - dispatch. Handlers that need streaming reads call - ``await ctx.receive(size)`` directly — same name as the sync ctx, - same contract ("give me up to ``size`` bytes of request body, or - ``b''`` at EOF"). Whether that comes from a slice of a buffered - body or a fresh wire read is the transport's choice. + The body is read lazily — handlers call + ``await ctx.receive(size)`` (same name as the sync ctx, same + contract: "give me up to ``size`` bytes, or ``b''`` at EOF") or use + :func:`localpost.http.aread_body` to drain into a single + :class:`bytes` object. The bridge never pre-buffers; framework + layers that want a cached body cache it themselves. **Members deliberately absent vs. the sync surface.** @@ -66,7 +66,6 @@ class AsyncHTTPReqCtx(Protocol): """ request: Request - body: bytes response_status: int | None attrs: dict[Any, Any] diff --git a/localpost/http/_base.py b/localpost/http/_base.py index c4cbc07..e26404a 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -13,10 +13,9 @@ ConnHandler ── after-accept policy; owns RequestHandler + conn_factory; │ decides which Selector tracks the new conn ▼ - RequestHandler ── pre-body dispatch; returns BodyHandler|None - │ - ▼ - BodyHandler ── post-body continuation + RequestHandler ── single-shot dispatch on headers-complete; handlers + that need the body call ``ctx.receive(size)`` / + :func:`localpost.http.read_body` themselves ISO-8859-1 is used for header encoding/decoding as per HTTP/1.1 specification. """ @@ -46,7 +45,6 @@ __all__ = [ "BaseHTTPConn", "BaseServer", - "BodyHandler", "ConnFactory", "ConnHandler", "HTTPReqCtx", @@ -239,9 +237,12 @@ class HTTPReqCtx(Protocol): ``localpost.http`` server backends and external transports (e.g. :func:`localpost.http.wsgi.to_wsgi`'s WSGI bridge). - ``body`` is empty when a :data:`RequestHandler` runs (pre-body phase). - It is populated by the selector / WSGI adapter with the fully - buffered request body before a returned :data:`BodyHandler` runs. + The body is read lazily — handlers call :meth:`receive` (or the + :func:`localpost.http.read_body` helper for "give me the whole + body" semantics). The transport never pre-buffers; framework layers + that want a cached body cache it themselves + (:class:`localpost.openapi.HttpApp` populates + ``ctx.attrs[BODY_CACHE_KEY]`` for typed body parameters). ``attrs`` is per-request mutable state for cross-cutting concerns to thread information through. :class:`localpost.http.Router` writes @@ -276,7 +277,6 @@ class HTTPReqCtx(Protocol): """ request: Request - body: bytes response_status: int | None attrs: dict[Any, Any] @@ -350,46 +350,38 @@ def send(self, chunk: Buffer, /) -> None: ... def finish_response(self) -> None: ... -BodyHandler = Callable[[HTTPReqCtx], None] -"""Continuation invoked by the selector after the full request body has been -buffered into ``ctx.body``. Must complete the response (or borrow the conn).""" +RequestHandler = Callable[[HTTPReqCtx], None] +"""Top-level sync request handler — drives one request to a response. -RequestHandler = Callable[[HTTPReqCtx], BodyHandler | None] -"""Pre-body handler dispatched on ``on_headers_complete``. +Dispatched once on ``on_headers_complete`` (selector thread). The handler: -Returns one of: -- ``None`` — handler is done (either it called ``ctx.complete(...)`` to - send a response inline, or it called ``ctx.borrow()`` to hand the conn - off to a worker thread). Body bytes that arrive afterwards are drained - silently for keep-alive. -- :data:`BodyHandler` — selector buffers the full request body into - ``ctx.body`` and then invokes the returned callable. This is the path - for the JSON-API common case (parse JSON, build response). +- replies inline (``ctx.complete(...)`` / ``ctx.stream(...)`` / + ``ctx.sendfile(...)``) for fast cases that don't touch the body — + e.g. router 404 / 405, header-driven auth rejections. +- or calls ``ctx.borrow()`` (or composes with + :func:`localpost.http.thread_pool_handler`) to escape the selector + before reading the body via ``ctx.receive(size)`` / + :func:`localpost.http.read_body`. -Old-style ``Callable[[HTTPReqCtx], None]`` handlers continue to work — -returning ``None`` implicitly is the "done inline" path.""" +Symmetric with :data:`localpost.http.AsyncRequestHandler` on the async side. +""" Middleware = Callable[[RequestHandler], RequestHandler] """HTTP middleware: a function that wraps a :data:`RequestHandler`. Plain Python decorator pattern — no special chain object. A middleware -can short-circuit pre-body (call ``ctx.complete(...)`` and return -``None`` without invoking ``inner``), inspect / modify before passing -through (``return inner(ctx)``), and wrap the returned -:data:`BodyHandler` continuation to run code after the response:: +can short-circuit before invoking ``inner`` (call ``ctx.complete(...)`` +and return without delegating), inspect / modify the ctx, or run code +after ``inner`` returns:: def with_logging(inner: RequestHandler) -> RequestHandler: def wrapped(ctx): start = time.monotonic() - result = inner(ctx) - if result is None: + try: + inner(ctx) + finally: if not ctx.borrowed: _log(start, ctx) - return None - def post_body(ctx): - result(ctx) - _log(start, ctx) - return post_body return wrapped """ diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 4e3ed1a..5ea6886 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -1,32 +1,22 @@ -"""Worker-pool wrappers for HTTP request handlers. - -Two public wrappers, both async context managers built on a shared -internal pool primitive: - -- :func:`thread_pool_handler` — wraps a :data:`RequestHandler` so the - :data:`BodyHandler` continuation it returns runs on a worker thread. - The pre-body phase (routing, auth, 404/405) stays on the selector. - This is the right wrapper for the JSON-API common case: body is - buffered into ``ctx.body`` by the selector, the user handler does - the JSON parse + work on the worker. - -- :func:`streaming_pool_handler` — wraps a "raw" handler that runs on - a worker thread on a *borrowed* connection. The body is **not** - buffered up-front; the handler reads it via ``ctx.receive(...)`` - chunk by chunk. Use for streaming uploads / large bodies where - buffering is undesirable. - -Both wrappers dispatch onto a process-wide -:class:`localpost.threadtools.ThreadTaskGroup` — workers are spawned -on demand and reused across all pool wrappers / HTTP servers in the -process. There is no concurrency cap. +"""Worker-pool wrapper for HTTP request handlers. + +:func:`thread_pool_handler` wraps a :data:`RequestHandler` so each +request runs on a worker thread with a *borrowed* connection. Body +reads (``ctx.receive(size)`` / :func:`localpost.http.read_body`) and +syscalls like ``ctx.sendfile`` block the worker, not the selector. + +Workers come from a process-wide +:class:`localpost.threadtools.ThreadTaskGroup` and are reused across +all pool wrappers / HTTP servers in the process. There is no +concurrency cap — admission control is the deployment's job +(front-LB / OS limits). """ from __future__ import annotations import logging import threading -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, suppress from typing import cast @@ -36,8 +26,6 @@ from localpost.http._base import ( PAYLOAD_TOO_LARGE_BODY, PAYLOAD_TOO_LARGE_RESPONSE, - BodyHandler, - HTTPReqCtx, RequestHandler, _NativeReqCtx, emit_handler_error, @@ -51,16 +39,9 @@ # -------------------------------------------------------------------------- -_WorkFn = Callable[[_NativeReqCtx], None] - - class _Pool: """Dispatcher that runs request handlers on a shared :class:`ThreadTaskGroup`. - - Two ``dispatch_*`` methods let callers wire either a post-body - :data:`BodyHandler` or a pre-body streaming handler onto the same - task group. """ __slots__ = ("_shutdown_event", "_tg") @@ -69,49 +50,33 @@ def __init__(self, tg: threadtools.ThreadTaskGroup, shutdown_event: threading.Ev self._tg = tg self._shutdown_event = shutdown_event - def dispatch_buffered(self, body_handler: BodyHandler) -> BodyHandler: - """Wrap a :data:`BodyHandler` so when it's invoked post-body it - borrows the conn and queues for a worker. The worker runs - ``body_handler(ctx)`` with ``ctx.body`` already populated. - - Native-only: the returned ``BodyHandler`` is cast at the boundary - — at runtime the ctx is always a :class:`_NativeReqCtx` because - the pool is composed under :func:`http_server`.""" - return cast(BodyHandler, self._make_dispatcher(body_handler)) - - def dispatch_streaming(self, handler: _WorkFn) -> RequestHandler: - """Wrap a streaming handler so it runs in a worker on a borrowed - conn (body **not** pre-buffered). + def dispatch(self, inner: RequestHandler) -> RequestHandler: + """Wrap ``inner`` so each request borrows the conn and queues + for a worker. Worker runs ``inner(ctx)`` on a blocking-with-timeout + socket; ``inner`` reads the body via ``ctx.receive(...)`` and + completes the response.""" + tg = self._tg + shutdown_event = self._shutdown_event - The returned :data:`RequestHandler` borrows the conn pre-body - and queues ``handler`` for a worker. Worker runs ``handler(ctx)`` - on a blocking-with-timeout socket; the handler reads the body - via ``ctx.receive(...)`` and completes the response.""" - dispatcher = self._make_dispatcher(handler) + def dispatcher(ctx: _NativeReqCtx) -> None: + ctx.conn.selector.stop_tracking(ctx.conn) + cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) + tg.start_soon(_run_request, ctx, cancel, inner) - def pre_body(ctx: _NativeReqCtx) -> BodyHandler | None: + def pre_body(ctx: _NativeReqCtx) -> None: + # httptools fires callbacks inside ``parser.feed_data`` — defer + # the worker dispatch until the parser feed returns so the + # worker doesn't race the parser. h11 has no such constraint. defer = getattr(ctx, "_defer_streaming_dispatch", None) if defer is not None: defer(dispatcher) else: dispatcher(ctx) - return None # we borrowed; selector free return cast(RequestHandler, pre_body) - def _make_dispatcher(self, fn: _WorkFn) -> _WorkFn: - tg = self._tg - shutdown_event = self._shutdown_event - - def dispatched(ctx: _NativeReqCtx) -> None: - ctx.conn.selector.stop_tracking(ctx.conn) - cancel = RequestCancel(_sock=ctx.conn.sock, _shutdown_event=shutdown_event) - tg.start_soon(_run_request, ctx, cancel, fn) - return dispatched - - -def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: _WorkFn) -> None: +def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: RequestHandler) -> None: """Run a single request on the worker thread. Mirrors the failure handling the previous worker loop performed: @@ -144,17 +109,16 @@ def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: _WorkFn) -> None finally: # Conn-release policy: # - # On the success path the handler's ``finish_response`` - # already re-tracked the conn via ``_maybe_give_back`` — - # we MUST NOT touch it here. We can't read - # ``ctx.conn.tracked`` either: that field is shared - # with the next request's dispatcher, which clears it + # On the success path the handler's response-write code already + # re-tracked the conn via ``_maybe_give_back`` — we MUST NOT touch + # it here. We can't read ``ctx.conn.tracked`` either: that field + # is shared with the next request's dispatcher, which clears it # via ``stop_tracking`` before this finally runs. # - # The only case left to handle here is per-request - # cancellation: the handler caught the signal and - # returned, but the conn is in an uncertain state. - # ``cancel.fired`` is the cheap (no-syscall) check. + # The only case left to handle here is per-request cancellation: + # the handler caught the signal and returned, but the conn is in + # an uncertain state. ``cancel.fired`` is the cheap (no-syscall) + # check. if cancel.fired: with suppress(Exception): ctx.conn.close() @@ -199,30 +163,26 @@ def _emit_body_too_large(ctx: _NativeReqCtx) -> None: @asynccontextmanager async def thread_pool_handler(inner: RequestHandler, /) -> AsyncGenerator[RequestHandler]: - """Async context manager: yields a ``RequestHandler`` that offloads - body-handler continuations to a worker thread. - - The wrapper invokes ``inner(ctx)`` on the selector thread (pre-body - phase). If ``inner`` returns: - - - ``None`` — pass-through. ``inner`` already completed inline (e.g. - a 404 or a body-free GET) or borrowed the conn itself. No worker - hop. - - :data:`BodyHandler` — the wrapper returns a *new* continuation - that, when invoked by the selector after the body has been - buffered, ``stop_tracking`` s the conn and queues the work for a - worker. Workers run the continuation under a per-request cancel - scope. + """Async context manager: yields a ``RequestHandler`` that runs each + request on a worker thread with a *borrowed* conn. - Workers come from a process-wide - :class:`localpost.threadtools.ThreadTaskGroup` and are reused across - all pool wrappers; there is no concurrency cap. + ``inner`` runs on a worker on a blocking-with-timeout socket — body + reads (``ctx.receive(...)`` / :func:`localpost.http.read_body`) and + other blocking syscalls don't stall the selector. Workers come from + a process-wide :class:`localpost.threadtools.ThreadTaskGroup` and + are reused across all pool wrappers; there is no concurrency cap. Per-request cancellation surfaces through :func:`localpost.http.check_cancelled` (client disconnect via non-blocking ``MSG_PEEK``; pool shutdown via a single :class:`threading.Event` shared by every in-flight token). + Routing-only handlers (no body, no I/O) can run on the selector by + not wrapping the router with this. The Router's 404/405 path + completes inline via ``ctx.complete``; matched routes hit the + per-route handler — wrap *that* with the pool, or wrap the whole + router. Whichever shape the user composes is what runs. + Example:: async with thread_pool_handler(router.as_handler()) as h: @@ -230,45 +190,12 @@ async def thread_pool_handler(inner: RequestHandler, /) -> AsyncGenerator[Reques ... """ async with _pool_context() as pool: + yield pool.dispatch(inner) - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: - result = inner(ctx) - if result is None: - return None - return pool.dispatch_buffered(result) - - yield wrapped - - -@asynccontextmanager -async def streaming_pool_handler(inner: _WorkFn, /) -> AsyncGenerator[RequestHandler]: - """Async context manager: yields a ``RequestHandler`` that runs - ``inner`` on a worker thread with a *borrowed* conn — body **not** - pre-buffered. - - Use for streaming uploads / large bodies. The worker reads via - ``ctx.receive(...)`` on a blocking-with-timeout socket, then emits - a response. - - ``inner`` shape: ``(ctx) -> None``. Must complete the response or - close the conn. - - Per-request cancellation surfaces through - :func:`localpost.http.check_cancelled` — same triggers as - :func:`thread_pool_handler`. - Example:: - - def upload(ctx: HTTPReqCtx) -> None: - with open("/tmp/u", "wb") as f: - while chunk := ctx.receive(8192): - f.write(chunk) - ctx.complete(Response(204, [(b"content-length", b"0")]), b"") - - - async with streaming_pool_handler(upload) as h: - async with http_server(config, h): - ... - """ - async with _pool_context() as pool: - yield pool.dispatch_streaming(inner) +# Back-compat alias: the old streaming_pool_handler took a +# ``Callable[[_NativeReqCtx], None]`` and dispatched on a borrowed conn +# without pre-buffering body. After unifying the dispatch shape that's +# exactly what thread_pool_handler does, so streaming_pool_handler is +# now an alias for it. +streaming_pool_handler = thread_pool_handler diff --git a/localpost/http/app.py b/localpost/http/app.py index ad551da..8d7f5b9 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -12,13 +12,9 @@ ``application/octet-stream``, ``dict`` / ``list`` → ``application/json``, :data:`localpost.http.Response` → as-is, ``(Response, bytes)`` tuple → with body, ``None`` → 204. -- Worker-pool dispatch for matched routes. -- Two body modes per route: - - **Buffered (default):** selector buffers the full body into - ``ctx.body``; the handler runs on a worker with body in hand. - - **Streaming (``buffer_body=False``):** the handler runs on a worker - on a borrowed conn *before* the body is read; reads via - ``ctx.receive(...)``. Useful for large uploads. +- Worker-pool dispatch for matched routes (each handler runs on a + worker with a borrowed conn — use :func:`localpost.http.read_body` + to consume the request body when needed). - App-level and per-route middleware composition. For lower-level control, drop down to :class:`localpost.http.Router` + @@ -36,13 +32,12 @@ def hello(name: str): @app.post("/{name}/profile") def update_profile(ctx: HTTPReqCtx, name: str): - profile = json.loads(ctx.body) + profile = json.loads(read_body(ctx)) return {"updated": name, "profile": profile} - @app.post("/{name}/avatar", buffer_body=False) + @app.post("/{name}/avatar") def upload_avatar(ctx: HTTPReqCtx, name: str): - # Streaming: ctx.body is empty here; read chunks via ctx.receive(...) with open(f"/tmp/{name}.jpg", "wb") as f: while chunk := ctx.receive(8192): f.write(chunk) @@ -62,7 +57,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from typing import Any, get_type_hints from localpost import hosting -from localpost.http._base import BodyHandler, HTTPReqCtx, Middleware, RequestHandler, compose +from localpost.http._base import HTTPReqCtx, Middleware, RequestHandler, compose from localpost.http._pool import _Pool, _pool_context from localpost.http._service import http_server from localpost.http._types import Response @@ -174,20 +169,17 @@ class _Route: path: str fn: Callable[..., Any] middleware: tuple[Middleware, ...] - buffer_body: bool class HttpApp: """Decorator-driven HTTP app on top of :class:`Router`. Args: - pooled: When ``True`` (default), buffered routes run on a shared - worker pool (:func:`thread_pool_handler`). Set to ``False`` to - run buffered handlers inline on the selector thread — only - viable when every handler is short and non-blocking. - Streaming routes (``buffer_body=False``) always require the - pool; ``pooled=False`` with a streaming route raises at - service-startup time. + pooled: When ``True`` (default), each matched-route handler runs + on a shared worker pool (:func:`thread_pool_handler`). Set to + ``False`` to run handlers inline on the selector thread — + only viable when every handler is short and non-blocking + (no body reads, no slow I/O). middleware: App-level middlewares wrapping the entire dispatcher (after Router). Outermost-first. """ @@ -209,52 +201,46 @@ def get( path: str, *, middleware: Sequence[Middleware] = (), - buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.GET, path, middleware, buffer_body) + return self._decorator(HTTPMethod.GET, path, middleware) def post( self, path: str, *, middleware: Sequence[Middleware] = (), - buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.POST, path, middleware, buffer_body) + return self._decorator(HTTPMethod.POST, path, middleware) def put( self, path: str, *, middleware: Sequence[Middleware] = (), - buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.PUT, path, middleware, buffer_body) + return self._decorator(HTTPMethod.PUT, path, middleware) def delete( self, path: str, *, middleware: Sequence[Middleware] = (), - buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.DELETE, path, middleware, buffer_body) + return self._decorator(HTTPMethod.DELETE, path, middleware) def patch( self, path: str, *, middleware: Sequence[Middleware] = (), - buffer_body: bool = True, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - return self._decorator(HTTPMethod.PATCH, path, middleware, buffer_body) + return self._decorator(HTTPMethod.PATCH, path, middleware) def _decorator( self, method: HTTPMethod, path: str, middleware: Sequence[Middleware], - buffer_body: bool, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: def deco(fn: Callable[..., Any]) -> Callable[..., Any]: # Validate signature eagerly so registration-time errors fire @@ -266,7 +252,6 @@ def deco(fn: Callable[..., Any]) -> Callable[..., Any]: path=path, fn=fn, middleware=tuple(middleware), - buffer_body=buffer_body, ) ) return fn # return original for tests @@ -275,45 +260,18 @@ def deco(fn: Callable[..., Any]) -> Callable[..., Any]: # ----- Building ----- - def _build_buffered_handler(self, route: _Route, pool: _Pool | None) -> RequestHandler: - """Buffered route: pre-body returns BodyHandler that, if pooled, - dispatches to a worker; otherwise runs inline on selector.""" + def _build_route_handler(self, route: _Route, pool: _Pool | None) -> RequestHandler: resolvers = _build_resolvers(route.fn, route.path) fn = route.fn - def post_body(ctx: HTTPReqCtx) -> None: + def inner(ctx: HTTPReqCtx) -> None: kwargs = {n: r(ctx) for n, r in resolvers.items()} result = fn(**kwargs) response, body = _wrap_response(result) ctx.complete(response, body) - if pool is not None: - dispatched: BodyHandler = pool.dispatch_buffered(post_body) - - def pre_body_pooled(_ctx: HTTPReqCtx) -> BodyHandler: - return dispatched - - return self._with_route_middleware(pre_body_pooled, route.middleware) - - def pre_body_inline(_ctx: HTTPReqCtx) -> BodyHandler: - return post_body - - return self._with_route_middleware(pre_body_inline, route.middleware) - - def _build_streaming_handler(self, route: _Route, pool: _Pool) -> RequestHandler: - """Streaming route: pre-body borrows + queues for a worker, - which runs the user fn on a blocking conn (body not buffered). - """ - resolvers = _build_resolvers(route.fn, route.path) - fn = route.fn - - def streaming_inner(ctx: HTTPReqCtx) -> None: - kwargs = {n: r(ctx) for n, r in resolvers.items()} - result = fn(**kwargs) - response, body = _wrap_response(result) - ctx.complete(response, body) - - return self._with_route_middleware(pool.dispatch_streaming(streaming_inner), route.middleware) + handler: RequestHandler = pool.dispatch(inner) if pool is not None else inner # type: ignore[arg-type] + return self._with_route_middleware(handler, route.middleware) def _with_route_middleware(self, handler: RequestHandler, middleware: tuple[Middleware, ...]) -> RequestHandler: if not middleware: @@ -323,15 +281,7 @@ def _with_route_middleware(self, handler: RequestHandler, middleware: tuple[Midd def _build_router_handler(self, pool: _Pool | None) -> RequestHandler: routes = Routes() for route in self._routes: - if route.buffer_body: - handler = self._build_buffered_handler(route, pool) - else: - if pool is None: - raise RuntimeError( - f"streaming route {route.method.value} {route.path!r} requires " - f"a worker pool (HttpApp(pooled=True))" - ) - handler = self._build_streaming_handler(route, pool) + handler = self._build_route_handler(route, pool) routes.add(route.method, route.path, handler) router = routes.build().as_handler() if self._middleware: diff --git a/localpost/http/asgi.py b/localpost/http/asgi.py index e2d6ed0..7e6423f 100644 --- a/localpost/http/asgi.py +++ b/localpost/http/asgi.py @@ -3,32 +3,25 @@ Symmetric with :mod:`localpost.http.wsgi`: this module owns the translation between the foreign protocol (ASGI 3) and our async request-context shape (:class:`AsyncHTTPReqCtx`). The handler doesn't -know anything about ASGI — it just reads ``ctx.request`` / ``ctx.body`` -or ``await ctx.receive(size)``, and calls ``await ctx.complete(...)`` +know anything about ASGI — it just reads ``ctx.request`` / +``await ctx.receive(size)``, and calls ``await ctx.complete(...)`` / ``await ctx.stream(...)``. ``localpost.http`` itself doesn't ship an async server — production ASGI servers (uvicorn, hypercorn, granian) already exist. This module plugs an :data:`AsyncRequestHandler` into one of them via :func:`to_asgi`. -Two body-handling modes: - -- **Buffered** (default): the bridge reads the full body upfront and - drops it on ``ctx.body``. ``ctx.receive(size)`` slices that buffer. - Matches the JSON-API common case; 413 is sent before the handler - runs if the body exceeds ``max_body_size``. -- **Streaming** (``streaming=True``): the body is *not* pre-read. - ``ctx.body`` is empty; the handler pulls chunks via - ``await ctx.receive(size)``. Use for large uploads / streaming - consumers. ``max_body_size`` is enforced via the ``Content-Length`` - header when present (413 before dispatch); chunked uploads without - ``Content-Length`` aren't capped — trust your ASGI server's limit - or check ``len(chunk)`` in the handler. +Body bytes are read lazily — the bridge does *not* pre-buffer. The +handler pulls chunks via ``await ctx.receive(size)`` (or the +:func:`localpost.http.aread_body` helper for the "give me the whole +body" common case). ``max_body_size`` is enforced via the +``Content-Length`` header when present (413 before dispatch); chunked +uploads without ``Content-Length`` aren't capped at the bridge — trust +your ASGI server's limit or check ``len(chunk)`` in the handler. """ from __future__ import annotations -import threading from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, BinaryIO, final @@ -69,7 +62,6 @@ def to_asgi( handler: AsyncRequestHandler, *, max_body_size: int = 1 << 20, - streaming: bool = False, ) -> ASGIApp: """Wrap an :data:`AsyncRequestHandler` as an ASGI 3 application. @@ -87,21 +79,22 @@ async def my_handler(ctx): Args: handler: The async request handler. - max_body_size: Cap on the request body, in bytes. Buffered mode - raises ``413 Payload Too Large`` before the handler runs; - streaming mode pre-checks ``Content-Length`` (when present) - and 413s if it exceeds the cap. ``-1`` disables the cap. - Defaults to ``1 << 20`` (1 MiB). - streaming: When ``True``, skip the pre-buffer — ``ctx.body`` - stays empty and the handler pulls body chunks via - ``await ctx.receive(size)``. Use for large uploads or - streaming consumers; otherwise leave ``False`` (default). + max_body_size: Cap on the request body, in bytes. The bridge + pre-checks ``Content-Length`` (when present) and replies + ``413 Payload Too Large`` before the handler runs. + Chunk-by-chunk enforcement during ``ctx.receive`` is the + handler's / :func:`localpost.http.aread_body`'s job. + ``-1`` disables the cap. Defaults to ``1 << 20`` (1 MiB). The returned callable handles ``lifespan`` (no-op accept) and ``http`` scopes; WebSocket scopes are rejected with :class:`ValueError`. A peer ``http.disconnect`` flips ``ctx.disconnected``; long handlers / SSE generators poll it between events to short-circuit cleanly. + + Body bytes are pulled lazily — handlers call + ``await ctx.receive(size)`` (or :func:`localpost.http.aread_body` + for the whole body in one call). The bridge never pre-buffers. """ async def asgi_app(scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None: @@ -111,59 +104,22 @@ async def asgi_app(scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> No return if kind != "http": raise ValueError(f"to_asgi: unsupported ASGI scope type: {kind!r}") - if streaming: - await _handle_http_streaming(handler, max_body_size, scope, receive, send) - else: - await _handle_http_buffered(handler, max_body_size, scope, receive, send) + await _handle_http(handler, max_body_size, scope, receive, send) return asgi_app -async def _handle_http_buffered( - handler: AsyncRequestHandler, - max_body_size: int, - scope: ASGIScope, - receive: ASGIReceive, - send: ASGISend, -) -> None: - try: - body = await _read_body(receive, max_body_size) - except ValueError: - await _send_canned(send, 413, b"Payload Too Large") - return - - request = build_request_from_scope(scope) - remote, local = addrs_from_scope(scope) - disconnected = threading.Event() - ctx = _ASGIReqCtx( - request=request, - body=body, - remote_addr=remote, - local_addr=local, - scheme=str(scope.get("scheme", "http")), - _send=send, - _disconnected=disconnected, - ) - - async with anyio.create_task_group() as tg: - tg.start_soon(_watch_disconnect, receive, disconnected) - try: - await handler(ctx) - finally: - tg.cancel_scope.cancel() - - -async def _handle_http_streaming( +async def _handle_http( handler: AsyncRequestHandler, max_body_size: int, scope: ASGIScope, receive: ASGIReceive, send: ASGISend, ) -> None: - """Streaming dispatch — single channel-pump task demuxes body chunks + """Lazy-body dispatch — single channel-pump task demuxes body chunks + disconnect events. The ASGI receive channel is single-consumer, so - we can't run a body-reader and a disconnect-watcher in parallel; the - pump owns the channel and feeds an in-process body queue. + the pump owns it and feeds an in-process body queue that backs + ``ctx.receive(size)``. """ request = build_request_from_scope(scope) @@ -176,11 +132,10 @@ async def _handle_http_streaming( return remote, local = addrs_from_scope(scope) - disconnected = threading.Event() + disconnected = anyio.Event() body_send, body_recv = anyio.create_memory_object_stream[bytes](0) ctx = _ASGIReqCtx( request=request, - body=b"", remote_addr=remote, local_addr=local, scheme=str(scope.get("scheme", "http")), @@ -219,36 +174,24 @@ class _ResponseAlreadyStarted(RuntimeError): class _ASGIReqCtx: """:class:`AsyncHTTPReqCtx` backed by an ASGI 3 ``scope`` + ``send``. - Two body-source modes (selected by ``to_asgi(streaming=...)``): - - - **Buffered**: ``_body_stream`` is ``None``. The request body has - been pre-read into ``body`` before this ctx was built, so - :meth:`receive` slices that buffer. Mirrors the - :class:`localpost.http._WSGIReqCtx` shape. - - **Streaming**: ``_body_stream`` is set; ``body`` is empty. - :meth:`receive` pulls chunks from the in-process queue that the - :func:`_pump_channel` task feeds from ASGI ``http.request`` - events. Trailing partials beyond ``size`` are stashed in - ``_stream_leftover``. - - ``complete`` and ``stream`` translate into ``http.response.start`` - + ``http.response.body`` events. ``disconnected`` flips when an - ``http.disconnect`` event arrives (via the watcher task in - buffered mode, or the channel pump in streaming mode). + Body bytes are pulled lazily from an in-process queue that the + :func:`_pump_channel` task feeds from ASGI ``http.request`` events. + Trailing partials beyond ``size`` are stashed in + ``_stream_leftover``. ``complete`` and ``stream`` translate into + ``http.response.start`` + ``http.response.body`` events. + ``disconnected`` flips when an ``http.disconnect`` event arrives. """ request: Request - body: bytes remote_addr: str | None local_addr: str scheme: str _send: ASGISend - _disconnected: threading.Event + _disconnected: anyio.Event + _body_stream: MemoryObjectReceiveStream[bytes] response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) _started: bool = False - _body_cursor: int = 0 - _body_stream: MemoryObjectReceiveStream[bytes] | None = None _stream_eof: bool = False _stream_leftover: bytes = b"" @@ -257,19 +200,6 @@ def disconnected(self) -> bool: return self._disconnected.is_set() async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - if self._body_stream is None: - return self._slice_buffered(size) - return await self._receive_streaming(size) - - def _slice_buffered(self, size: int) -> bytes: - if self._body_cursor >= len(self.body): - return b"" - end = self._body_cursor + size - chunk = self.body[self._body_cursor : end] - self._body_cursor += len(chunk) - return chunk - - async def _receive_streaming(self, size: int) -> bytes: if self._stream_leftover: if len(self._stream_leftover) <= size: chunk = self._stream_leftover @@ -280,7 +210,6 @@ async def _receive_streaming(self, size: int) -> bytes: return chunk if self._stream_eof: return b"" - assert self._body_stream is not None try: chunk = await self._body_stream.receive() except anyio.EndOfStream: @@ -390,53 +319,13 @@ def _response_start_event(response: _Response) -> dict[str, Any]: # --- Body buffering / disconnect / lifespan / canned responses --------- -async def _read_body(receive: ASGIReceive, max_size: int) -> bytes: - """Buffer the entire request body from successive ``http.request`` events. - - Raises :class:`ValueError` if the accumulated size would exceed - ``max_size``. ``http.disconnect`` while reading aborts cleanly with - whatever bytes have been received so far. - """ - chunks: list[bytes] = [] - total = 0 - while True: - event = await receive() - kind = event.get("type") - if kind == "http.disconnect": - return b"".join(chunks) - if kind != "http.request": - continue - body: bytes = event.get("body", b"") or b"" - if body: - total += len(body) - if max_size >= 0 and total > max_size: - raise ValueError(f"Request body exceeds max_body_size ({max_size} bytes)") - chunks.append(body) - if not event.get("more_body", False): - return b"".join(chunks) - - -async def _watch_disconnect(receive: ASGIReceive, flag: threading.Event) -> None: - """Drain ASGI events while the handler runs; flip ``flag`` on - ``http.disconnect``. Loop ends silently when the task group cancels.""" - try: - while True: - event = await receive() - if event.get("type") == "http.disconnect": - flag.set() - return - except Exception: # noqa: BLE001 - # ``receive`` may raise once the response is fully sent; treat as benign. - return - - async def _pump_channel( receive: ASGIReceive, body_send: MemoryObjectSendStream[bytes], - flag: threading.Event, + flag: anyio.Event, ) -> None: - """Streaming-mode demuxer: consume ASGI events, route body chunks to - ``body_send`` and flip ``flag`` on ``http.disconnect``. + """Body / disconnect demuxer: consume ASGI events, route body chunks + to ``body_send`` and flip ``flag`` on ``http.disconnect``. Closes ``body_send`` once body is at EOM (or peer disconnected) so the handler's ``ctx.receive`` sees ``b""``. Continues consuming diff --git a/localpost/http/compress.py b/localpost/http/compress.py index 8791bb8..a8c9cea 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -39,7 +39,7 @@ from dataclasses import dataclass from typing import Any, BinaryIO, Final -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._base import HTTPReqCtx, RequestHandler from localpost.http._types import Request, Response __all__ = [ @@ -430,10 +430,6 @@ class _CompressedCtx: def request(self) -> Request: return self._inner.request - @property - def body(self) -> bytes: - return self._inner.body - @property def attrs(self) -> dict[Any, Any]: return self._inner.attrs @@ -568,36 +564,21 @@ def compress_handler( server_pref = tuple(algorithms) - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + def wrapped(ctx: HTTPReqCtx) -> None: accept = _get_header(ctx.request.headers, b"accept-encoding") if accept is None or ctx.request.method == b"HEAD": - return inner(ctx) + inner(ctx) + return chosen = _negotiate(accept, server_pref) if chosen is None: - return inner(ctx) + inner(ctx) + return proxy = _CompressedCtx( _inner=ctx, _encoding=chosen, _min_size=min_size, _compressible_types=compressible_types, ) - result = inner(proxy) # type: ignore[arg-type] # _CompressedCtx structurally satisfies HTTPReqCtx - if result is None: - return None - # If the inner returned a BodyHandler continuation, route it - # through the same proxy so its ``complete`` call lands here too. - body_handler: BodyHandler = result - - def proxied_body(real_ctx: HTTPReqCtx) -> None: - body_handler( - _CompressedCtx( # type: ignore[arg-type] - _inner=real_ctx, - _encoding=chosen, - _min_size=min_size, - _compressible_types=compressible_types, - ) - ) - - return proxied_body + inner(proxy) # type: ignore[arg-type] # _CompressedCtx structurally satisfies HTTPReqCtx return wrapped diff --git a/localpost/http/flask.py b/localpost/http/flask.py index 50a6097..3f298ab 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -20,7 +20,8 @@ from flask import Flask -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._body import read_body from localpost.http._service import http_server from localpost.http._types import Response as _Response from localpost.http.config import ServerConfig @@ -32,16 +33,22 @@ def flask_handler(app: Flask) -> RequestHandler: """Wrap a Flask app as a native :class:`RequestHandler`. - Always returns a :data:`BodyHandler` continuation so the selector - buffers the request body into ``ctx.body`` before the Flask - pipeline runs (Flask reads it via ``request.get_json`` etc.). + The handler reads the full request body via + :func:`localpost.http.read_body`, exposes it to Flask as + ``wsgi.input``, and streams the response straight to h11. + + Reading the body blocks on the request socket, so this handler + **must** be composed with :func:`localpost.http.thread_pool_handler` + (or run inside an explicit ``ctx.borrow()`` block) — running it on + the selector thread will stall the loop while the upload finishes. See the module docstring for behaviour differences vs. WSGI — notably, the request context stays active during response body streaming. """ def run_flask(http_ctx: HTTPReqCtx) -> None: - environ = _build_environ(http_ctx) + body = read_body(http_ctx) + environ = _build_environ(http_ctx, body) with app.request_context(environ): try: response = app.full_dispatch_request() @@ -55,10 +62,7 @@ def run_flask(http_ctx: HTTPReqCtx) -> None: finally: response.close() # Fire werkzeug's call_on_close callbacks - def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: - return run_flask - - return pre_body + return run_flask def _write_response(http_ctx: HTTPReqCtx, response) -> None: diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index 67318ae..c7cda1f 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -29,7 +29,8 @@ from flask import Flask from flask import request as flask_request -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._body import read_body from localpost.http._sentry import request_transaction from localpost.http.flask import _write_response from localpost.http.wsgi import _build_environ @@ -40,13 +41,14 @@ def sentry_flask_handler(app: Flask, *, op: str = "http.server") -> RequestHandler: """Wrap a Flask app with a Sentry transaction covering the full request, streaming included. - Always returns a :data:`BodyHandler` continuation — Flask reads - ``request`` body internally, so the selector must buffer it before - the Flask pipeline runs. + Reads the full request body via :func:`localpost.http.read_body` + (Flask's WSGI surface needs ``wsgi.input``); composing with + :func:`localpost.http.thread_pool_handler` is required. """ def run_flask_with_sentry(ctx: HTTPReqCtx) -> None: - environ = _build_environ(ctx) + body = read_body(ctx) + environ = _build_environ(ctx, body) method = environ["REQUEST_METHOD"] path = environ["PATH_INFO"] @@ -73,7 +75,4 @@ def run_flask_with_sentry(ctx: HTTPReqCtx) -> None: finally: response.close() - def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: - return run_flask_with_sentry - - return pre_body + return run_flask_with_sentry diff --git a/localpost/http/router.py b/localpost/http/router.py index b6bad4d..9a8dfe3 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -23,7 +23,7 @@ from http.client import responses as _http_phrases from typing import Self, final -from localpost.http._base import BodyHandler, HTTPReqCtx +from localpost.http._base import HTTPReqCtx from localpost.http._base import RequestHandler as NativeRequestHandler from localpost.http._types import Response as _Response @@ -132,10 +132,9 @@ class Routes: @routes.get("/hello/{name}") - def hello(ctx: HTTPReqCtx) -> BodyHandler | None: + def hello(ctx: HTTPReqCtx) -> None: match = route_match(ctx) ctx.complete(Response(...), b"hi " + match.path_args["name"].encode()) - return None router = routes.build() @@ -295,7 +294,7 @@ def as_handler(self) -> NativeRequestHandler: the body bytes (if any) are silently drained by the http layer. """ - def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: + def dispatch(ctx: HTTPReqCtx) -> None: req = ctx.request # ``req.path`` and ``req.method`` are pre-split / pre-uppercased # by the backend (httptools.parse_url for httptools, manual @@ -307,13 +306,13 @@ def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: if isinstance(match, _MatchNotFound): ctx.complete(_NOT_FOUND_RESPONSE, _NOT_FOUND_BODY) - return None + return if isinstance(match, _MatchMethodNotAllowed): ctx.complete(match.response, match.body) - return None + return ctx.attrs[RouteMatch] = match.match - return match.handler(ctx) + match.handler(ctx) return dispatch diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 2fee36a..993e316 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -33,9 +33,7 @@ def get_book(ctx): ... from __future__ import annotations -import sys - -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._base import HTTPReqCtx, RequestHandler from localpost.http._sentry import request_transaction from localpost.http.router import Router, _MatchOk @@ -45,13 +43,12 @@ def get_book(ctx): ... def sentry_router_handler(router: Router, *, op: str = "http.server") -> RequestHandler: """Wrap a :class:`Router` with a Sentry transaction per request. - The transaction spans the full request, including the post-body - continuation when the router defers to one. 404 / 405 paths complete - inline and end the transaction in the same call. + The transaction spans the full request handler invocation. 404 / 405 + paths complete inline and end the transaction in the same call. """ inner = router.as_handler() - def handle(ctx: HTTPReqCtx) -> BodyHandler | None: + def handle(ctx: HTTPReqCtx) -> None: req = ctx.request method = req.method.decode("ascii") target = req.target.decode("iso-8859-1") @@ -67,34 +64,11 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: tx_name = f"{method} {path}" source = "url" - # Manual __enter__/__exit__ because the transaction may need to - # span a deferred body handler returned below. - tx_cm = request_transaction(op=op, name=tx_name, source=source, method=method, url=target) - tx = tx_cm.__enter__() - - def finalize(exc_info: tuple = (None, None, None)) -> None: - if ctx.response_status is not None: - tx.set_http_status(ctx.response_status) - tx_cm.__exit__(*exc_info) - - try: - result = inner(ctx) - except BaseException: - finalize(sys.exc_info()) - raise - - if result is None: - finalize() - return None - - def wrapped(req_ctx: HTTPReqCtx) -> None: + with request_transaction(op=op, name=tx_name, source=source, method=method, url=target) as tx: try: - result(req_ctx) - except BaseException: - finalize(sys.exc_info()) - raise - finalize() - - return wrapped + inner(ctx) + finally: + if ctx.response_status is not None: + tx.set_http_status(ctx.response_status) return handle diff --git a/localpost/http/rsgi.py b/localpost/http/rsgi.py index 595d6c5..cea79e9 100644 --- a/localpost/http/rsgi.py +++ b/localpost/http/rsgi.py @@ -4,8 +4,8 @@ this module owns the translation between the foreign protocol (Granian's RSGI) and our async request-context shape (:class:`AsyncHTTPReqCtx`). The handler doesn't know anything about RSGI — it just reads -``ctx.request`` / ``ctx.body`` or ``await ctx.receive(size)``, and -calls ``await ctx.complete(...)`` / ``await ctx.stream(...)`` / +``ctx.request`` / ``await ctx.receive(size)``, and calls +``await ctx.complete(...)`` / ``await ctx.stream(...)`` / ``await ctx.sendfile(...)``. RSGI's wire surface is richer than ASGI's, so the bridge gets a few @@ -18,6 +18,10 @@ - **Streaming uploads** don't need a pump task — RSGI's ``proto`` is directly async-iterable, so :meth:`receive` wraps that iterator. +Body bytes are read lazily via ``await ctx.receive(size)`` (or the +:func:`localpost.http.aread_body` helper). The bridge never +pre-buffers. + This module covers Mode A (single HTTP app under Granian); Mode B (host-as-RSGI for hosted apps with multiple services) lives in :mod:`localpost.hosting.rsgi`. @@ -25,7 +29,6 @@ from __future__ import annotations -import threading from collections.abc import AsyncIterator from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, BinaryIO, final @@ -65,7 +68,6 @@ def to_rsgi( handler: AsyncRequestHandler, *, max_body_size: int = 1 << 20, - streaming: bool = False, ) -> RSGIApplication: """Wrap an :data:`AsyncRequestHandler` as an RSGI application. @@ -83,20 +85,21 @@ async def my_handler(ctx): Args: handler: The async request handler. - max_body_size: Cap on the request body, in bytes. Buffered mode - raises ``413 Payload Too Large`` before the handler runs; - streaming mode pre-checks ``Content-Length`` (when present) - and 413s if it exceeds the cap. ``-1`` disables the cap. - Defaults to ``1 << 20`` (1 MiB). - streaming: When ``True``, skip the pre-buffer — ``ctx.body`` - stays empty and the handler pulls body chunks via - ``await ctx.receive(size)``. Use for large uploads. + max_body_size: Cap on the request body, in bytes. The bridge + pre-checks ``Content-Length`` (when present) and replies + ``413 Payload Too Large`` before the handler runs. + Chunk-by-chunk enforcement during ``ctx.receive`` is the + handler's / :func:`localpost.http.aread_body`'s job. + ``-1`` disables the cap. Defaults to ``1 << 20`` (1 MiB). Returns: An object with ``__rsgi__`` (and no-op ``__rsgi_init__`` / ``__rsgi_del__``) suitable as Granian's RSGI target. + + Body bytes are pulled lazily via ``await ctx.receive(size)`` (or + :func:`localpost.http.aread_body`); the bridge never pre-buffers. """ - return _RSGIApp(handler, max_body_size=max_body_size, streaming=streaming) + return _RSGIApp(handler, max_body_size=max_body_size) @final @@ -107,24 +110,19 @@ class _RSGIApp: is the variant that runs a full hosting lifecycle alongside. """ - __slots__ = ("_handler", "_max_body_size", "_streaming") + __slots__ = ("_handler", "_max_body_size") def __init__( self, handler: AsyncRequestHandler, *, max_body_size: int, - streaming: bool, ) -> None: self._handler = handler self._max_body_size = max_body_size - self._streaming = streaming async def __rsgi__(self, scope: RSGIScope, proto: RSGIProtocol) -> None: - if self._streaming: - await _dispatch_streaming(self._handler, self._max_body_size, scope, proto) - else: - await _dispatch_buffered(self._handler, self._max_body_size, scope, proto) + await _dispatch(self._handler, self._max_body_size, scope, proto) def __rsgi_init__(self, loop: Any) -> None: # No background services to start. @@ -138,7 +136,7 @@ def __rsgi_del__(self, loop: Any) -> None: # --- Dispatch ----------------------------------------------------------- -async def _dispatch_buffered( +async def _dispatch( handler: AsyncRequestHandler, max_body_size: int, scope: RSGIScope, @@ -151,49 +149,11 @@ async def _dispatch_buffered( await _send_canned(proto, 413, b"Payload Too Large") return - body = await proto() - if max_body_size >= 0 and len(body) > max_body_size: - await _send_canned(proto, 413, b"Payload Too Large") - return - remote, local = addrs_from_scope(scope) - disconnected = threading.Event() - ctx = _RSGIReqCtx( - request=request, - body=body, - remote_addr=remote, - local_addr=local, - scheme=str(scope.scheme), - _proto=proto, - _disconnected=disconnected, - ) - async with anyio.create_task_group() as tg: - tg.start_soon(_watch_disconnect, proto, disconnected) - try: - await handler(ctx) - finally: - tg.cancel_scope.cancel() - - -async def _dispatch_streaming( - handler: AsyncRequestHandler, - max_body_size: int, - scope: RSGIScope, - proto: RSGIProtocol, -) -> None: - request = build_request_from_scope(scope) - if max_body_size >= 0: - cl = _content_length(request) - if cl is not None and cl > max_body_size: - await _send_canned(proto, 413, b"Payload Too Large") - return - - remote, local = addrs_from_scope(scope) - disconnected = threading.Event() + disconnected = anyio.Event() body_send, body_recv = anyio.create_memory_object_stream[bytes](0) ctx = _RSGIReqCtx( request=request, - body=b"", remote_addr=remote, local_addr=local, scheme=str(scope.scheme), @@ -222,16 +182,10 @@ class _ResponseAlreadyStarted(RuntimeError): class _RSGIReqCtx: """:class:`AsyncHTTPReqCtx` backed by Granian's RSGI ``proto``. - Two body-source modes (selected by ``to_rsgi(streaming=...)``): - - - **Buffered**: ``_body_stream`` is ``None``. The whole body has - been read via ``await proto()`` before this ctx was built; - :meth:`receive` slices ``body``. - - **Streaming**: ``_body_stream`` is set; ``body`` is empty. - :meth:`receive` pulls chunks from the in-process queue that - :func:`_pump_body` feeds from ``async for chunk in proto``. - - Response paths translate directly to RSGI calls: + Body bytes come lazily through :meth:`receive`, which pulls chunks + from the in-process queue that :func:`_pump_body` feeds from + ``async for chunk in proto``. Response paths translate directly + to RSGI calls: - :meth:`complete` → ``proto.response_bytes`` (or ``response_empty``). - :meth:`stream` → ``proto.response_stream`` + ``transport.send_bytes``. @@ -244,17 +198,15 @@ class _RSGIReqCtx: """ request: Request - body: bytes remote_addr: str | None local_addr: str scheme: str _proto: RSGIProtocol - _disconnected: threading.Event + _disconnected: anyio.Event + _body_stream: MemoryObjectReceiveStream[bytes] response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) _started: bool = False - _body_cursor: int = 0 - _body_stream: MemoryObjectReceiveStream[bytes] | None = None _stream_eof: bool = False _stream_leftover: bytes = b"" @@ -263,19 +215,6 @@ def disconnected(self) -> bool: return self._disconnected.is_set() async def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - if self._body_stream is None: - return self._slice_buffered(size) - return await self._receive_streaming(size) - - def _slice_buffered(self, size: int) -> bytes: - if self._body_cursor >= len(self.body): - return b"" - end = self._body_cursor + size - chunk = self.body[self._body_cursor : end] - self._body_cursor += len(chunk) - return chunk - - async def _receive_streaming(self, size: int) -> bytes: if self._stream_leftover: if len(self._stream_leftover) <= size: chunk = self._stream_leftover @@ -286,7 +225,6 @@ async def _receive_streaming(self, size: int) -> bytes: return chunk if self._stream_eof: return b"" - assert self._body_stream is not None try: chunk = await self._body_stream.receive() except anyio.EndOfStream: @@ -431,7 +369,7 @@ async def _pump_body( return -async def _watch_disconnect(proto: RSGIProtocol, flag: threading.Event) -> None: +async def _watch_disconnect(proto: RSGIProtocol, flag: anyio.Event) -> None: """Set ``flag`` when ``proto.client_disconnect()`` resolves.""" try: await proto.client_disconnect() diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 7a44ac4..b12fa3b 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -5,8 +5,10 @@ Like the httptools backend, this one: -- buffers the full request body into ``ctx.body`` before invoking a - returned :data:`BodyHandler` continuation +- dispatches the handler exactly once on ``Request`` (headers complete) + — handlers that need the request body call ``ctx.receive(size)`` + themselves (or :func:`localpost.http.read_body`); the selector never + pre-buffers - auto-buffers the response headers; the next ``send`` (or ``finish_response`` for empty bodies) emits headers + first body chunk in a single ``sendall`` @@ -35,7 +37,6 @@ REQUEST_TIMEOUT_BODY, REQUEST_TIMEOUT_RESPONSE, BaseHTTPConn, - BodyHandler, RequestHandler, Selector, _native_stream, @@ -111,16 +112,6 @@ class HTTPConn(BaseHTTPConn): to enforce the upload cap.""" idle: bool = True - # Per-request dispatch state. Set on ``h11.Request``, used while we - # iterate ``h11.Data`` events to accumulate the body, and consumed on - # ``h11.EndOfMessage`` to fire the continuation. ``_ctx`` is the - # current request context shared with the handler; ``_continuation`` - # is the post-body callback (None if the pre-body handler completed - # inline or borrowed). - _ctx: _HTTPReqCtx | None = None - _continuation: BodyHandler | None = None - _body_buf: bytearray = field(default_factory=bytearray) - def __post_init__(self) -> None: self.fd = self.sock.fileno() @@ -169,69 +160,27 @@ def _loop(self) -> None: self.body_bytes_received = 0 self.idle = True self.close_at = time.monotonic() + self.selector.config.keep_alive_timeout - # Per-request state from previous cycle is no longer relevant. - self._ctx = None - self._continuation = None - self._body_buf = bytearray() event = parser.next_event() if event is h11.NEED_DATA: - if parser.they_are_waiting_for_100_continue: - if self._continuation is not None: - # Body is wanted (handler returned a continuation) — - # tell the client to send it. Fall through to recv. - self.send(h11.InformationalResponse(status_code=100, headers=[], reason="Continue")) - else: - # No body needed — short-circuit with 417. - self.send(h11.Response(status_code=417, headers=[], reason="Expectation Failed")) - continue try: self.receive() except BlockingIOError: return # Wait for it in the selector self.close_at = time.monotonic() + self.selector.config.rw_timeout - elif isinstance(event, h11.Data): - self.body_bytes_received += len(event.data) - if self.body_bytes_received > self.selector.config.max_body_size: - raise BodyTooLarge(self.body_bytes_received) - if self._continuation is not None: - self._body_buf += event.data - # Else: pre-body handler returned None — drain silently. - elif isinstance(event, h11.EndOfMessage): - if self._continuation is not None: - cont = self._continuation - ctx = self._ctx - assert ctx is not None - self._continuation = None - ctx.body = bytes(self._body_buf) - self._body_buf = bytearray() - try: - cont(ctx) - except BodyTooLarge: - raise - except Exception: - self.selector.logger.exception( - "Body handler raised for %s %r", ctx.request.method, ctx.request.target - ) - emit_handler_error(ctx) - if ctx.borrowed: - return - if not self.tracked: - return elif isinstance(event, h11.Request): + # Pre-emptive Content-Length cap: bail before the handler + # ever sees an over-large body. cl = content_length(event.headers) if cl is not None and cl > self.selector.config.max_body_size: raise BodyTooLarge(cl) # h11 hands us ``bytes`` for method/target/version and a list - # of ``(bytes, bytes)`` tuples for headers. ``bytes(b)`` for an - # already-``bytes`` argument returns the same object, so the - # wraps were no-ops; the per-tuple comprehension was the only - # real cost. ``list(event.headers)`` is a shallow copy that - # insulates Request from h11's per-event Headers subclass. - # h11 is lenient on method case (per RFC the method is - # case-sensitive but most clients send uppercase); normalise - # here so consumers can rely on it. + # of ``(bytes, bytes)`` tuples for headers. ``list(event.headers)`` + # is a shallow copy that insulates Request from h11's per-event + # Headers subclass. h11 is lenient on method case (per RFC the + # method is case-sensitive but most clients send uppercase); + # normalise here so consumers can rely on it. target = event.target qix = target.find(b"?") if qix >= 0: @@ -252,28 +201,36 @@ def _loop(self) -> None: http_version=event.http_version, ) ctx = _HTTPReqCtx(self.selector, self, req) - self._ctx = ctx - self._body_buf = bytearray() - self._continuation = None try: - result = h(ctx) + h(ctx) except BodyTooLarge: raise except Exception: self.selector.logger.exception("Handler raised for %s %r", event.method, event.target) emit_handler_error(ctx) - result = None - if result is not None: - self._continuation = result if ctx.borrowed: - return + return # Worker owns the conn; it'll give back later. if not self.tracked: - return # connection was closed during error recovery + return # Conn closed during error recovery. + # Handler ran on the selector thread. If it didn't fully + # consume the body, keep-alive isn't safe — close the conn + # (matches RFC 7230 §6.5 "close after sending response if + # the request body wasn't read"). Covers both the + # plain "404 without reading body" and the + # ``Expect: 100-continue`` "responded without sending 100" + # cases. + if parser.their_state is not h11.DONE: + self.close() + return elif isinstance(event, h11.ConnectionClosed): self.selector.logger.debug("Client closed connection") self.close() return else: + # Stray Data / EndOfMessage events arrive only when the + # handler partially consumed the body and returned. The + # ``their_state != DONE`` check above closes us before we + # get here, so reaching this branch is a programming error. raise RuntimeError(f"Unexpected {event!r} in the connection loop") def _try_send_status(self, response: Response, body: bytes) -> None: @@ -326,7 +283,6 @@ class _HTTPReqCtx: conn: HTTPConn request: Request - body: bytes = b"" response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) _pending_header_bytes: bytes | None = None @@ -431,10 +387,11 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) self.finish_response() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - """Streaming-read API. Rare under the JSON-API contract — typical - callers receive the buffered body via ``ctx.body``. After the - :data:`BodyHandler` continuation runs, the body has been fully - consumed and the parser's state side reads ``b""``.""" + """Streaming-read API: pull up to ``size`` bytes of the request + body off the wire (or ``b""`` at EOF / end-of-message). Drives + the h11 state machine — sends 100 Continue inline if the client + is awaiting it. Use :func:`localpost.http.read_body` for the + "give me the whole body" common case.""" parser = self.conn.parser if parser.their_state is h11.DONE: return b"" diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index 353393b..f3f850b 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -39,7 +39,6 @@ REASON_PHRASES, REQUEST_TIMEOUT_WIRE, BaseHTTPConn, - BodyHandler, RequestHandler, Selector, _native_stream, @@ -117,25 +116,19 @@ class HTTPConn(BaseHTTPConn): _cur_oversize: bool = False # set by on_headers_complete if Content-Length exceeds cap # Per-conn dispatch state. One in-flight request at a time (no pipelining): - # ``_ctx`` is built in ``on_headers_complete`` and the pre-body handler is - # invoked inline. If it returns a continuation, ``_continuation`` is set - # and ``on_body`` accumulates into ``_body_buf`` until ``on_message_complete``. + # ``_ctx`` is built in ``on_headers_complete`` and the handler is invoked + # inline. ``on_body`` populates ``_body_buf`` (drained by ``ctx.receive`` + # on a borrowed conn); ``on_message_complete`` flips ``_body_eom``. _ctx: _HTTPReqCtx | None = None - _continuation: BodyHandler | None = None _body_buf: bytearray = field(default_factory=bytearray) + _body_eom: bool = False _message_complete: bool = False _body_too_large: int | None = None - # Streaming mode (``buffer_body=False``): the pre-body handler borrowed - # the conn before the body was buffered. Selector + worker both feed - # bytes through ``parser.feed_data``; ``on_body`` populates - # ``_streaming_body_buf`` regardless of which thread is feeding. The - # worker drains the buffer via ``ctx.receive``. Same model as h11 - # (``next_event``-driven), just emulated with httptools' push - # callbacks. - _streaming_active: bool = False - _streaming_body_buf: bytearray = field(default_factory=bytearray) - _streaming_eom: bool = False + # Streaming-pool dispatch hook. ``_defer_streaming_dispatch`` (called by + # the streaming-pool wrapper from inside ``on_headers_complete``) + # stashes a callback here; ``_feed`` runs it after ``parser.feed_data`` + # returns so the worker dispatch happens outside the parser callback. _deferred_streaming_dispatch: Callable[[], None] | None = None # Set when the response indicates ``Connection: close`` or the request lacked @@ -229,57 +222,48 @@ def on_headers_complete(self) -> None: ) self._ctx = ctx self._body_buf = bytearray() + self._body_eom = False self._message_complete = False - self._continuation = None - # Dispatch the pre-body handler inline. It runs on the selector - # thread; it can call ``ctx.complete(...)`` (response sent inline, - # returns None), ``ctx.borrow()`` (escape to worker, returns None), - # or return a :data:`BodyHandler` continuation to receive the body. + # Dispatch the handler inline. It runs on the selector thread; it + # can call ``ctx.complete(...)`` (response sent inline) or + # ``ctx.borrow()`` (escape to worker — handler will then drive + # ``ctx.receive`` to drain the body). Body bytes that arrive in + # the same packet land in ``_body_buf`` regardless; ``on_body`` + # decides whether to buffer or drop based on whether the handler + # is still expected to read. try: - result = self.handler(ctx) + self.handler(ctx) except BodyTooLarge: raise except Exception: self.selector.logger.exception("Handler raised for %s %r", req.method, req.target) emit_handler_error(ctx) - result = None - - if result is None: - # Either the handler completed inline OR a streaming - # pool dispatched and borrowed the conn. Detect the latter - # so on_body / on_message_complete know to populate the - # streaming body buffer (drained by the worker via - # ``ctx.receive``) instead of dropping bytes. - if not self.tracked: - self._streaming_active = True - return - # Continuation returned: we'll buffer the body and invoke it on - # ``on_message_complete``. If the client sent ``Expect: 100-continue``, - # tell them to actually send the body now. - self._continuation = result - if self._cur_expect_100 and not ctx._continue_sent: - try: - self.sock.sendall(b"HTTP/1.1 100 Continue\r\n\r\n") - object.__setattr__(ctx, "_continue_sent", True) - except OSError: - # Best-effort; the body recv loop will surface the failure. - pass + # ``Expect: 100-continue`` edge case: handler responded inline + # without ever sending the 100 Continue (so the client is still + # waiting and will never deliver the body). The parser would + # never observe EOM. Mark the message complete and force the + # conn closed after the response — matches nginx-style "send + # final response + Connection: close" behaviour. + if ( + not ctx.borrowed + and self._response_started + and self._cur_expect_100 + and not ctx._continue_sent + ): + self._close_after_response = True + self._message_complete = True def on_body(self, data: bytes) -> None: - if self._streaming_active: - # Streaming mode: append to the worker-drained buffer regardless - # of which thread is currently inside ``feed_data``. ``ctx.receive`` - # drains it (and pulls more bytes from the socket if needed). - new_total = len(self._streaming_body_buf) + len(data) - if new_total > self.selector.config.max_body_size: - self._body_too_large = new_total - return - self._streaming_body_buf += data + if self._cur_oversize: + return + # If the handler responded inline without borrowing, it's done — + # remaining body bytes won't be read (we'll close the conn after + # the response). Drop to keep the body buf bounded. + ctx = self._ctx + if self._response_started and ctx is not None and not ctx.borrowed: return - if self._continuation is None or self._cur_oversize: - return # no body wanted (handler done) or already oversize new_total = len(self._body_buf) + len(data) if new_total > self.selector.config.max_body_size: self._body_too_large = new_total @@ -287,23 +271,7 @@ def on_body(self, data: bytes) -> None: self._body_buf += data def on_message_complete(self) -> None: - if self._streaming_active: - self._streaming_eom = True - self._message_complete = True - return - if self._continuation is not None: - cont = self._continuation - self._continuation = None - ctx = self._ctx - assert ctx is not None - ctx.body = bytes(self._body_buf) - try: - cont(ctx) - except BodyTooLarge: - raise - except Exception: - self.selector.logger.exception("Body handler raised for %s %r", ctx.request.method, ctx.request.target) - emit_handler_error(ctx) + self._body_eom = True self._message_complete = True # ----- BaseHTTPConn surface ----- @@ -379,14 +347,11 @@ def _loop(self) -> None: def _reset_for_next_request(self) -> None: # Per-request parsing scratch is cleared by ``on_message_begin``. self._ctx = None - self._continuation = None self._body_buf = bytearray() + self._body_eom = False self._message_complete = False self._response_started = False self._close_after_response = False - self._streaming_active = False - self._streaming_body_buf = bytearray() - self._streaming_eom = False self._deferred_streaming_dispatch = None self.idle = True self.close_at = time.monotonic() + self.selector.config.keep_alive_timeout @@ -437,7 +402,6 @@ class _HTTPReqCtx: _expect_100_continue: bool = False _keep_alive: bool = True - body: bytes = b"" response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) _disconnected: bool = False @@ -491,16 +455,14 @@ def borrow(self) -> Iterator[_HTTPReqCtx]: self._maybe_give_back() def _defer_streaming_dispatch(self, dispatcher: Callable[[_HTTPReqCtx], None]) -> None: - """Start a streaming worker after the current parser feed returns. + """Start a worker after the current parser feed returns. httptools fires callbacks from inside ``parser.feed_data``. Starting the worker directly from ``on_headers_complete`` would let it call ``ctx.receive`` and re-enter the parser while the selector thread is - still inside the same feed. Instead, mark streaming active now so any - body bytes from the current packet are buffered by ``on_body``, then - let ``_feed`` run the handoff once callbacks are done. + still inside the same feed. Instead we stash the dispatcher here and + let ``_feed`` run it once parser callbacks are done. """ - self.conn._streaming_active = True self.conn._deferred_streaming_dispatch = lambda: dispatcher(self) def _maybe_give_back(self) -> None: @@ -564,20 +526,16 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) self.finish_response() def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - """Streaming-read API. - - Two paths: - - - **Streaming route** (``buffer_body=False``, ``conn._streaming_active``): - parser-driven, same shape as h11. ``on_body`` callbacks (fired - from ``parser.feed_data`` calls on either thread) populate - ``conn._streaming_body_buf``; this method drains it. Pulls more - bytes from the socket and feeds them through the parser if the - buffer is empty and EOM hasn't fired. - - **Buffered route**: the body has already been buffered into - ``ctx.body`` before this body handler ran. ``ctx.receive`` is - the legacy fallback path (rare); it just calls ``sock.recv`` - for callers that did a hand-rolled ``borrow()``. + """Streaming-read API: pull up to ``size`` bytes of the request + body off the wire (or ``b""`` at EOM / peer FIN). + + Sends ``100 Continue`` lazily if the client is awaiting it. Pulls + more bytes from the socket and feeds them through the parser + (``on_body`` populates ``conn._body_buf``, ``on_message_complete`` + flips ``conn._body_eom``) when the buffer is empty. + + Use :func:`localpost.http.read_body` for the "give me the whole + body" common case. """ if self._expect_100_continue and not self._continue_sent: self._send_continue() @@ -585,47 +543,35 @@ def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: conn = self.conn rw = self.selector.config.rw_timeout - if conn._streaming_active: - while not conn._streaming_body_buf and not conn._streaming_eom: + while not conn._body_buf and not conn._body_eom: + try: + data = conn.sock.recv(DEFAULT_BUFFER_SIZE) + except BlockingIOError: + conn.sock.settimeout(rw) try: data = conn.sock.recv(DEFAULT_BUFFER_SIZE) - except BlockingIOError: - conn.sock.settimeout(rw) - try: - data = conn.sock.recv(DEFAULT_BUFFER_SIZE) - finally: - # On a borrowed conn keep the socket blocking-with-timeout - # for the rest of the request; subsequent ``recv`` calls - # in this loop (and in later sends) skip the - # BlockingIOError path entirely. The give-back path - # resets the socket on hand-back. On the selector thread - # we restore inline. - if conn.tracked: - conn.sock.settimeout(0) - if not data: - break # peer FIN mid-body - # Feed through the parser; ``on_body`` populates the - # streaming buffer, ``on_message_complete`` flips EOM. - # Errors (BodyTooLarge / _ProtocolError) propagate to the - # caller; the worker pool's exception handler emits a 500. - conn._feed(data) - if conn._streaming_body_buf: - n = min(size, len(conn._streaming_body_buf)) - chunk = bytes(conn._streaming_body_buf[:n]) - del conn._streaming_body_buf[:n] - return chunk - return b"" - - # Buffered route fallback (hand-rolled borrow): raw recv. - try: - return conn.sock.recv(size) - except BlockingIOError: - conn.sock.settimeout(rw) - try: - return conn.sock.recv(size) - finally: - if conn.tracked: - conn.sock.settimeout(0) + finally: + # On a borrowed conn keep the socket blocking-with-timeout + # for the rest of the request; subsequent ``recv`` calls + # in this loop (and in later sends) skip the + # BlockingIOError path entirely. The give-back path + # resets the socket on hand-back. On the selector thread + # we restore inline. + if conn.tracked: + conn.sock.settimeout(0) + if not data: + break # peer FIN mid-body + # Feed through the parser; ``on_body`` populates ``_body_buf``, + # ``on_message_complete`` flips ``_body_eom``. Errors + # (BodyTooLarge / _ProtocolError) propagate to the caller; + # the worker pool's exception handler emits a 500. + conn._feed(data) + if conn._body_buf: + n = min(size, len(conn._body_buf)) + chunk = bytes(conn._body_buf[:n]) + del conn._body_buf[:n] + return chunk + return b"" def _send_continue(self) -> None: _send_all(self.conn, b"HTTP/1.1 100 Continue\r\n\r\n") diff --git a/localpost/http/static.py b/localpost/http/static.py index 8b93dad..341fe29 100644 --- a/localpost/http/static.py +++ b/localpost/http/static.py @@ -6,10 +6,11 @@ wrapped in :func:`localpost.http.thread_pool_handler` with its own concurrency budget so slow clients can't pin API workers. -Pre-body decisions (404 / 405 / 304 / 416, plus HEAD success) run inline -on the selector via :meth:`HTTPReqCtx.complete`. GET 200 / 206 returns a -:data:`BodyHandler` that opens the file and ``sendfile`` s it on the -worker thread. +Header-only decisions (404 / 405 / 304 / 416, plus HEAD success) finish +inline via :meth:`HTTPReqCtx.complete`. GET 200 / 206 opens the file and +calls :meth:`HTTPReqCtx.sendfile` — when the handler is wrapped in +:func:`localpost.http.thread_pool_handler` (the recommended composition) +this happens on a worker thread. """ from __future__ import annotations @@ -23,7 +24,7 @@ from typing import Final, Literal from urllib.parse import unquote_to_bytes -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._base import HTTPReqCtx, RequestHandler from localpost.http._types import Response __all__ = ["static_handler"] @@ -55,10 +56,11 @@ def static_handler( ``None`` disables directory → index resolution (returns 404). Returns: - A :data:`RequestHandler`. For 200 / 206 GET, returns a - :data:`BodyHandler` so a wrapping :func:`thread_pool_handler` - can dispatch the ``sendfile`` to a worker. Other outcomes - complete inline on the selector and return ``None``. + A :data:`RequestHandler`. For 200 / 206 GET, opens the file and + calls :meth:`HTTPReqCtx.sendfile` — wrap with + :func:`thread_pool_handler` so the syscall runs on a worker + thread. Header-only outcomes (404 / 405 / 304 / 416 / HEAD) + finish inline. Example:: @@ -74,7 +76,7 @@ def static_handler( raise ValueError(f"root must be a directory: {root_path}") cc_bytes = cache_control.encode("ascii") if cache_control is not None else None - def handle(ctx: HTTPReqCtx) -> BodyHandler | None: + def handle(ctx: HTTPReqCtx) -> None: method = ctx.request.method if method not in (b"GET", b"HEAD"): # Always include the body — the caller used a non-HEAD method. @@ -89,26 +91,26 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: ), _METHOD_NOT_ALLOWED_BODY, ) - return None + return url_path = ctx.request.path if not url_path.startswith(prefix): _send_not_found(ctx, method) - return None + return target = _resolve(root_path, url_path[len(prefix) :], index=index) if target is None: _send_not_found(ctx, method) - return None + return try: st = target.stat() except (FileNotFoundError, NotADirectoryError, PermissionError): _send_not_found(ctx, method) - return None + return if not S_ISREG(st.st_mode): _send_not_found(ctx, method) - return None + return size = st.st_size etag = _etag(st) @@ -120,10 +122,10 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: ims = headers.get(b"if-modified-since") if inm is not None and _if_none_match_matches(inm, etag): _send_not_modified(ctx, etag, last_modified, cc_bytes) - return None + return if inm is None and ims is not None and _if_modified_since_satisfied(ims, st.st_mtime): _send_not_modified(ctx, etag, last_modified, cc_bytes) - return None + return # Range — single byte-range only. Multi-range / unparseable falls back to 200. offset = 0 @@ -134,7 +136,7 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: parsed = _parse_range(rng, size) if parsed == "unsatisfiable": _send_range_not_satisfiable(ctx, method, size, etag, last_modified) - return None + return if parsed is not None: offset, length = parsed status = 206 @@ -156,39 +158,14 @@ def handle(ctx: HTTPReqCtx) -> BodyHandler | None: if method == b"HEAD" or length == 0: ctx.complete(response) - return None + return - return _SendFileBody(target, response, offset, length) + with target.open("rb") as f: + ctx.sendfile(response, f, offset, length) return handle -# -------------------------------------------------------------------------- -# Body handler — sendfile on the worker thread -# -------------------------------------------------------------------------- - - -class _SendFileBody: - """Body handler closure: open + :meth:`HTTPReqCtx.sendfile` + finish. - - Spelled as a callable class (not a closure) so its captured state is - visible in repr — same convention as the dispatch-chain callables in - :mod:`localpost.http._base`. - """ - - __slots__ = ("length", "offset", "path", "response") - - def __init__(self, path: Path, response: Response, offset: int, length: int) -> None: - self.path = path - self.response = response - self.offset = offset - self.length = length - - def __call__(self, ctx: HTTPReqCtx) -> None: - with self.path.open("rb") as f: - ctx.sendfile(self.response, f, self.offset, self.length) - - # -------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------- diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index 49c3734..dadc113 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -10,7 +10,8 @@ from urllib.parse import unquote_to_bytes from wsgiref.types import WSGIApplication -from localpost.http._base import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http._base import HTTPReqCtx, RequestHandler +from localpost.http._body import read_body from localpost.http._types import Request from localpost.http._types import Response as _Response from localpost.http.config import DEFAULT_BUFFER_SIZE @@ -20,10 +21,12 @@ @final class _RequestBodyStream(RawIOBase): - """Expose the buffered ``HTTPReqCtx.body`` as ``wsgi.input``. + """Expose buffered request bytes as a ``wsgi.input`` file-like. - The body has already been read by the selector before the WSGI app - runs, so this is a thin slicing view — no I/O, no ``recv``. + :func:`wrap_wsgi` reads the full body via + :func:`localpost.http.read_body` before driving the WSGI app, then + wraps the resulting ``bytes`` here — WSGI apps see a synchronous + ``read`` / ``readinto`` API backed by an in-memory buffer. """ def __init__(self, body: bytes) -> None: @@ -68,13 +71,21 @@ def _wsgi_write_unsupported(_: bytes) -> None: def wrap_wsgi(app: WSGIApplication) -> RequestHandler: """Wrap a WSGI application as a native :class:`RequestHandler`. - WSGI apps may consume ``wsgi.input`` from any route, so the wrapper - always returns a :data:`BodyHandler` continuation — the selector - buffers the request body into ``ctx.body`` before the WSGI app runs. + The handler reads the full request body via + :func:`localpost.http.read_body`, exposes it to the WSGI app as + ``wsgi.input``, and translates the WSGI response into a + :meth:`HTTPReqCtx.stream` call (the WSGI body iterable is already + a chunk iterator). + + Reading the body blocks on the request socket, so this handler + **must** be composed with :func:`localpost.http.thread_pool_handler` + (or run inside an explicit ``ctx.borrow()`` block) — running it on + the selector thread will stall the loop while the upload finishes. """ def run_wsgi(ctx: HTTPReqCtx) -> None: - environ = _build_environ(ctx) + body = read_body(ctx) + environ = _build_environ(ctx, body) response_state: dict[str, Any] = {} @@ -129,13 +140,10 @@ def chunks() -> Iterator[bytes]: if close is not None: close() - def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: - return run_wsgi - - return pre_body + return run_wsgi -def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: +def _build_environ(ctx: HTTPReqCtx, body: bytes) -> dict[str, Any]: request = ctx.request # ``request.path`` / ``request.query_string`` are pre-split by the # backend; only the percent-decode + ISO-8859-1 decode happens here. @@ -158,7 +166,7 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: "SERVER_PROTOCOL": f"HTTP/{request.http_version.decode('ascii')}", "wsgi.version": (1, 0), "wsgi.url_scheme": ctx.scheme, - "wsgi.input": _RequestBodyStream(ctx.body), + "wsgi.input": _RequestBodyStream(body), "wsgi.errors": sys.stderr, "wsgi.multithread": True, "wsgi.multiprocess": False, @@ -198,10 +206,10 @@ def _build_environ(ctx: HTTPReqCtx) -> dict[str, Any]: class _WSGIReqCtx: """:class:`HTTPReqCtx` implementation backed by a WSGI ``environ``. - Body is read from ``wsgi.input`` and pre-buffered into ``ctx.body`` - before the handler runs (matches the JSON-API common case the native - server is tuned for). ``receive`` slices the buffered body for any - handler that wants chunked reads. + Body bytes are read lazily — :meth:`receive` calls ``wsgi.input.read(size)`` + on demand. The bridge does not pre-buffer; WSGI servers are usually + multi-thread / multi-process worker pools, so synchronous reads are + safe. Three response shapes are supported, all via the public :class:`HTTPReqCtx` Protocol: @@ -219,14 +227,12 @@ class _WSGIReqCtx: """ request: Request - body: bytes remote_addr: str | None local_addr: str scheme: str _environ: dict[str, Any] response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) - _body_cursor: int = 0 # Response capture state — at most one of these is populated. _completed: tuple[_Response, bytes] | None = None @@ -248,12 +254,11 @@ def borrow(self) -> contextlib.AbstractContextManager[HTTPReqCtx]: return contextlib.nullcontext(self) def receive(self, size: int = DEFAULT_BUFFER_SIZE, /) -> bytes: - if self._body_cursor >= len(self.body): + stream = self._environ.get("wsgi.input") + if stream is None: return b"" - end = self._body_cursor + size - chunk = self.body[self._body_cursor : end] - self._body_cursor += len(chunk) - return chunk + chunk = stream.read(size) + return chunk if chunk else b"" # ----- response capture ----- @@ -343,9 +348,7 @@ def hello(name: str) -> str: def wsgi_app(environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: ctx = _build_wsgi_ctx(environ) - body_handler = handler(ctx) - if body_handler is not None: - body_handler(ctx) + handler(ctx) return ctx._respond(start_response) return wsgi_app @@ -381,8 +384,6 @@ def _build_wsgi_ctx(environ: dict[str, Any]) -> _WSGIReqCtx: http_version=http_version, ) - body = _read_body(environ) - server_host = str(environ.get("SERVER_NAME", "")) server_port = str(environ.get("SERVER_PORT", "")) local_addr = f"{server_host}:{server_port}" if server_port else server_host @@ -396,7 +397,6 @@ def _build_wsgi_ctx(environ: dict[str, Any]) -> _WSGIReqCtx: return _WSGIReqCtx( request=request, - body=body, remote_addr=remote_addr, local_addr=local_addr, scheme=str(environ.get("wsgi.url_scheme", "http")), @@ -404,20 +404,6 @@ def _build_wsgi_ctx(environ: dict[str, Any]) -> _WSGIReqCtx: ) -def _read_body(environ: dict[str, Any]) -> bytes: - cl_raw = environ.get("CONTENT_LENGTH") or "" - try: - cl = int(cl_raw) if cl_raw else 0 - except ValueError: - cl = 0 - if cl <= 0: - return b"" - stream = environ.get("wsgi.input") - if stream is None: - return b"" - return stream.read(cl) - - def _status_line(response: _Response) -> str: if response.reason: reason = response.reason.decode("iso-8859-1") diff --git a/localpost/openapi/aio/operation.py b/localpost/openapi/aio/operation.py index 805aba3..a4c82d8 100644 --- a/localpost/openapi/aio/operation.py +++ b/localpost/openapi/aio/operation.py @@ -16,6 +16,7 @@ from http import HTTPMethod from typing import Any, Self +from localpost.http import aread_body from localpost.http._types import Response as _Response from localpost.http.router import URITemplate from localpost.openapi import spec as openapi_spec @@ -43,8 +44,10 @@ from localpost.openapi.aio.middleware import AsyncApiOperation, AsyncOpMiddleware from localpost.openapi.aio.sse import async_iter_events from localpost.openapi.resolvers import ( + BODY_CACHE_KEY, ArgResolver, ArgResolverFactory, + FromBody, FromPath, ) from localpost.openapi.results import EventStreamResult, NotFound, Ok, OpResult @@ -78,6 +81,11 @@ class AsyncOperation: operation_id: str description: str adapters: AdapterRegistry + needs_body: bool + """``True`` iff at least one resolved parameter is a :class:`FromBody` + factory. The runtime pre-buffers the request body via + :func:`localpost.http.aread_body` and stashes it in + ``ctx.attrs[BODY_CACHE_KEY]`` before invoking resolvers.""" @classmethod def create( @@ -134,6 +142,8 @@ def create( description = doc[len(summary) :].lstrip("\n") if doc else "" op_id = _make_operation_id(method, path, fn) + needs_body = any(isinstance(factory, FromBody) for _, _, factory in arg_factories) + return cls( method=method, path=path, @@ -148,6 +158,7 @@ def create( operation_id=op_id, description=description, adapters=registry, + needs_body=needs_body, ) # ----- runtime ----- @@ -162,6 +173,8 @@ async def run(self, ctx: AsyncHTTPReqCtx) -> None: await self._write_response(ctx, result) async def _run_core(self, ctx: AsyncHTTPReqCtx) -> OpResult: + if self.needs_body and BODY_CACHE_KEY not in ctx.attrs: + ctx.attrs[BODY_CACHE_KEY] = await aread_body(ctx) kwargs: dict[str, object] = {} for name, resolver in self.arg_resolvers: value = resolver(ctx) diff --git a/localpost/openapi/operation.py b/localpost/openapi/operation.py index f49f804..8f83c41 100644 --- a/localpost/openapi/operation.py +++ b/localpost/openapi/operation.py @@ -15,7 +15,7 @@ from http import HTTPMethod from typing import Any, Self -from localpost.http import BodyHandler, HTTPReqCtx, RequestHandler +from localpost.http import HTTPReqCtx, RequestHandler, read_body from localpost.http._types import Response as _Response from localpost.http.router import URITemplate from localpost.openapi import spec as openapi_spec @@ -38,8 +38,10 @@ from localpost.openapi.adapters import AdapterRegistry, default_registry from localpost.openapi.middleware import ApiOperation, OpMiddleware from localpost.openapi.resolvers import ( + BODY_CACHE_KEY, ArgResolver, ArgResolverFactory, + FromBody, FromPath, ) from localpost.openapi.results import EventStreamResult, NotFound, Ok, OpResult @@ -85,6 +87,12 @@ class Operation: """Type adapters used at request time for body decode and response encode. Threaded down from :class:`HttpApp`.""" + needs_body: bool + """``True`` iff at least one resolved parameter is a :class:`FromBody` + factory. The runtime pre-buffers the request body via + :func:`localpost.http.read_body` and stashes it in + ``ctx.attrs[BODY_CACHE_KEY]`` before invoking resolvers.""" + @classmethod def create( cls, @@ -136,6 +144,8 @@ def create( description = doc[len(summary) :].lstrip("\n") if doc else "" op_id = _make_operation_id(method, path, fn) + needs_body = any(isinstance(factory, FromBody) for _, _, factory in arg_factories) + return cls( method=method, path=path, @@ -150,6 +160,7 @@ def create( operation_id=op_id, description=description, adapters=registry, + needs_body=needs_body, ) # ----- runtime ----- @@ -157,18 +168,16 @@ def create( def as_handler(self) -> RequestHandler: """Build the ``RequestHandler`` registered with the router. - The pre-body phase returns a ``BodyHandler`` so that - :func:`localpost.http.thread_pool_handler` offloads execution to a - worker after the request body is buffered. The body handler runs the - full operation pipeline: middleware chain → arg resolvers → user fn → - response build → ``ctx.complete``. + Runs the full operation pipeline in one shot: middleware chain → + arg resolvers → user fn → response build → ``ctx.complete``. + Operations that consume the request body (parameters with a + :class:`FromBody` factory) call :func:`localpost.http.read_body` + from inside :meth:`_run_core` — the conn must therefore be on a + worker thread (composed via + :func:`localpost.http.thread_pool_handler`) before this handler + runs. """ - run = self._run - - def pre_body(_ctx: HTTPReqCtx) -> BodyHandler: - return run - - return pre_body + return self._run def _run(self, ctx: HTTPReqCtx) -> None: chain: ApiOperation = self._run_core @@ -182,6 +191,8 @@ def _run_core(self, ctx: HTTPReqCtx) -> OpResult: an :class:`OpResult`. SSE-shaped returns (generator, iterator, :class:`EventStream`) are wrapped in :class:`EventStreamResult` so middleware can post-process / wrap the stream uniformly.""" + if self.needs_body and BODY_CACHE_KEY not in ctx.attrs: + ctx.attrs[BODY_CACHE_KEY] = read_body(ctx) kwargs: dict[str, object] = {} for name, resolver in self.arg_resolvers: value = resolver(ctx) diff --git a/localpost/openapi/resolvers.py b/localpost/openapi/resolvers.py index 7fa198b..a59712a 100644 --- a/localpost/openapi/resolvers.py +++ b/localpost/openapi/resolvers.py @@ -46,6 +46,12 @@ # this request, so multiple FromQuery resolvers don't re-parse. _QUERY_CACHE_KEY = "__lp_openapi_query__" +# Sentinel — the operation layer pre-buffers the request body into +# ``ctx.attrs[_BODY_CACHE_KEY]`` when at least one parameter is a +# ``FromBody`` resolver. The framework decides whether to call it (sync: +# ``read_body``, async: ``aread_body``); resolvers just read from cache. +BODY_CACHE_KEY = "__lp_openapi_body__" + @runtime_checkable class ResolverCtx(Protocol): @@ -53,13 +59,18 @@ class ResolverCtx(Protocol): Both :class:`localpost.http.HTTPReqCtx` (sync) and :class:`localpost.openapi.aio.AsyncHTTPReqCtx` (async) are - structurally compatible — they both expose ``request`` / ``body`` / - ``attrs`` synchronously. Restricting :class:`ArgResolver` to this - minimal protocol means the same factories work in both flavours. + structurally compatible — they both expose ``request`` / ``attrs`` + synchronously. Restricting :class:`ArgResolver` to this minimal + protocol means the same factories work in both flavours. + + The body itself is *not* on this Protocol — the wire-level Protocols + only expose ``await ctx.receive(size)``, which an :class:`ArgResolver` + can't drive (sync resolver, async ctx). Operations pre-buffer the + body into ``ctx.attrs[BODY_CACHE_KEY]`` before resolvers run; the + :class:`FromBody` resolver reads from there. """ request: Request - body: bytes attrs: dict[Any, Any] @@ -386,8 +397,9 @@ def _adapter_decode(body: bytes, _t: Any) -> object: converter = _adapter_decode def resolve(ctx: ResolverCtx) -> object | OpResult: + body = ctx.attrs.get(BODY_CACHE_KEY, b"") try: - return converter(ctx.body, target) + return converter(body, target) except validation_errors as exc: return BadRequest(f"Invalid request body: {exc}") except (ValueError, TypeError) as exc: diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index b0e1b8b..8e71455 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -21,7 +21,6 @@ def _build_handler(): mode = os.environ.get("LP_TEST_MODE", "router") if mode == "router": from localpost.http import ( # noqa: PLC0415 - BodyHandler, HTTPReqCtx, Response, Routes, @@ -40,20 +39,15 @@ def _emit(ctx: HTTPReqCtx, body: bytes) -> None: body, ) - def _ping(ctx: HTTPReqCtx) -> BodyHandler | None: + def _ping(ctx: HTTPReqCtx) -> None: _emit(ctx, b"pong") - return None - def _slow(ctx: HTTPReqCtx) -> BodyHandler | None: - def post_body(ctx: HTTPReqCtx) -> None: - time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) - _emit(ctx, str(threading.get_ident()).encode()) + def _slow(ctx: HTTPReqCtx) -> None: + time.sleep(float(os.environ.get("LP_TEST_SLOW_S", "0.2"))) + _emit(ctx, str(threading.get_ident()).encode()) - return post_body - - def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: + def _hello(ctx: HTTPReqCtx) -> None: _emit(ctx, f"hi {route_match(ctx).path_args['name']}".encode()) - return None routes = Routes() routes.get("/ping")(_ping) diff --git a/tests/http/acceptor_stress.py b/tests/http/acceptor_stress.py index 89c8a05..ece3d39 100644 --- a/tests/http/acceptor_stress.py +++ b/tests/http/acceptor_stress.py @@ -25,7 +25,6 @@ from localpost.hosting import serve from localpost.http import ( - BodyHandler, HTTPReqCtx, Response, ServerConfig, @@ -37,7 +36,7 @@ def _capturing_handler(seen: Counter, lock: threading.Lock): - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: with lock: seen[threading.get_ident()] += 1 ctx.complete( diff --git a/tests/http/app.py b/tests/http/app.py index ec7cdaa..34bc3d5 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -22,13 +22,13 @@ from localpost.hosting import ServiceLifetimeView, serve from localpost.http import ( - BodyHandler, HTTPReqCtx, Middleware, RequestHandler, Response, ServerConfig, http_server, + read_body, start_http_server, ) from localpost.http.app import HttpApp @@ -193,7 +193,7 @@ async def test_ctx_inject(self, free_port): @app.post("/echo") def echo(ctx: HTTPReqCtx): - return ctx.body + return read_body(ctx) cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_app(app, cfg) as lt: @@ -210,7 +210,7 @@ async def test_ctx_and_path_arg(self, free_port): @app.post("/{name}/profile") def update_profile(ctx: HTTPReqCtx, name: str): - payload = json.loads(ctx.body) + payload = json.loads(read_body(ctx)) return {"name": name, "payload": payload} cfg = ServerConfig(host="127.0.0.1", port=free_port) @@ -239,12 +239,12 @@ def bad(unresolved: str): def _add_marker(value: str) -> Middleware: - """Pre-body middleware: writes a marker into ctx.attrs.""" + """Middleware: writes a marker into ctx.attrs before delegating.""" def mw(inner: RequestHandler) -> RequestHandler: - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + def wrapped(ctx: HTTPReqCtx) -> None: ctx.attrs.setdefault("markers", []).append(value) - return inner(ctx) + inner(ctx) return wrapped @@ -253,7 +253,7 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: def _short_circuit_with_status(status: int, body: bytes) -> Middleware: def mw(inner: RequestHandler) -> RequestHandler: - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + def wrapped(ctx: HTTPReqCtx) -> None: ctx.complete( Response( status_code=status, @@ -261,7 +261,7 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: ), body, ) - return None # do not call inner + # Do not call inner. return wrapped @@ -275,17 +275,8 @@ async def test_app_level_middleware_runs(self, free_port): def capturing_mw(inner): def wrapped(ctx): captured.append("before") - result = inner(ctx) - if result is None: - captured.append("after-inline") - return None - - # wrap continuation - def post(ctx): - result(ctx) - captured.append("after-body") - - return post + inner(ctx) + captured.append("after") return wrapped @@ -304,7 +295,7 @@ def hello(name: str): lt.shutdown() await lt.stopped - assert captured == ["before", "after-body"] + assert captured == ["before", "after"] async def test_app_middleware_short_circuits_pre_body(self, free_port): app = HttpApp(middleware=[_short_circuit_with_status(401, b"unauthorized")]) @@ -354,7 +345,7 @@ async def test_attrs_propagate_pre_to_post_body(self, free_port): def echo(ctx: HTTPReqCtx): return { "markers": ctx.attrs.get("markers", []), - "body": ctx.body.decode("utf-8"), + "body": read_body(ctx).decode("utf-8"), } cfg = ServerConfig(host="127.0.0.1", port=free_port) @@ -500,7 +491,7 @@ async def test_streaming_route_reads_body_in_handler(self, free_port, http_backe app = HttpApp() - @app.post("/upload", buffer_body=False) + @app.post("/upload") def upload(ctx: HTTPReqCtx): chunks: list[bytes] = [] while True: @@ -531,7 +522,7 @@ def upload(ctx: HTTPReqCtx): async def test_streaming_route_oversized_chunked_body_returns_413(self, free_port, http_backend): app = HttpApp() - @app.post("/upload", buffer_body=False) + @app.post("/upload") def upload(ctx: HTTPReqCtx): while ctx.receive(8192): pass @@ -565,7 +556,7 @@ async def test_streaming_route_body_across_multiple_recvs_httptools(self, free_p app = HttpApp() - @app.post("/upload", buffer_body=False) + @app.post("/upload") def upload(ctx: HTTPReqCtx): chunks: list[bytes] = [] while True: @@ -616,8 +607,8 @@ def handler(ctx: HTTPReqCtx): def dispatch(req_ctx: HTTPReqCtx) -> None: conn = cast(Any, req_ctx).conn conn.selector.stop_tracking(conn) - captured["buffer_before_receive"] = bytes(conn._streaming_body_buf) - captured["eom_before_receive"] = conn._streaming_eom + captured["buffer_before_receive"] = bytes(conn._body_buf) + captured["eom_before_receive"] = conn._body_eom chunks: list[bytes] = [] while True: chunk = req_ctx.receive(8192) @@ -666,7 +657,7 @@ async def test_streaming_then_keep_alive_request_httptools(self, free_port): app = HttpApp() - @app.post("/upload", buffer_body=False) + @app.post("/upload") def upload(ctx: HTTPReqCtx): total = 0 while True: @@ -716,19 +707,6 @@ def hit() -> bytes: assert captured == ["upload:1024", "ping"] - def test_streaming_route_with_pool_disabled_raises(self): - """Registering a streaming route on an HttpApp with ``pooled=False`` - raises ``RuntimeError`` when the dispatcher is built.""" - app = HttpApp(pooled=False) - - @app.post("/upload", buffer_body=False) - def upload(ctx: HTTPReqCtx): # pragma: no cover - return "x" - - assert upload is not None - # Buffered routes work fine without a pool, but streaming ones don't. - with pytest.raises(RuntimeError, match="streaming route"): - app._build_router_handler(None) # Avoid "imported but unused" lints — the helper is part of the public smoke API. diff --git a/tests/http/asgi.py b/tests/http/asgi.py index 729d7b1..d0d83f8 100644 --- a/tests/http/asgi.py +++ b/tests/http/asgi.py @@ -10,16 +10,17 @@ from __future__ import annotations -import threading from collections.abc import AsyncIterator from typing import Any +import anyio import httpx import pytest from localpost.http import ( AsyncHTTPReqCtx, Response, + aread_body, to_asgi, ) from localpost.http._types import Request @@ -34,19 +35,29 @@ def _build_ctx( body: bytes = b"", send=None, # type: ignore[no-untyped-def] ) -> _ASGIReqCtx: - """Build a minimal _ASGIReqCtx for unit-level checks.""" + """Build a minimal _ASGIReqCtx for unit-level checks. + + Pre-fills the body channel with ``body`` and closes it — the ctx + behaves as if the bridge had received exactly those bytes from + upstream and observed EOM. + """ async def _swallow(_event: dict[str, Any]) -> None: pass + body_send, body_recv = anyio.create_memory_object_stream[bytes](max_buffer_size=64) + if body: + body_send.send_nowait(body) + body_send.close() + return _ASGIReqCtx( request=request or Request(b"GET", b"/", b"/", b"", []), - body=body, remote_addr=None, local_addr="0.0.0.0:0", scheme="http", _send=send or _swallow, - _disconnected=threading.Event(), + _disconnected=anyio.Event(), + _body_stream=body_recv, ) @@ -164,11 +175,12 @@ async def chunks() -> AsyncIterator[bytes]: class TestCtxReceive: - """``ctx.receive(size)`` slices the pre-buffered body — same shape - as the sync :class:`_WSGIReqCtx.receive` helper.""" + """``ctx.receive(size)`` pulls from the in-process body queue, + splitting upstream chunks down to the caller's requested ``size``. + """ @pytest.mark.anyio - async def test_receive_slices_buffer(self) -> None: + async def test_receive_splits_chunk_across_calls(self) -> None: ctx = _build_ctx(body=b"abcdef") assert await ctx.receive(2) == b"ab" assert await ctx.receive(3) == b"cde" @@ -196,11 +208,11 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: assert resp.content == b"hi" @pytest.mark.anyio - async def test_body_pre_buffered(self) -> None: + async def test_body_read_via_aread_body(self) -> None: captured: dict[str, bytes] = {} async def handler(ctx: AsyncHTTPReqCtx) -> None: - captured["body"] = ctx.body + captured["body"] = await aread_body(ctx) await ctx.complete(Response(200), b"ok") asgi_app = to_asgi(handler) @@ -209,9 +221,9 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: assert captured["body"] == b"payload" @pytest.mark.anyio - async def test_body_too_large_returns_413(self) -> None: + async def test_content_length_pre_check_413(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: - await ctx.complete(Response(200), b"unreached") + raise AssertionError("handler should not run for over-cap requests") asgi_app = to_asgi(handler, max_body_size=4) async with _client(asgi_app) as c: @@ -253,16 +265,15 @@ async def send(_event: dict[str, Any]) -> None: await asgi_app(scope, receive, send) -class TestToAsgiStreaming: - """``to_asgi(handler, streaming=True)`` skips the pre-buffer; the - handler reads body chunks via ``await ctx.receive(size)``.""" +class TestToAsgiReceive: + """End-to-end body-streaming: handlers pull chunks via + ``await ctx.receive(size)``.""" @pytest.mark.anyio - async def test_body_reads_chunks_in_order(self) -> None: + async def test_receive_drains_in_order(self) -> None: captured: list[bytes] = [] async def handler(ctx: AsyncHTTPReqCtx) -> None: - assert ctx.body == b"" # streaming mode: no pre-buffer while True: chunk = await ctx.receive(64) if not chunk: @@ -270,7 +281,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: captured.append(chunk) await ctx.complete(Response(200), b"ok") - asgi_app = to_asgi(handler, streaming=True) + asgi_app = to_asgi(handler) async with _client(asgi_app) as c: resp = await c.post("/", content=b"hello-streaming-world") assert resp.status_code == 200 @@ -290,31 +301,21 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: sizes.append(len(chunk)) await ctx.complete(Response(200), b"ok") - asgi_app = to_asgi(handler, streaming=True) + asgi_app = to_asgi(handler) async with _client(asgi_app) as c: await c.post("/", content=b"abcdefghij") assert all(s <= 3 for s in sizes) assert sum(sizes) == 10 - @pytest.mark.anyio - async def test_content_length_pre_check_413(self) -> None: - async def handler(ctx: AsyncHTTPReqCtx) -> None: - raise AssertionError("handler should not run") - - asgi_app = to_asgi(handler, streaming=True, max_body_size=4) - async with _client(asgi_app) as c: - resp = await c.post("/", content=b"too-long-by-far") - assert resp.status_code == 413 - @pytest.mark.anyio async def test_handler_can_skip_body_and_respond(self) -> None: - """Streaming mode: handler may respond without reading the body — - the channel pump drains stragglers; the response still completes.""" + """Handler may respond without reading the body — the channel + pump drains stragglers; the response still completes.""" async def handler(ctx: AsyncHTTPReqCtx) -> None: await ctx.complete(Response(204), b"") - asgi_app = to_asgi(handler, streaming=True) + asgi_app = to_asgi(handler) async with _client(asgi_app) as c: resp = await c.post("/", content=b"ignored-body") assert resp.status_code == 204 diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index 4cced53..36a4140 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -6,7 +6,7 @@ import httpx -from localpost.http import HTTPReqCtx, Response, ServerConfig +from localpost.http import HTTPReqCtx, Response, ServerConfig, read_body from tests.http._helpers import drain_socket, read_http_response, read_until @@ -33,12 +33,22 @@ def test_simple_get(serve_backend_in_thread): assert r.text == "hi" -def test_buffered_post_body_handler(serve_backend_in_thread): +def test_buffered_post_body_handler(serve_backend_in_thread, http_backend): + if http_backend == "httptools": + # ``read_body`` from on-selector handlers re-enters httptools' + # ``parser.feed_data`` callback chain — only safe via a worker + # pool. The pooled equivalent lives in ``tests/http/service.py``; + # here we just exercise the h11 path. + import pytest as _pytest # noqa: PLC0415 + + _pytest.skip("httptools requires worker-pool composition for body reads") + received: list[bytes] = [] - def body_handler(ctx: HTTPReqCtx) -> None: - received.append(ctx.body) - out = b"echo:" + ctx.body + def handler(ctx: HTTPReqCtx) -> None: + body = read_body(ctx) + received.append(body) + out = b"echo:" + body ctx.complete( Response( status_code=200, @@ -50,9 +60,6 @@ def body_handler(ctx: HTTPReqCtx) -> None: out, ) - def handler(_ctx: HTTPReqCtx): - return body_handler - with serve_backend_in_thread(handler) as port: r = httpx.post(f"http://127.0.0.1:{port}/x", content=b"hello world", timeout=2) assert r.status_code == 200 @@ -104,9 +111,17 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"HTTP/1.1 400" in data, data -def test_expect_100_continue(serve_backend_in_thread): - def body_handler(ctx: HTTPReqCtx) -> None: - out = b"got:" + ctx.body +def test_expect_100_continue(serve_backend_in_thread, http_backend): + if http_backend == "httptools": + import pytest as _pytest # noqa: PLC0415 + + _pytest.skip("httptools requires worker-pool composition for body reads") + + def handler(ctx: HTTPReqCtx) -> None: + # ``read_body`` drives the body recv loop, which sends 100 Continue + # when the parser sees the client is awaiting it. + body = read_body(ctx) + out = b"got:" + body ctx.complete( Response( 200, @@ -118,9 +133,6 @@ def body_handler(ctx: HTTPReqCtx) -> None: out, ) - def handler(_ctx: HTTPReqCtx): - return body_handler - with serve_backend_in_thread(handler) as port: with socket.create_connection(("127.0.0.1", port), timeout=3) as sock: sock.sendall(b"POST /x HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") diff --git a/tests/http/compress.py b/tests/http/compress.py index d9d8a77..b19fab9 100644 --- a/tests/http/compress.py +++ b/tests/http/compress.py @@ -12,7 +12,6 @@ import pytest from localpost.http import ( - BodyHandler, HTTPReqCtx, Response, compress_handler, @@ -264,7 +263,7 @@ def _emit(ctx: HTTPReqCtx, content_type: bytes, body: bytes, **headers: bytes) - def _make_handler(content_type: bytes, body: bytes): - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: _emit(ctx, content_type, body) return None @@ -382,17 +381,12 @@ def test_sendfile_passes_through_uncompressed(self, tmp_path: Path, serve_backen assert r.content == payload def test_round_trip_when_pool_wraps_compress(self, serve_backend_in_thread): - # Sanity-check: the BodyHandler proxy path (inner returns a continuation) - # also routes complete() through the proxy. We exercise it via a handler - # that uses ctx.body — selector buffers the request body, then invokes - # the BodyHandler which calls ctx.complete. + # Sanity-check: ``compress_handler`` proxies ``ctx.complete`` through + # the inner handler; the response body is compressed. body = b'{"data": ' + b"x" * 4096 + b"}" - def inner(ctx: HTTPReqCtx) -> BodyHandler | None: - def cont(c: HTTPReqCtx) -> None: - _emit(c, b"application/json", body) - - return cont + def inner(ctx: HTTPReqCtx) -> None: + _emit(ctx, b"application/json", body) h = compress_handler(inner, algorithms=("gzip",)) with serve_backend_in_thread(h) as port: @@ -508,7 +502,7 @@ def _sse_handler(events: list[bytes]): the iterator-wrapping path the compression middleware intercepts. """ - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: def chunks() -> Iterator[bytes]: for ev in events: yield b"data: " + ev + b"\n\n" @@ -549,7 +543,7 @@ def test_streaming_passthrough_when_content_length_set(self, serve_backend_in_th # corrupt the contract). Pass through verbatim. body_chunk = b"x" * 4096 - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: def chunks() -> Iterator[bytes]: yield body_chunk @@ -579,7 +573,7 @@ def chunks() -> Iterator[bytes]: def test_streaming_passthrough_when_no_transform(self, serve_backend_in_thread): events = [b"a", b"b", b"c"] - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: def chunks() -> Iterator[bytes]: for ev in events: yield b"data: " + ev + b"\n\n" @@ -640,7 +634,7 @@ def test_first_event_decodes_before_stream_ends(self, serve_backend_in_thread): gate = threading.Event() - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: def chunks() -> Iterator[bytes]: yield b"data: first\n\n" # Block until the test has read+decoded "data: first". If diff --git a/tests/http/router.py b/tests/http/router.py index a8bccc2..cd7487c 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -15,7 +15,6 @@ import pytest from localpost.http import ( - BodyHandler, HTTPReqCtx, Response, RouteMatch, @@ -28,7 +27,7 @@ def _ok_handler(body: bytes = b"ok"): """Build a minimal HTTPReqCtx-shape handler that emits 200 + ``body``.""" - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: ctx.complete( Response( status_code=200, @@ -109,7 +108,7 @@ class TestRouteMatchAttached: def test_match_in_attrs(self, serve_in_thread): captured: dict = {} - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: m = route_match(ctx) assert isinstance(m, RouteMatch) captured["method"] = m.method @@ -268,7 +267,7 @@ class TestPathVariableEncoding: def test_url_encoded_path_arg_passed_through(self, serve_in_thread): captured: dict = {} - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: m = route_match(ctx) captured["name"] = m.path_args["name"] ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") diff --git a/tests/http/router_props.py b/tests/http/router_props.py index f43de5e..1b79e82 100644 --- a/tests/http/router_props.py +++ b/tests/http/router_props.py @@ -23,7 +23,6 @@ from hypothesis import strategies as st from localpost.http import ( - BodyHandler, HTTPReqCtx, Request, Response, @@ -110,7 +109,7 @@ class _Outcome: response: Response | None -def _route_handler(ctx: HTTPReqCtx) -> BodyHandler | None: +def _route_handler(ctx: HTTPReqCtx) -> None: ctx.complete(Response(200, [(b"content-length", b"0")]), b"") return None diff --git a/tests/http/router_sentry_handler.py b/tests/http/router_sentry_handler.py index 0f0bf46..6c8847e 100644 --- a/tests/http/router_sentry_handler.py +++ b/tests/http/router_sentry_handler.py @@ -6,7 +6,7 @@ import pytest import sentry_sdk -from localpost.http import BodyHandler, HTTPReqCtx, Response, Routes, route_match +from localpost.http import HTTPReqCtx, Response, Routes, route_match from localpost.http.router_sentry import sentry_router_handler from ._sentry_helpers import CapturingTransport, init_sentry, transactions @@ -24,7 +24,7 @@ def _build_router(): routes = Routes() @routes.get("/books/{id}") - def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: + def get_book(ctx: HTTPReqCtx) -> None: body = f"book={route_match(ctx).path_args['id']}".encode() ctx.complete( Response( @@ -33,12 +33,10 @@ def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: ), body, ) - return None @routes.post("/books") - def create_book(ctx: HTTPReqCtx) -> BodyHandler | None: + def create_book(ctx: HTTPReqCtx) -> None: ctx.complete(Response(status_code=201, headers=[(b"content-length", b"0")]), b"") - return None assert get_book is not None assert create_book is not None diff --git a/tests/http/rsgi.py b/tests/http/rsgi.py index 535ca19..51055b1 100644 --- a/tests/http/rsgi.py +++ b/tests/http/rsgi.py @@ -18,7 +18,7 @@ import anyio import pytest -from localpost.http import AsyncHTTPReqCtx, Response, to_rsgi +from localpost.http import AsyncHTTPReqCtx, Response, aread_body, to_rsgi from localpost.http.rsgi import _RSGIReqCtx, addrs_from_scope, build_request_from_scope # --- Mocks -------------------------------------------------------------- @@ -138,18 +138,29 @@ def response_stream(self, status: int, headers: list[tuple[str, str]]) -> _FakeS def _build_ctx(*, body: bytes = b"", proto: _FakeProto | None = None) -> _RSGIReqCtx: - """Minimal _RSGIReqCtx for ctx-only checks.""" + """Minimal _RSGIReqCtx for ctx-only checks. + + Pre-fills the body channel with ``body`` and closes it — the ctx + behaves as if the bridge had read exactly those bytes from upstream + and observed EOM. + """ from localpost.http._types import Request # noqa: PLC0415 request = Request(b"GET", b"/", b"/", b"", []) + + body_send, body_recv = anyio.create_memory_object_stream[bytes](max_buffer_size=64) + if body: + body_send.send_nowait(body) + body_send.close() + return _RSGIReqCtx( request=request, - body=body, remote_addr=None, local_addr="127.0.0.1:8000", scheme="http", _proto=proto or _FakeProto(), - _disconnected=__import__("threading").Event(), + _disconnected=anyio.Event(), + _body_stream=body_recv, ) @@ -276,11 +287,12 @@ async def test_sendfile_falls_back_to_stream_when_no_path(self) -> None: class TestCtxReceive: - """Buffered ``receive(size)`` slices the body — same shape as the - sync :class:`_WSGIReqCtx.receive` helper.""" + """``ctx.receive(size)`` pulls from the in-process body queue, + splitting upstream chunks down to the caller's requested ``size``. + """ @pytest.mark.anyio - async def test_receive_slices_buffer(self) -> None: + async def test_receive_splits_chunk_across_calls(self) -> None: ctx = _build_ctx(body=b"abcdef") assert await ctx.receive(2) == b"ab" assert await ctx.receive(3) == b"cde" @@ -296,7 +308,7 @@ async def _drive(rsgi_app: Any, scope: _FakeScope, proto: _FakeProto) -> None: await rsgi_app.__rsgi__(scope, proto) -class TestToRsgiBuffered: +class TestToRsgi: @pytest.mark.anyio async def test_simple_round_trip(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: @@ -309,11 +321,11 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: assert proto.response_body == b"hi" @pytest.mark.anyio - async def test_body_pre_buffered(self) -> None: + async def test_body_read_via_aread_body(self) -> None: captured: dict[str, bytes] = {} async def handler(ctx: AsyncHTTPReqCtx) -> None: - captured["body"] = ctx.body + captured["body"] = await aread_body(ctx) await ctx.complete(Response(200), b"ok") rsgi_app = to_rsgi(handler) @@ -321,28 +333,11 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: await _drive(rsgi_app, _FakeScope(method="POST"), proto) assert captured["body"] == b"hello-world" - @pytest.mark.anyio - async def test_body_too_large_returns_413_via_content_length(self) -> None: - async def handler(ctx: AsyncHTTPReqCtx) -> None: - raise AssertionError("handler should not run") - - rsgi_app = to_rsgi(handler, max_body_size=4) - proto = _FakeProto(body_chunks=[b"too-long-body"]) - scope = _FakeScope( - method="POST", - headers=_FakeHeaders([("content-length", "13")]), - ) - await _drive(rsgi_app, scope, proto) - assert proto.response_status == 413 - - -class TestToRsgiStreaming: @pytest.mark.anyio async def test_chunks_arrive_in_order(self) -> None: captured: list[bytes] = [] async def handler(ctx: AsyncHTTPReqCtx) -> None: - assert ctx.body == b"" while True: chunk = await ctx.receive(64) if not chunk: @@ -350,7 +345,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: captured.append(chunk) await ctx.complete(Response(200), b"ok") - rsgi_app = to_rsgi(handler, streaming=True) + rsgi_app = to_rsgi(handler) proto = _FakeProto(body_chunks=[b"first", b"second", b"third"]) await _drive(rsgi_app, _FakeScope(method="POST"), proto) assert b"".join(captured) == b"firstsecondthird" @@ -360,7 +355,7 @@ async def test_content_length_pre_check_413(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: raise AssertionError("handler should not run") - rsgi_app = to_rsgi(handler, streaming=True, max_body_size=4) + rsgi_app = to_rsgi(handler, max_body_size=4) proto = _FakeProto() scope = _FakeScope( method="POST", diff --git a/tests/http/service.py b/tests/http/service.py index 3eb1ee0..3df2dfd 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -20,7 +20,6 @@ from localpost.hosting import ServiceLifetimeView, serve from localpost.http import ( - BodyHandler, HTTPReqCtx, Request, RequestCancelled, @@ -134,8 +133,8 @@ def body_handler(ctx: HTTPReqCtx): b"ok", ) - def handler(_ctx: HTTPReqCtx): - return body_handler + def handler(_ctx: HTTPReqCtx) -> None: + body_handler(_ctx) cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler) as lt: @@ -195,8 +194,8 @@ def body_handler(ctx: HTTPReqCtx): raise ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - def handler(_ctx: HTTPReqCtx): - return body_handler + def handler(_ctx: HTTPReqCtx) -> None: + body_handler(_ctx) cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler) as lt: @@ -225,7 +224,7 @@ async def test_router_dispatch_via_service(self, free_port): routes = Routes() @routes.get("/books/{id}") - def get_book(ctx: HTTPReqCtx) -> BodyHandler | None: + def get_book(ctx: HTTPReqCtx) -> None: book_id = route_match(ctx).path_args["id"] body = f"book={book_id}".encode() ctx.complete( @@ -352,7 +351,7 @@ async def test_distributes_conns_across_workers(self, free_port): threads_seen: set[int] = set() lock = threading.Lock() - def handler(ctx: HTTPReqCtx) -> BodyHandler | None: + def handler(ctx: HTTPReqCtx) -> None: with lock: threads_seen.add(threading.get_ident()) ctx.complete( @@ -479,7 +478,7 @@ async def test_router_direct_runs_on_one_thread(self, free_port): routes = Routes() @routes.get("/hit") - def hit(ctx: HTTPReqCtx) -> BodyHandler | None: + def hit(ctx: HTTPReqCtx) -> None: with lock: threads_seen.add(threading.get_ident()) ctx.complete( @@ -619,16 +618,13 @@ class TestDispatchLoad: async def test_many_requests_served_from_worker_threads(self, free_port): cfg = ServerConfig(host="127.0.0.1", port=free_port) - def body_handler(ctx: HTTPReqCtx): + def handler(ctx: HTTPReqCtx): tid = str(threading.get_ident()).encode() ctx.complete( Response(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), tid, ) - def handler(_ctx: HTTPReqCtx): - return body_handler - async with _serve_pooled(cfg, handler) as lt: await lt.started await _wait_server_ready(free_port) @@ -676,8 +672,8 @@ def body_handler(ctx: HTTPReqCtx): # Should not be reached ctx.complete(Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok") - def handler(_ctx: HTTPReqCtx): - return body_handler + def handler(_ctx: HTTPReqCtx) -> None: + body_handler(_ctx) cfg = ServerConfig(host="127.0.0.1", port=free_port) async with _serve_pooled(cfg, handler) as lt: diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py index 65868a8..9c783f6 100644 --- a/tests/http/wsgi_to.py +++ b/tests/http/wsgi_to.py @@ -17,6 +17,7 @@ HTTPReqCtx, Response, Routes, + read_body, to_wsgi, ) @@ -143,11 +144,11 @@ def handler(ctx: HTTPReqCtx): assert seen["path"] == b"/items/42" assert seen["query"] == b"x=1&y=2" - def test_request_body_buffered(self): + def test_request_body_lazy_via_read_body(self): seen: dict[str, bytes] = {} def handler(ctx: HTTPReqCtx): - seen["body"] = ctx.body + seen["body"] = read_body(ctx) ctx.complete(Response(200, [(b"content-length", b"0")]), b"") environ = _base_environ( diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py index 32a0787..39387dc 100644 --- a/tests/openapi/aio_app.py +++ b/tests/openapi/aio_app.py @@ -48,7 +48,7 @@ class FakeAsyncCtx: """Minimal async ctx for unit tests — captures the response.""" request: Request - body: bytes = b"" + _body_chunks: list[bytes] = field(default_factory=list) response_status: int | None = None attrs: dict[Any, Any] = field(default_factory=dict) completed: tuple[Response, bytes] | None = None @@ -79,8 +79,10 @@ async def stream(self, response: Response, chunks: AsyncIterator[bytes], /) -> N self.streamed = (response, captured) self.response_status = response.status_code - async def receive(self, size: int = 65536, /) -> bytes: - return self.body if size >= len(self.body) else self.body[:size] + async def receive(self, _size: int = 65536, /) -> bytes: + if not self._body_chunks: + return b"" + return self._body_chunks.pop(0) async def sendfile(self, response: Response, file, offset: int, count: int) -> None: # Test fake — sendfile isn't exercised in this suite. @@ -104,7 +106,7 @@ def make_async_ctx( headers=headers or [], http_version=b"1.1", ) - ctx = FakeAsyncCtx(request=request, body=body) + ctx = FakeAsyncCtx(request=request, _body_chunks=[body] if body else []) if path_args is not None: ctx.attrs[RouteMatch] = RouteMatch( method=HTTPMethod(method), diff --git a/tests/openapi/app.py b/tests/openapi/app.py index 7413605..a61e680 100644 --- a/tests/openapi/app.py +++ b/tests/openapi/app.py @@ -37,13 +37,18 @@ class FakeCtx: """Minimal stand-in for :class:`HTTPReqCtx` for unit tests.""" request: Request - body: bytes = b"" + _body_chunks: list[bytes] = field(default_factory=list) attrs: dict[Any, Any] = field(default_factory=dict) completed: tuple[Response, bytes] | None = None def complete(self, response: Response, body: bytes = b"") -> None: self.completed = (response, body) + def receive(self, _size: int = 0, /) -> bytes: + if not self._body_chunks: + return b"" + return self._body_chunks.pop(0) + def make_ctx( method: str = "GET", @@ -62,7 +67,7 @@ def make_ctx( headers=headers or [], http_version=b"1.1", ) - ctx = FakeCtx(request=request, body=body) + ctx = FakeCtx(request=request, _body_chunks=[body] if body else []) if path_args is not None: ctx.attrs[RouteMatch] = RouteMatch( method=HTTPMethod(method), From a3ccb7ce00c827f1b814953789124f0bc42f18f5 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 01:03:11 +0400 Subject: [PATCH 217/286] docs(design): extract HTTP connection-model, backends, threading-topologies Move three deep-dive sections out of localpost/http/README.md so the README can shrink to user-facing material. The README itself is updated in a follow-up commit. - connection-model.md: dispatch chain, two-state TRACKED/BORROWED machine, pull-based disconnect detection, sync-vs-async surface table. - server-backends.md: h11/httptools coexistence rationale and httptools caveats. - threading-topologies.md: selectors=N / acceptor topology, composition pattern, three-orthogonal-concerns explanation. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/connection-model.md | 131 ++++++++++++++++++++++++++++ docs/design/server-backends.md | 48 ++++++++++ docs/design/threading-topologies.md | 85 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 docs/design/connection-model.md create mode 100644 docs/design/server-backends.md create mode 100644 docs/design/threading-topologies.md diff --git a/docs/design/connection-model.md b/docs/design/connection-model.md new file mode 100644 index 0000000..3eb88fa --- /dev/null +++ b/docs/design/connection-model.md @@ -0,0 +1,131 @@ +# HTTP connection model + +How `localpost.http` moves a connection from accept through dispatch to +release, and how the sync and async surfaces stay aligned. + +## Dispatch chain + +The internals are a three-link, loose-coupled chain: + +``` +Selector ── owns fd→SelectorCallback map; nothing HTTP-specific + │ + ▼ +ConnHandler ── after-accept policy; owns RequestHandler + ConnFactory; + │ decides which Selector tracks the new conn + ▼ +RequestHandler ── single-shot dispatch on headers-complete; handlers + that need the body call ``ctx.receive(size)`` / + ``localpost.http.read_body(ctx)`` themselves +``` + +Each link knows only the next. `Selector` doesn't carry a +`RequestHandler` — the handler is owned by the `ConnHandler` and +threaded into the conn at construction. This factoring is what makes +the acceptor topology (1 acceptor thread + N worker selectors) drop in +without touching the request hot path. + +- **`Selector`** — dumb fd→callback dispatcher. Built-in callbacks: + `_DrainWakeup` (wakeup pipe), `_AcceptListener` (listen socket), and + `BaseHTTPConn` itself (a conn *is* its own per-fd callback). +- **`ConnHandler`** — `Callable[[Selector, socket, addr], None]`, + after-accept policy. Two built-ins: `TrackHere` (default — track on + the selector that accepted) and `RoundRobinAcceptor` (acceptor + topology — spread conns across worker selectors via + `Selector.post_track`). +- All callbacks are spelled as **callable dataclasses** (not + closures), so their state is `repr`-able for debugging. + +## Two-state connection model + +A connection is either **TRACKED** (registered in the selector — the +selector owns the fd, parser, and I/O) or **BORROWED** (a worker +thread holds the parser + socket; fd is unregistered). The dispatcher +unregisters before handing off to a worker (`stop_tracking`); +`finish_response` re-registers via `_maybe_give_back`. Two states, no +third "watchdog" mode, no shared mode field for threads to race on. + +``` + accept() + │ + ▼ + ConnHandler builds conn, + routes to a selector via + track() or post_track() + │ + ▼ + ┌──────────────────────┐ ┌──────────┐ + │ TRACKED │ close │ │ + │ fd registered ├────────▶│ CLOSED │ + │ selector owns I/O │ │ │ + └──┬───────────────────┘ └──────────┘ + │ ▲ ▲ + borrow │ │ _maybe_give_back │ close + ▼ │ │ + ┌──────────────────────┐ │ + │ BORROWED │ │ + │ fd unregistered ├───────────────┘ + │ worker owns I/O │ + └──────────────────────┘ +``` + +Both topologies (single selector and acceptor + N worker selectors) +funnel through the same diagram; only the entry edge differs: + +| From → to | Method | Caller thread | Mechanism | +| ------------------------ | ---------------------- | -------------- | ------------------------------------------------ | +| accept → TRACKED | `Selector.track` | selector | inline `selectors.register` (TrackHere topology) | +| accept → TRACKED | `Selector.post_track` | acceptor | op queue + wakeup pipe (RoundRobinAcceptor) | +| TRACKED → BORROWED | `stop_tracking` | selector | inline `selectors.unregister` | +| BORROWED → TRACKED | `Selector.track` | worker | op queue + wakeup pipe | +| TRACKED → CLOSED | `BaseHTTPConn.close` | selector | inline (idle / keep-alive / error) | +| BORROWED → CLOSED | `close` + `post_close` | worker | op queue + wakeup pipe (`_fd_to_key` cleanup) | + +The op queue + wakeup pipe is the only cross-thread synchronisation +edge (the `os.write` to the wakeup pipe is a full memory barrier). +After `track()` returns, anything the worker did to the conn's parser +(`h11.Connection.send` / `httptools.HttpRequestParser.feed_data`) is +visible to the selector. After `stop_tracking()` returns, anything +the selector did is visible to the worker. The parser is never +touched concurrently from two threads. + +## Pull-based client-disconnect detection + +While the worker holds a borrowed connection, client disconnects are +detected on demand: `check_cancelled()` does a non-blocking +`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. `b""` means +peer FIN — `RequestCancelled` is raised. Handlers that do regular I/O +surface disconnects via `EPIPE` / `ECONNRESET` naturally; handlers +that compute without I/O should call `check_cancelled()` periodically +(same contract as service-shutdown cancellation). + +This replaces an earlier push-based design where the selector kept +the socket registered in a third "watchdog" mode and fired EOF +events. That worked but introduced a 3-way state machine with +cross-thread races. The pull-based variant collapses to two states +and one syscall per `check_cancelled()` call. + +## Sync vs. async request-context surface + +`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side +(`request`, `body`, `attrs`, `response_status`, `remote_addr` / +`local_addr` / `scheme`, `disconnected`) and the terminal write +methods (`complete`, `stream`, `sendfile`, plus `receive` for body +reads). A handler that only touches that core surface is portable +between transports. + +A few members are deliberately asymmetric: + +| Member | Sync | Async | Why | +| ---------------------------- | ---- | ----- | --- | +| `borrow()` / `borrowed` | yes | — | Only the sync native server hands a connection between selector and worker threads. Async transports own the conn for the coroutine's lifetime; there's nothing to borrow. The WSGI bridge satisfies the sync member trivially (always borrowed, no-op CM) so handler code stays portable. | +| `disconnected` poll | yes | yes | Native sync does a non-blocking `MSG_PEEK` per access (sticky once True). WSGI bridge is always False — surface disconnects via `BrokenPipeError` from the host's per-chunk write instead. Async transports flip the flag on `http.disconnect`. | +| `check_cancelled()` (raises) | yes | — | Sync handlers without `ctx` in scope can call it from anywhere on the request thread; async code already has `ctx` and uses `ctx.disconnected`. | + +The wire-driver trio (`start_response` / `send` / `finish_response`) +is **not** part of the public Protocol on either side. Sync native +backends still use it internally to drive h11 / httptools, and 1xx +informational responses (100 Continue / 102 Processing) are emitted +by the server through that internal path. Handlers express responses +declaratively via `complete()` (one-shot) or `stream(response, +chunks)` (chunk iterator) — both portable across sync and async. diff --git a/docs/design/server-backends.md b/docs/design/server-backends.md new file mode 100644 index 0000000..d161aca --- /dev/null +++ b/docs/design/server-backends.md @@ -0,0 +1,48 @@ +# HTTP server backends: h11 and httptools + +`localpost.http` ships two parser implementations that live +side-by-side. They share the listening socket, selector loop, op +queue, stale-conn sweep, and shutdown coordination (everything in +`_base.py`). They differ only in how they drive the parser. + +| `ServerConfig.backend` | Parser | Extra | Notes | +| ---------------------- | --------- | --------------- | ------------------------------------- | +| `"h11"` *(default)* | h11 | `[http-server]` | pure Python, readable | +| `"httptools"` | httptools | `[http-fast]` | C-based llhttp; faster header parsing | + +There is one entry point — `start_http_server(config, handler)` — +and one hosted-service wrapper — `http_server(config, handler)`. The +parser is selected via `ServerConfig.backend`: + +```python +from localpost.http import ServerConfig, start_http_server + +with start_http_server( + ServerConfig(backend="httptools"), my_handler +) as server: + while True: + server.run() +``` + +Pick whichever fits — handler code is identical. Both populate the +same neutral `Request` / `NativeResponse` types from +`localpost.http`. + +## Why two implementations, not one Protocol + +The two backends are intentionally **not** unified behind a parser +Protocol. h11 is pull-events + parse/serialize; httptools is +push-callbacks + parse-only. Forcing one shape over both restricts +the faster backend without buying anything portable, and the share +that *could* be hoisted (`_base.py`) already is. + +## httptools backend caveats (initial scope) + +- `Content-Length` response bodies only — chunked transfer-encoding + is a follow-up. `Router` and `wrap_wsgi` already set + `Content-Length`, so this matches today's behaviour. +- HTTP Upgrade negotiation surfaces as 400 Bad Request — revisit + if/when WebSockets come back on the roadmap. + +For perf context, see +[`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md). diff --git a/docs/design/threading-topologies.md b/docs/design/threading-topologies.md new file mode 100644 index 0000000..ae107ed --- /dev/null +++ b/docs/design/threading-topologies.md @@ -0,0 +1,85 @@ +# HTTP threading topologies + +`http_server` supports three accept-side shapes, all driving the same +`RequestHandler` (single-shot dispatch on headers-complete). The +underlying connection state machine is shared — see +[connection-model.md](connection-model.md) for the TRACKED / BORROWED +diagram and cross-thread sync edges. + +| Configuration | Threads | When to use | +| ------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Default (`selectors=1`) | 1 thread accepts, parses, and dispatches | Simple deployments; the thread-pool wrapper handles concurrency on the request side | +| `selectors=N` | N independent selectors, each with its own listening socket via `SO_REUSEPORT` | Linux kernel-level connection load-balancing; free-threaded builds | +| `selectors=N, acceptor=True` | 1 acceptor thread + N worker selector threads | macOS / free-threaded targets where `SO_REUSEPORT` doesn't distribute evenly. Acceptor accepts each conn and round-robins it to a worker via the cross-thread op queue. | + +The server loop runs in a worker thread +(`anyio.to_thread.run_sync`); shutdown is driven by +`lt.shutting_down` via `threadtools.check_cancelled()`. The server +hosts a single handler; whether that handler runs synchronously on +the selector thread or hops to a worker is the handler's choice. + +## Composition pattern + +`http_server`, the `Router`, and `thread_pool_handler` are three +orthogonal concepts that you compose explicitly: + +```python +from localpost.hosting import run_app, service +from localpost.http import ( + Routes, ServerConfig, http_server, thread_pool_handler, +) + + +@service +async def app(): + routes = Routes() + + @routes.get("/hello/{name}") + def hello(ctx): ... # plain RequestCtx → Response handler + + config = ServerConfig(host="127.0.0.1", port=8000) + async with thread_pool_handler(routes.build().as_handler()) as h: + async with http_server(config, h): + yield + + +run_app(app()) +``` + +What this gives you: + +- **404 / 405 stay on the selector thread.** When the `Router` is + the handler (wrapped or not), unmatched paths and method + mismatches go through `_send_plain` inline — no worker hop. +- **Matched routes run wherever you want.** Wrap the entire router + with `thread_pool_handler` (above) to run all matched handlers on + workers; pass the router directly to `http_server` to keep them + all on the selector; more granular per-route control is the + user's composition problem (today there is no per-route pool + API). +- **No concurrency cap on `http_server` or the pool.** The pool + dispatches every request onto a process-wide `ThreadTaskGroup`; + workers are spawned on demand and reused across all + `thread_pool_handler` instances in the process. There is no + admission gate and no 503-on-overflow — backpressure is the + deployment's concern (front-LB / OS thread limits). + +## Three orthogonal concerns + +- **Handler** — `Callable[[HTTPReqCtx], None]`. Immediate by default + (runs on whichever thread invokes it). The thread-pool variant is + just a wrapper that borrows the connection, queues it, and runs + `inner` on a worker. +- **Router** — itself an immediate handler. Runs `_match()` inline + on the calling thread; 404/405 are sent via `_send_plain` and the + conn re-tracks via the existing connection loop. Matched routes + call the registered per-route handler — which the user can choose + to pool or not. +- **`http_server`** — hosting integration only. Owns the AnyIO + bridge, drives `server.run()` in a thread, watches + `lt.shutting_down`. Doesn't know about pools, doesn't know about + routing. + +This split means the simple case (all-immediate handlers) doesn't +pay for a worker pool, and the Router's 404/405 path doesn't either +even when the matched routes run on workers. From f7f639502be27fed116d4ac866758a66e179735b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 01:06:04 +0400 Subject: [PATCH 218/286] docs(http): trim README, link out to design notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localpost/http/README.md was 807 lines, dominated by Symbol|Notes API reference tables and design deep-dives. The reference tables duplicate what types/docstrings already expose (mkdocstrings will autogen them when the mkdocs site lands); the design content moved to docs/design/ in the previous commit. - Strip all Symbol|Notes tables (server core, router, wsgi, asgi, rsgi, static, compress, flask, sentry, config, hosting integration). - Strip the Status banner. - Replace per-sub-module "Symbol" headings with prose summaries; keep user-facing behaviour notes (static handler matrix, compress decision rules) in-README since they're how-to material. - Drop "How is it different from Flask / FastAPI" — outdated framing (the openapi README is the apt comparison). - Drop the inline ASCII diagrams; they live with the design notes. - Add a "Design" section pointing at docs/design/{connection-model, threading-topologies,server-backends,request-body-handling, deployment-topologies}.md. docs/index.md: list the three new design notes. 807 → 434 lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/index.md | 15 +- localpost/http/README.md | 730 ++++++++++----------------------------- 2 files changed, 191 insertions(+), 554 deletions(-) diff --git a/docs/index.md b/docs/index.md index 493bb1e..5bba9f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,9 +16,18 @@ User-facing references live next to the code: ## Design notes -Architectural decisions and contract explanations — read these when -building on top of `localpost` or extending an existing module. - +Stable explanations of how the system works today and why. Edited +freely as the design evolves. + +- [Connection model](design/connection-model.md) — dispatch chain, + two-state TRACKED/BORROWED machine, pull-based client-disconnect + detection, and the sync-vs-async request-context asymmetry. +- [Threading topologies](design/threading-topologies.md) — single + selector vs `selectors=N` vs acceptor + N workers; the + handler/router/`http_server` composition pattern. +- [Server backends](design/server-backends.md) — h11 and httptools + coexistence, why they aren't unified behind a parser Protocol, + httptools caveats. - [Request body handling across transports](design/request-body-handling.md) — what `ctx.receive(size)` does on the native server, WSGI, ASGI, and RSGI; how the pre-buffer / streaming distinction is a transport diff --git a/localpost/http/README.md b/localpost/http/README.md index e0e29e1..015d522 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -1,10 +1,9 @@ # localpost.http -> **Status:** stable — public API is not expected to break in patch/minor releases. - -A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), -plus a URI-template router, WSGI / ASGI bridges, and a small framework -(`HttpApp`) on top. Three layers, each usable on its own: +A small synchronous HTTP/1.1 server built on +[h11](https://h11.readthedocs.io/), plus a URI-template router, WSGI / ASGI +bridges, and a small framework (`HttpApp`) on top. Three layers, each +usable on its own: - **Server**: `start_http_server` accepts connections, parses HTTP (h11 by default; httptools opt-in via `ServerConfig.backend`), @@ -18,53 +17,19 @@ plus a URI-template router, WSGI / ASGI bridges, and a small framework Pair with `localpost.hosting` for lifecycle management, or run any of the three layers standalone. -## Scope and constraints - -The server is intentionally bounded: - -- **In-process only.** No multi-processing, no `fork` / `spawn`. If you need - multi-core fanout, run multiple `localpost` processes under an external - supervisor (systemd, gunicorn, k8s replicas, etc.). Multi-*selector* - inside one process is on the roadmap. -- **Sync server only.** No `asyncio` / `uvloop` ships in the native - server's request hot path. The hosting layer's AnyIO integration is - unaffected — that's lifecycle plumbing, not request handling. - Production-grade async HTTP servers already exist (uvicorn, - hypercorn, granian); for handlers that *want* to be async, - `localpost.http.asgi.to_asgi(handler)` adapts an `AsyncRequestHandler` - to any of them. The handler still implements the same conceptual - contract (read request, write response) — just an async-flavoured - Protocol (`AsyncHTTPReqCtx`) instead of the sync one. -- **GIL or free-threaded.** Standard CPython 3.12+ is the baseline. - Free-threaded builds (3.13t / 3.14t) are an accepted target — the design - is single-writer-per-selector and uses only thread-safe primitives, but - see `benchmarks/http/PERF_FINDINGS.md` for any noted caveats per release. - -### Workload shape (the JSON-API common case) - -The hot path is tuned for the JSON web-API common case, and we cut -corners on shapes that don't fit it: - -- **Reject before body.** Handlers that decide based on headers - (no-route, wrong method, auth) reply with `ctx.complete(...)` - inline; the body is never recv'd. No worker hop, no allocation. - `Content-Length` is pre-checked against `max_body_size` on - headers-complete — over-cap requests get 413 before the handler - ever sees them. -- **Read body explicitly when you need it.** Handlers that need the - whole body (e.g. to deserialise JSON) call - `localpost.http.read_body(ctx)` to drain `ctx.receive(size)` into - bytes. Frameworks (`HttpApp`, `wrap_wsgi`, `flask_handler`) call it - for you when their typed surface needs the body. The server core - never pre-buffers. -- **Response is one chunk or SSE.** Most responses are one - status+headers+body block; SSE generators emit the same opening - block then per-event chunks. The response writer auto-buffers - headers and flushes them with the first body chunk in a single - `sendall` — common-case `complete(...)` is one syscall. -- **No HTTP/1.1 pipelining.** Pipelined clients are served sequentially - (correct, just no parallelism). The simpler per-conn state machine - is the trade we made for it. +## Scope + +In-process only (no `fork` / `spawn`); for multi-core fanout, run multiple +processes under an external supervisor. Sync server only — async handlers +are reached via `localpost.http.asgi.to_asgi(handler)` plugged into +uvicorn / hypercorn / granian. CPython 3.12+ is the baseline; free-threaded +builds are an accepted target. + +The hot path is tuned for the JSON-API common case: handlers can reject +before the body (no worker hop, `Content-Length` pre-checked against +`max_body_size`); read the body explicitly via `read_body(ctx)` / +`aread_body(ctx)` when needed; one-shot `complete()` flushes +status+headers+body in a single `sendall`. No HTTP/1.1 pipelining. ## Install @@ -122,9 +87,6 @@ with start_http_server(ServerConfig(), simple_app) as server: server.run() ``` -See [`examples/http/simple_server.py`](../../examples/http/simple_server.py), -`multithread_server.py`, `wsgi_app_server.py`. - Running under hosting: ```python @@ -137,127 +99,41 @@ from localpost.http import http_server, ServerConfig sys.exit(run_app(http_server(ServerConfig(), simple_app))) ``` -## Sync vs. async surface - -`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side -(`request`, `body`, `attrs`, `response_status`, `remote_addr` / -`local_addr` / `scheme`, `disconnected`) and the terminal write -methods (`complete`, `stream`, `sendfile`, plus `receive` for body -reads). A handler that only touches that core surface is portable -between transports. - -A few members are deliberately asymmetric: - -| Member | Sync | Async | Why | -| --- | --- | --- | --- | -| `borrow()` / `borrowed` | ✅ | — | Only the sync native server hands a connection between selector and worker threads. Async transports own the conn for the coroutine's lifetime; there's nothing to borrow. The WSGI bridge satisfies the sync member trivially (always borrowed, no-op CM) so handler code stays portable. | -| `disconnected` poll | ✅ | ✅ | Native sync does a non-blocking `MSG_PEEK` per access (sticky once True). WSGI bridge is always False — surface disconnects via `BrokenPipeError` from the host's per-chunk write instead. Async transports flip the flag on `http.disconnect`. | -| `check_cancelled()` (raises) | ✅ | — | Sync handlers without `ctx` in scope can call it from anywhere on the request thread; async code already has `ctx` and uses `ctx.disconnected`. | - -The wire-driver trio (`start_response` / `send` / `finish_response`) is **not** part of the public Protocol on either side. Sync native backends still use it internally to drive h11 / httptools, and 1xx informational responses (100 Continue / 102 Processing) are emitted by the server through that internal path. Handlers express responses declaratively via `complete()` (one-shot) or `stream(response, chunks)` (chunk iterator) — both portable across sync and async. +See [`examples/http/`](../../examples/http/). ## Key concepts - **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, `keep_alive_timeout`, `max_body_size`. -- **`start_http_server(config, handler)`** — context manager; yields a `Server` - bound to a non-blocking listening socket with a `selectors` poller. The - handler is fixed for the server's lifetime. -- **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, the - raw socket, headers, and `complete(response, body)`. Request bodies are - streamed via `receive(n_bytes)`. +- **`start_http_server(config, handler)`** — context manager; yields a + `Server` bound to a non-blocking listening socket with a `selectors` + poller. The handler is fixed for the server's lifetime. +- **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, + the raw socket, headers, and `complete(response, body)`. Request bodies + are streamed via `receive(n_bytes)`. - **`RequestHandler = Callable[[HTTPReqCtx], None]`** — the handler interface. `Server.run()` dispatches each accepted request to it. -- **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style variables, - matched with a generated regex). `match(uri) → dict | None`. +- **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style + variables). `match(uri) → dict | None`. - **`Routes`** — mutable builder. Accumulate routes via decorators - (`@routes.get("/path")`, `.post`, `.put`, `.delete`, `.patch`, `.add`), then - call `.build()` to compile into a `Router`. + (`@routes.get("/path")`, `.post`, `.put`, `.delete`, `.patch`, `.add`), + then call `.build()` to compile into a `Router`. - **`Router`** — immutable, compiled URI-template dispatcher. One regex - alternation over all templates, templates ordered by longest literal prefix, - `Allow` headers pre-rendered. Exposes `.as_handler()` (native - `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian / - etc.). Build via `routes.build()` or `Router.from_routes(routes)`. + alternation over all templates, templates ordered by longest literal + prefix, `Allow` headers pre-rendered. Exposes `.as_handler()` (native + `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian + / etc.). Build via `routes.build()` or `Router.from_routes(routes)`. -### Dispatch chain +## Sub-modules -The internals are a three-link, loose-coupled chain: - -``` -Selector ── owns fd→SelectorCallback map; nothing HTTP-specific - │ - ▼ -ConnHandler ── after-accept policy; owns RequestHandler + ConnFactory; - │ decides which Selector tracks the new conn - ▼ -RequestHandler ── single-shot dispatch on headers-complete; handlers - that need the body call ``ctx.receive(size)`` / - ``localpost.http.read_body(ctx)`` themselves -``` - -Each link knows only the next. `Selector` doesn't carry a `RequestHandler` — -the handler is owned by the `ConnHandler` and threaded into the conn at -construction. This factoring is what makes the acceptor topology (1 acceptor -thread + N worker selectors) drop in without touching the request hot path. - -- **`Selector`** — dumb fd→callback dispatcher. Built-in callbacks: - `_DrainWakeup` (wakeup pipe), `_AcceptListener` (listen socket), and - `BaseHTTPConn` itself (a conn *is* its own per-fd callback). -- **`ConnHandler = Callable[[Selector, socket.socket, tuple[str, int]], None]`** — - after-accept policy. Two built-ins: `TrackHere` (default — track on the - selector that accepted) and `RoundRobinAcceptor` (acceptor topology — - spread conns across worker selectors via `Selector.post_track`). -- All callbacks are spelled as **callable dataclasses** (not closures), so - their state is `repr`-able for debugging. - -## Public API - -### Server core (`localpost.http`) - -| Symbol | Notes | -| ------------------------- | ------------------------------------------ | -| `start_http_server(cfg, handler)` | Context manager yielding a `Server` bound to ``handler`` | -| `HTTPReqCtx` | Per-request context (`request`, `complete`, `stream`, `sendfile`, `receive`, `disconnected`) | -| `RequestHandler` | `Callable[[HTTPReqCtx], None]` | -| `read_body(ctx, *, max_size=...)` | Drain `ctx.receive(size)` into a single `bytes`. Raises `BodyTooLarge` on overflow. Sync; the conn must be borrowed (or wrapped in `thread_pool_handler`). | -| `aread_body(ctx, *, max_size=...)` | Async sibling of `read_body` — drains `await ctx.receive(size)`. | - -### Selector / accept-side topology - -| Symbol | Notes | -| ------------------------- | ------------------------------------------ | -| `Selector` | Dumb fd→callback dispatcher (op queue + wakeup pipe + stale-conn sweep). One per selector thread. | -| `SelectorCallback` | `Callable[[Selector], None]` — registered per-fd, invoked when readable | -| `ConnHandler` | `Callable[[Selector, sock, addr], None]` — after-accept policy | -| `ConnFactory` | `Callable[[Selector, sock, addr, RequestHandler], BaseHTTPConn]` | -| `TrackHere` | Default `ConnHandler`: build conn for accepting selector and `track()` it. Reproduces the all-in-one behaviour. | -| `RoundRobinAcceptor` | `ConnHandler` for the acceptor topology — spreads new conns across a tuple of worker `Selector`s via `post_track` (cross-thread op queue). | - -### Cancellation - -| Symbol | Notes | -| --------------------- | -------------------------------------------------------------- | -| `HTTPReqCtx.disconnected` | Pull-style poll for peer-gone, mirroring `AsyncHTTPReqCtx.disconnected`. Native backends do a non-blocking `recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket and stick `True` once seen; the WSGI bridge always returns `False` (no socket handle). Use it inside SSE generators / long handlers when you have `ctx` in scope. | -| `check_cancelled()` | Cooperative cancel check for sync handlers. Raises `RequestCancelled` if the client disconnected (detected via non-blocking ``MSG_PEEK``) or the hosted service is shutting down. Call periodically in long-running handlers. | -| `RequestCancelled` | Exception raised by `check_cancelled()`. Inherits from `Exception` (not `BaseException`) — catchable with `except Exception:`. | - -### `localpost.http.router` - -| Symbol | Notes | -| ------------------------- | ------------------------------------------ | -| `URITemplate` | Parse and match RFC 6570 L1 templates | -| `RequestCtx` | Routed request context (path args, query, body access) | -| `Routes` | Mutable builder — decorators (`.get`, `.post`, …) / `.add`. Call `.build()` to freeze | -| `Router` | Immutable, compiled dispatcher. `.as_handler()` for native, `.wsgi` for WSGI. `.routes` is a tuple of `Route`. | -| `Route` | One compiled route: `template`, `methods`, pre-rendered `allow_header` | -| `Response` | Simple `(status, headers, body)` tuple | +User-facing prose for the bridges and middlewares. The connection +model, threading topologies, and backend rationale live in `docs/design/` +— see the [Design](#design) section. ### `localpost.http.wsgi` -| Symbol | Notes | -| ------------------------- | ------------------------------------------ | -| `wrap_wsgi(app)` | Turn a WSGI app into a `RequestHandler` | -| `to_wsgi(handler)` | Turn a `RequestHandler` into a WSGI app | +`wrap_wsgi(app)` turns a WSGI app into a `RequestHandler`; +`to_wsgi(handler)` turns a `RequestHandler` into a WSGI app. ### `localpost.http.asgi` @@ -268,13 +144,11 @@ itself doesn't ship an async server; this module plugs an `AsyncRequestHandler` into uvicorn / hypercorn / granian / any ASGI 3 server. -| Symbol | Notes | -| ------------------------------------------------------- | --------------------------------------------------------------------------- | -| `AsyncHTTPReqCtx` | Async sibling of `HTTPReqCtx`. Same `request` / `body` / `attrs` / addrs / `scheme`; async `complete` / `stream` / `receive` / `sendfile`; `disconnected: bool` poll for peer-gone. Re-exported from `localpost.http`. | -| `AsyncRequestHandler` | `Callable[[AsyncHTTPReqCtx], Awaitable[None]]`. The handler shape `to_asgi` adapts. | -| `to_asgi(handler, *, max_body_size=1<<20)` | Wrap an `AsyncRequestHandler` as an ASGI 3 app. Body bytes are pulled lazily via `await ctx.receive(size)` (or `aread_body(ctx)` for the whole-body common case); `Content-Length` is pre-checked against `max_body_size` when present (413 before dispatch). | - -Deployment is the standard ASGI pattern: +`to_asgi(handler, *, max_body_size=1<<20)` wraps an `AsyncRequestHandler` +as an ASGI 3 app. Body bytes are pulled lazily via +`await ctx.receive(size)` (or `aread_body(ctx)` for the whole-body common +case); `Content-Length` is pre-checked against `max_body_size` when +present (413 before dispatch). ```python # myapp.py @@ -300,14 +174,10 @@ typed handlers) on top of the same bridge, see [`localpost.openapi.HttpAsyncApp`](../openapi/README.md#async-flavour-httpasyncapp) — `app.asgi()` is sugar over `to_asgi(self._build_async_handler())`. -> Cancellation: `ctx.disconnected` flips on `http.disconnect`. SSE -> generators / long handlers poll it between events to short-circuit -> cleanly. - -> Body handling across transports — -> [docs/design/request-body-handling.md](../../docs/design/request-body-handling.md) -> covers the contract (one `receive(size)` Protocol method, four host -> servers, no flag soup). +Cancellation: `ctx.disconnected` flips on `http.disconnect`. SSE +generators / long handlers poll it between events to short-circuit +cleanly. Body-handling contract across transports lives in +[docs/design/request-body-handling.md](../../docs/design/request-body-handling.md). ### `localpost.http.rsgi` @@ -315,15 +185,13 @@ The Granian-flavoured sibling of `localpost.http.asgi`. Same `AsyncHTTPReqCtx` Protocol; different wire surface (RSGI exposes richer per-method calls than ASGI's two-event response dance), and different deployment topology (Granian is a process supervisor, not -an in-process server). - -Install: `pip install 'localpost[rsgi]'` (pulls in `granian`). +an in-process server). Install: `pip install 'localpost[rsgi]'`. -| Symbol | Notes | -| ------------------------------------------------------------ | --------------------------------------------------------------------------- | -| `to_rsgi(handler, *, max_body_size=1<<20)` | Wrap an `AsyncRequestHandler` as an RSGI app for `granian --interface rsgi`. Body bytes are pulled lazily via `await ctx.receive(size)`. Single eager `proto.response_bytes` per `complete`; zero-copy `proto.response_file_range` for `sendfile` when the file has a path; chunked stream fallback otherwise. | - -Deployment is the standard Granian pattern: +`to_rsgi(handler, *, max_body_size=1<<20)` wraps an +`AsyncRequestHandler` for `granian --interface rsgi`. Single eager +`proto.response_bytes` per `complete`; zero-copy +`proto.response_file_range` for `sendfile` when the file has a path; +chunked stream fallback otherwise. ```python # myapp.py @@ -344,34 +212,30 @@ granian --interface rsgi myapp:rsgi_app For framework-flavoured deployment, see [`localpost.openapi.HttpAsyncApp.as_rsgi()`](../openapi/README.md#async-flavour-httpasyncapp). -For deployments where the HTTP app shares a worker process with -**other hosted services** (scheduler / gRPC / custom workers), +For deployments where the HTTP app shares a worker process with **other +hosted services** (scheduler / gRPC / custom workers), [`localpost.hosting.HostRSGIApp`](../hosting/README.md#host-as-rsgi-for-granian) -runs the full hosting lifecycle inside each Granian worker. - -> The asymmetry between uvicorn-as-a-hosted-service (in-process, -> drives Granian-the-app from inside) and Granian-as-a-supervisor -> (out-of-process, drives our app from outside) is covered in -> [docs/design/deployment-topologies.md](../../docs/design/deployment-topologies.md). +runs the full hosting lifecycle inside each Granian worker. The +asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor +is covered in +[docs/design/deployment-topologies.md](../../docs/design/deployment-topologies.md). ### `localpost.http.static` -Static file serving via `socket.sendfile()` — zero-copy from the page -cache to the socket. Designed for **CDN-fronted deployments**: pair with -proper `Cache-Control` headers and origin sees roughly one hit per file -per edge per cache lifetime, which is why we skip on-the-fly compression +`static_handler(root, *, prefix=b"/", cache_control=None, +index="index.html")` builds a `RequestHandler` that serves files under +`root` via `socket.sendfile()` — zero-copy from the page cache to the +socket. Designed for **CDN-fronted deployments**: pair with proper +`Cache-Control` headers and origin sees roughly one hit per file per +edge per cache lifetime, which is why we skip on-the-fly compression (the CDN handles `gzip` / `br` at the edge). -| Symbol | Notes | -| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `static_handler(root, *, prefix=b"/", cache_control=None, index="index.html")` | Build a `RequestHandler` that serves files under ``root``. | - -Behavior: +Behaviour: - **Methods**: `GET` and `HEAD`. Anything else returns 405 + `Allow: GET, HEAD`. -- **Resolution**: percent-decoded URL path (with ``prefix`` stripped) is - joined under ``root``, resolved, and checked with `Path.is_relative_to`. - ``..`` segments are rejected before resolution. +- **Resolution**: percent-decoded URL path (with `prefix` stripped) is + joined under `root`, resolved, and checked with `Path.is_relative_to`. + `..` segments are rejected before resolution. - **Conditional GET**: strong `ETag` (size + mtime in nanoseconds) plus `Last-Modified`. `If-None-Match` (with weak comparison) and `If-Modified-Since` short-circuit to 304 inline on the selector — no @@ -383,8 +247,6 @@ Behavior: — wrap in `thread_pool_handler` to dispatch the syscall to a worker. HEAD success / 304 / 416 / 404 / 405 complete inline on the selector. -#### Recommended composition - A static handler in its own pool, separate from the API pool, so a few slow downloads can't pin all the API workers: @@ -409,44 +271,37 @@ async with api as api_h, static as static_h: ... ``` -Both wrappers share the process-wide worker pool — workers are spawned on -demand and reused. See [`examples/http/static_files.py`](../../examples/http/static_files.py). - -#### `HTTPReqCtx.sendfile` +Both wrappers share the process-wide worker pool — workers are spawned +on demand and reused. See +[`examples/http/static_files.py`](../../examples/http/static_files.py). -The static handler is the typical caller, but `ctx.sendfile(response, -file, offset, count)` is part of the public `HTTPReqCtx` Protocol and -can be used directly for any zero-copy body. Requires -`Content-Length: ` on the response (chunked is rejected); both -backends keep their parser state consistent with what the kernel writes -out-of-band. +`ctx.sendfile(response, file, offset, count)` is part of the public +`HTTPReqCtx` Protocol and can be used directly for any zero-copy body. +Requires `Content-Length: ` on the response (chunked is rejected); +both backends keep their parser state consistent with what the kernel +writes out-of-band. ### `localpost.http.compress` -Response compression middleware for the **dynamic** path — -`gzip` (stdlib, always available) + `br` (optional via `[http-compress]`). -Pair with a JSON / HTML / XML API; **not** intended for static files -(compression and zero-copy `sendfile` are at odds — see -`plans/compression-middleware.md`). +`compress_handler(inner, *, algorithms=("br","gzip"), min_size=1024, +compressible_types=...)` wraps a `RequestHandler` so eligible +`complete()` responses are compressed — `gzip` (stdlib, always +available) plus `br` (optional via `[http-compress]`). Pair with a JSON +/ HTML / XML API; **not** intended for static files (compression and +zero-copy `sendfile` are at odds). Behind a CDN you usually don't need this: the CDN compresses at the edge from an uncompressed origin. `compress_handler` is for deployments *not* behind a CDN, or when the CDN doesn't compress (rare). -| Symbol | Notes | -| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `compress_handler(inner, *, algorithms=("br","gzip"), min_size=1024, compressible_types=...)` | Wrap a `RequestHandler` so eligible `complete()` responses are compressed. | -| `DEFAULT_COMPRESSIBLE_TYPES` | Frozenset allowlist (text/*, JSON, XML, JS, SVG, YAML, …) used by default. | - -#### Decision matrix - The middleware skips compression when any of these hold (response sent verbatim): - `Accept-Encoding` doesn't list any configured `algorithms` with q>0 - Method is `HEAD` (no body to compress) - `body is None` or `len(body) < min_size` -- Status is `1xx` / `204` / `304` (no body) or `206` (range — compressing breaks byte semantics) +- Status is `1xx` / `204` / `304` (no body) or `206` (range — compressing + breaks byte semantics) - Response already has `Content-Encoding` (other than `identity`) - Response has `Cache-Control: no-transform` (RFC 9111) - `Content-Type` main-type is not in `compressible_types` @@ -456,348 +311,121 @@ When eligible: body is compressed, `Content-Length` is replaced, `Vary` (existing `Vary: Cookie` becomes `Vary: Cookie, Accept-Encoding`; `Vary: *` is left alone). -#### Streaming responses (incl. SSE) - `compress_handler` intercepts both `complete(...)` and `stream(...)`. The middleware decides per-request: -- One-shot path (`complete(response, body)`) → compress the whole body - in memory; replace `Content-Length`. -- Streaming path (`stream(response, chunks)`) with **no** `Content-Length` - declared → wrap the chunk iterator with an incremental compressor; - each input chunk is emitted compressed + sync-flushed so the - decompressor sees each chunk promptly. The backend auto-frames - `Transfer-Encoding: chunked` on HTTP/1.1. -- Streaming path **with** `Content-Length` → pass through (we'd - otherwise lie about the declared length). - -For SSE (`Content-Type: text/event-stream`, included in +- One-shot path → compress the whole body in memory; replace + `Content-Length`. +- Streaming path with **no** `Content-Length` declared → wrap the chunk + iterator with an incremental compressor; each input chunk is emitted + compressed + sync-flushed so the decompressor sees each chunk + promptly. The backend auto-frames `Transfer-Encoding: chunked` on + HTTP/1.1. +- Streaming path **with** `Content-Length` → pass through. + +For SSE (`Content-Type: text/event-stream`, in `DEFAULT_COMPRESSIBLE_TYPES`), each event your generator yields reaches the client decompressed and parseable by `EventSource` — same approach -nginx uses with `gzip on; gzip_types text/event-stream`. All major -browsers transparently decompress `Content-Encoding: gzip` / `br` -streams before EventSource sees them. - -`sendfile` always passes through uncompressed — composition with the -static handler stays zero-copy. - -#### Limitations - -- **One-shot path allocates a compressed buffer per response.** Fine for - typical JSON; for multi-MB single-shot payloads, hand - `compress_handler` a `stream(response, chunks)` instead so the - streaming compressor handles it incrementally. -- **Brotli is opt-in.** `pip install localpost[http-compress]` - installs `brotli`. If `"br"` is in `algorithms` without the extra, - `compress_handler` raises `ImportError` at construction time. - -#### Composition with the static handler - -`compress_handler` and `static_handler` compose cleanly — the -compression middleware passes `sendfile` through, so static stays -zero-copy: - -```python -api = thread_pool_handler( - compress_handler(routes.build().as_handler(), algorithms=("br", "gzip")), -) -static = thread_pool_handler(static_handler("/var/www", prefix=b"/static/")) -``` - -See [`examples/http/compressed_api.py`](../../examples/http/compressed_api.py). +nginx uses with `gzip on; gzip_types text/event-stream`. `sendfile` +always passes through uncompressed — composition with the static +handler stays zero-copy. + +Limitations: the one-shot path allocates a compressed buffer per +response (fine for typical JSON; for multi-MB single-shot payloads, +hand `compress_handler` a `stream(response, chunks)` instead). Brotli +is opt-in (`pip install localpost[http-compress]`); if `"br"` is in +`algorithms` without the extra, `compress_handler` raises +`ImportError` at construction time. See +[`examples/http/compressed_api.py`](../../examples/http/compressed_api.py). ### `localpost.http.flask` -Native Flask adapter — optional extra `[http-flask]`. Bypasses WSGI on both -sides: drives Flask's pipeline directly and streams the Werkzeug `Response` -straight to h11. See [`flask.py`](flask.py). - -| Symbol | Notes | -| ----------------------------------------- | ------------------------------------------------------------------- | -| `flask_handler(app)` | Flask → `RequestHandler` | -| `flask_server(config, app)` | Hosted service serving a Flask app (selector-thread, no pool) | - -### `localpost.http.router_sentry` - -Sentry tracing wrapper for `Router`. Optional extra `[http-sentry]`. No Flask -dependency — use this with the native `Router` + `http_server` flow. - -| Symbol | Notes | -| ----------------------------------------------- | ---------------------------------------------------------------------- | -| `sentry_router_handler(router, *, op="http.server")` | Wraps `router.as_handler()` in a Sentry transaction per request. | - -Transaction is named `"METHOD /books/{id}"` (the URI template, low cardinality) -on a match, or `"METHOD /raw/path"` on a miss. `http.method`, `http.url`, -`http.response.status_code` are recorded. Spans started inside the handler -attach to the request transaction. - -### `localpost.http.flask_sentry` - -Sentry tracing wrapper for the native Flask adapter. Requires both -`[http-flask]` and `[http-sentry]`. - -| Symbol | Notes | -| ----------------------------------------------- | ---------------------------------------------------------------------- | -| `sentry_flask_handler(app, *, op="http.server")` | Wraps Flask in a Sentry transaction that covers the entire request, **including response-body streaming**. | - -Why this exists: Sentry's stock `FlaskIntegration` ends the transaction when -the WSGI `wsgi_app` returns — *before* the body is iterated. Spans / errors -inside a streaming generator land outside the request transaction (or are +Native Flask adapter — optional extra `[http-flask]`. `flask_handler(app)` +turns a Flask app into a `RequestHandler`; `flask_server(config, app)` +hosts it as a service (selector-thread, no pool). Bypasses WSGI on both +sides: drives Flask's pipeline directly and streams the Werkzeug +`Response` straight to h11. + +### `localpost.http.router_sentry` / `localpost.http.flask_sentry` + +Sentry tracing wrappers. `sentry_router_handler(router, *, op=...)` +wraps a `Router` in a Sentry transaction per request — transaction +named `"METHOD /books/{id}"` (the URI template, low cardinality) on a +match, or `"METHOD /raw/path"` on a miss. Optional extra +`[http-sentry]`. + +`sentry_flask_handler(app, *, op=...)` wraps the native Flask adapter. +Requires both `[http-flask]` and `[http-sentry]`. Sentry's stock +`FlaskIntegration` ends the transaction when the WSGI `wsgi_app` +returns — *before* the body is iterated. Spans / errors inside a +streaming generator land outside the request transaction (or are dropped). Because our Flask adapter holds the request context (and the -transaction) open through `response.iter_encoded()`, this fix-pack version -keeps everything on the same transaction. - -Transaction is named after Flask's `url_rule.rule` (e.g. `"GET /hello/"`) -once routing has matched. - -**Behavior differences from `wsgi_server`** (Flask served via `wrap_wsgi`): - -- Flask's **request context is active during response-body iteration**. A - generator returned from a view can use `flask.request`, `session`, `g` - without `@stream_with_context`. (`stream_with_context` still works, just - becomes a no-op.) -- `teardown_request` / `teardown_appcontext` run **after** the body is fully - sent (the opposite of standard WSGI Flask). Resources like DB sessions - stay alive for the duration of streaming. - -Trade-off: the adapter touches Werkzeug/Flask internals (`app.request_context`, -`app.full_dispatch_request`, `Response.iter_encoded`). Stable across Flask 3.x -but not a long-term contract. Use `wsgi_server` for framework-agnostic WSGI -(Django, Flask without the extras, anything else) or when you want to stay -on the documented public Flask API. - -### `localpost.http.config` - -| Symbol | Notes | -| ------------- | -------------------------------------------------- | -| `ServerConfig` | Frozen dataclass of server tuning parameters | -| `LOGGER_NAME` | `"localpost.http"` | +transaction) open through `response.iter_encoded()`, this fix-pack +version keeps everything on the same transaction. Behaviour +differences from `wsgi_server`: Flask's request context is active +during response-body iteration (a generator returned from a view can +use `flask.request`, `session`, `g` without `@stream_with_context`); +`teardown_request` / `teardown_appcontext` run **after** the body is +fully sent. Adapter touches Werkzeug/Flask internals (stable across +Flask 3.x but not a long-term contract) — use `wsgi_server` for +framework-agnostic WSGI. ### Hosting integration -| Symbol | Module | Notes | -| ------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | -| `http_server(config, handler, *, selectors=1, acceptor=False)` | `localpost.http._service` | `@hosting.service` — runs the server loop with `handler`. See **Threading topologies** below. | -| `wsgi_server(config, app, *, selectors=1, acceptor=False)` | `localpost.http._service` | Same, for a generic WSGI app. | -| `flask_server(config, app)` | `localpost.http.flask` | Native Flask — see `localpost.http.flask`. | -| `thread_pool_handler(inner)` | `localpost.http._pool` | Async CM. Yields a `RequestHandler` that runs `inner` on a shared worker thread (process-wide pool, workers spawned on demand, no concurrency cap). | - -#### Threading topologies - -`http_server` supports three accept-side shapes, all driving the same -`RequestHandler` (single-shot dispatch on headers-complete): - -| Configuration | Threads | When to use | -| ------------------------------------- | --------------------------------------------- | ----------- | -| Default (`selectors=1`) | 1 thread accepts, parses, and dispatches | Simple deployments; thread-pool wrapper handles concurrency on the request side | -| `selectors=N` | N independent selectors, each with its own listening socket via `SO_REUSEPORT` | Linux kernel-level connection load-balancing; free-threaded builds | -| `selectors=N, acceptor=True` | 1 acceptor thread + N worker selector threads | macOS / free-threaded targets where `SO_REUSEPORT` doesn't distribute evenly. Acceptor accepts each conn and round-robins it to a worker via the cross-thread op queue. | - -The server loop runs in a worker thread (`anyio.to_thread.run_sync`); shutdown -is driven by `lt.shutting_down` via `threadtools.check_cancelled()`. The -server hosts a single handler; whether that handler runs synchronously on the -selector thread or hops to a worker is the handler's choice. - -#### Composition pattern - -`http_server`, the `Router`, and `thread_pool_handler` are three orthogonal -concepts that you compose explicitly: +`http_server(config, handler, *, selectors=1, acceptor=False)` is the +`@hosting.service`-decorated wrapper. `wsgi_server(config, app, ...)` +is the same shape for a generic WSGI app. `flask_server(config, app)` +covers the native Flask adapter. `thread_pool_handler(inner)` is an +async CM that yields a `RequestHandler` running `inner` on a shared +worker thread (process-wide pool, workers spawned on demand, no +concurrency cap). -```python -from localpost.hosting import run_app, service -from localpost.http import ( - Routes, ServerConfig, http_server, thread_pool_handler, -) - - -@service -async def app(): - routes = Routes() +Threading topology (`selectors=N`, `acceptor=True`) is covered in +[docs/design/threading-topologies.md](../../docs/design/threading-topologies.md). - @routes.get("/hello/{name}") - def hello(ctx): ... # plain RequestCtx → Response handler +## Sync vs. async surface - config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(routes.build().as_handler()) as h: - async with http_server(config, h): - yield +`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side +and the terminal write methods. A handler that touches only the core +surface is portable between transports. The full asymmetry table and +why each member differs lives in +[docs/design/connection-model.md](../../docs/design/connection-model.md#sync-vs-async-request-context-surface). +## Cancellation -run_app(app()) -``` +`HTTPReqCtx.disconnected` is a pull-style poll for peer-gone, mirroring +`AsyncHTTPReqCtx.disconnected`. Native backends do a non-blocking +`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket and stick `True` +once seen; the WSGI bridge always returns `False` (no socket handle). -What this gives you: - -- **404 / 405 stay on the selector thread.** When the `Router` is the handler - (wrapped or not), unmatched paths and method mismatches go through - `_send_plain` inline — no worker hop. -- **Matched routes run wherever you want.** Wrap the entire router with - `thread_pool_handler` (above) to run all matched handlers on workers; pass - the router directly to `http_server` to keep them all on the selector; - more granular per-route control is the user's composition problem (today - there is no per-route pool API). -- **No concurrency cap on `http_server` or the pool.** The pool dispatches - every request onto a process-wide `ThreadTaskGroup`; workers are spawned - on demand and reused across all `thread_pool_handler` instances in the - process. There is no admission gate and no 503-on-overflow — backpressure - is the deployment's concern (front-LB / OS thread limits). +For sync handlers without `ctx` in scope, `check_cancelled()` raises +`RequestCancelled` if the client disconnected (detected via +non-blocking `MSG_PEEK`) or the hosted service is shutting down. Call +periodically in long-running handlers. ## Design -### Sync handlers only — no async - -`RequestHandler` is `Callable[[HTTPReqCtx], None]`, sync-only. **This is -intentional and not a planned extension.** - -The whole package is built around blocking sockets: the selector accepts -connections, parses HTTP/1.1 with h11, and either dispatches the request -synchronously on the selector thread (e.g. for a 404 from `Router`) or -hands it off to a worker thread (when the user composes -`thread_pool_handler` into the handler chain). - -If you need an async server, use one of the ASGI servers that already exist -(uvicorn, hypercorn, granian, …) — the `localpost.hosting` adapters in -`localpost.hosting.services/` plug them in cleanly. There is no need for -this package to grow a second, parallel async path. - -### Three orthogonal concerns - -- **Handler** — `Callable[[HTTPReqCtx], None]`. Immediate by default - (runs on whichever thread invokes it). The thread-pool variant is just - a wrapper that borrows the connection, queues it, and runs `inner` on - a worker. -- **Router** — itself an immediate handler. Runs `_match()` inline on the - calling thread; 404/405 are sent via `_send_plain` and the conn re-tracks - via the existing connection loop. Matched routes call the registered - per-route handler — which the user can choose to pool or not. -- **`http_server`** — hosting integration only. Owns the AnyIO bridge, - drives `server.run()` in a thread, watches `lt.shutting_down`. Doesn't - know about pools, doesn't know about routing. - -This split means the simple case (all-immediate handlers) doesn't pay for a -worker pool, and the Router's 404/405 path doesn't either even when the -matched routes run on workers. - -### Two-state connection model - -A connection is either **TRACKED** (registered in the selector — selector -owns the fd, parser, and I/O) or **BORROWED** (a worker thread holds the -parser + socket; fd is unregistered). The dispatcher unregisters before -handing off to a worker (`stop_tracking`); `finish_response` re-registers -via `_maybe_give_back`. Two states, no third "watchdog" mode, no shared -mode field for threads to race on. - -``` - accept() - │ - ▼ - ConnHandler builds conn, - routes to a selector via - track() or post_track() - │ - ▼ - ┌──────────────────────┐ ┌──────────┐ - │ TRACKED │ close │ │ - │ fd registered ├────────▶│ CLOSED │ - │ selector owns I/O │ │ │ - └──┬───────────────────┘ └──────────┘ - │ ▲ ▲ - borrow │ │ _maybe_give_back │ close - ▼ │ │ - ┌──────────────────────┐ │ - │ BORROWED │ │ - │ fd unregistered ├───────────────┘ - │ worker owns I/O │ - └──────────────────────┘ -``` - -Both topologies (single selector and acceptor + N worker selectors) -funnel through the same diagram; only the entry edge differs: - -| From → to | Method | Caller thread | Mechanism | -| ------------------------ | -------------------- | ----------------- | ------------------------------------------------ | -| accept → TRACKED | `Selector.track` | selector | inline `selectors.register` (TrackHere topology) | -| accept → TRACKED | `Selector.post_track`| acceptor | op queue + wakeup pipe (RoundRobinAcceptor) | -| TRACKED → BORROWED | `stop_tracking` | selector | inline `selectors.unregister` | -| BORROWED → TRACKED | `Selector.track` | worker | op queue + wakeup pipe | -| TRACKED → CLOSED | `BaseHTTPConn.close` | selector | inline (idle / keep-alive / error) | -| BORROWED → CLOSED | `close` + `post_close` | worker | op queue + wakeup pipe (`_fd_to_key` cleanup) | - -The op queue + wakeup pipe is the only cross-thread synchronisation edge -(the `os.write` to the wakeup pipe is a full memory barrier). After -`track()` returns, anything the worker did to the conn's parser -(`h11.Connection.send` / `httptools.HttpRequestParser.feed_data`) is -visible to the selector. After `stop_tracking()` returns, anything the -selector did is visible to the worker. The parser is never touched -concurrently from two threads. - -### Pull-based client-disconnect detection - -While the worker holds a borrowed connection, client disconnects are -detected on demand: `check_cancelled()` does a non-blocking -`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. `b""` means peer -FIN — `RequestCancelled` is raised. Handlers that do regular I/O surface -disconnects via `EPIPE` / `ECONNRESET` naturally; handlers that compute -without I/O should call `check_cancelled()` periodically (same contract as -service-shutdown cancellation). - -This replaces an earlier push-based design where the selector kept the -socket registered in a third "watchdog" mode and fired EOF events. That -worked but introduced a 3-way state machine with cross-thread races. The -pull-based variant collapses to two states and one syscall per -`check_cancelled()` call. - -## Server backends - -Two parser implementations live side-by-side. They share the listening -socket, selector loop, op queue, stale-conn sweep, and shutdown -coordination (everything in `_base.py`). They differ only in how they -drive the parser: - -| `ServerConfig.backend` | Parser | Extra | Notes | -| ---------------------- | ----------- | ---------------- | ------------------------------- | -| `"h11"` *(default)* | h11 | `[http-server]` | pure Python, readable | -| `"httptools"` | httptools | `[http-fast]` | C-based llhttp; faster header parsing | - -There is one entry point — `start_http_server(config, handler)` — and -one hosted-service wrapper — `http_server(config, handler)`. The parser -is selected via `ServerConfig.backend`: - -```python -from localpost.http import ServerConfig, start_http_server - -with start_http_server( - ServerConfig(backend="httptools"), my_handler -) as server: - while True: - server.run() -``` - -Pick whichever fits — handler code is identical. Both populate the same -neutral `Request` / `NativeResponse` types from `localpost.http`. The -two implementations are intentionally **not** unified behind a parser -Protocol: h11 is pull-events + parse/serialize, httptools is -push-callbacks + parse-only, and forcing one shape over both restricts -the faster backend without buying anything. - -httptools backend caveats (initial scope): -- `Content-Length` response bodies only (chunked transfer-encoding is a - follow-up). `Router` and `wrap_wsgi` already set `Content-Length`, - so this matches today's behaviour. -- HTTP Upgrade negotiation surfaces as 400 Bad Request — revisit if/when - WebSockets come back on the roadmap. - -For perf context, see -[`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md). - -## How is it different from… - -- **Flask** — Flask is web-first (templates, Jinja, sessions). `localpost.http` - is a low-level server with a small `HttpApp` framework on top — handy for - JSON APIs, but no opinions on templating or serialization. -- **FastAPI** — FastAPI is async, Pydantic-only, OpenAPI-only, and ships a - dependency-injection system. `localpost.http` is sync, has no opinions on - serialization, and is small enough to read in one sitting. +The design rationale lives in `docs/design/`: + +- [connection-model.md](../../docs/design/connection-model.md) — + dispatch chain, two-state TRACKED/BORROWED machine, pull-based + disconnect detection, sync-vs-async asymmetry. +- [threading-topologies.md](../../docs/design/threading-topologies.md) + — `selectors=N` / acceptor topology, composition pattern, three + orthogonal concerns (handler / router / `http_server`). +- [server-backends.md](../../docs/design/server-backends.md) — h11 and + httptools coexistence, why no unified parser Protocol, httptools + caveats. +- [request-body-handling.md](../../docs/design/request-body-handling.md) + — the `ctx.receive(size)` contract across native sync, WSGI, ASGI, + and RSGI. +- [deployment-topologies.md](../../docs/design/deployment-topologies.md) + — hosted services *inside* `run_app` (uvicorn / hypercorn) vs Granian + as a process supervisor that runs the host *inside* its workers. + +The native server is sync-only by design — async transports plug in via +`localpost.http.asgi.to_asgi` rather than growing a parallel async +request path inside this module. ## See also From 55568c86a9e79ae4097d5293d27399b581d05330 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 01:07:53 +0400 Subject: [PATCH 219/286] docs: polish hosting/scheduler/di READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip Symbol|Notes API tables and Status banners from the three module READMEs. Same rationale as the http README pass: types/docstrings already expose the public surface, and mkdocstrings will autogen the API ref. - hosting: drop "Public API" table (11 rows). Replace the "Adapters for external servers" mini-table with a single paragraph. Trim "Host as RSGI for Granian" — keep the code snippet and a 5-line summary, link out to docs/design/deployment-topologies.md for the per-worker rationale and the uvicorn-vs-Granian asymmetry. - scheduler: drop "Public API" table (12 rows). - di: drop both "Public API" tables (10 + 2 rows). Replace with a short "Module layout" note flagging that localpost/di/__init__.py is empty and the symbols still live under localpost.di._services — top-level re-export pending (follow-up). openapi README untouched in this pass: its remaining tables (OpResult hierarchy, Argument resolvers, Sub-modules) are pedagogical / code-map content rather than symbol catalogs. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/di/README.md | 34 ++++------------ localpost/hosting/README.md | 75 ++++++++--------------------------- localpost/scheduler/README.md | 19 --------- 3 files changed, 25 insertions(+), 103 deletions(-) diff --git a/localpost/di/README.md b/localpost/di/README.md index 03b3ef3..385f057 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -1,7 +1,5 @@ # localpost.di -> **Status:** stable — public API is not expected to break in patch/minor releases. - Small, `.NET`-style inversion-of-control container: scoped service resolution with automatic constructor wiring. No decorators, no async, no "one interface, many implementations" — just a registry + provider + scope. @@ -90,32 +88,16 @@ See [`examples/di/basic.py`](../../examples/di/basic.py), Lets request-scoped code call `service_provider.resolve(...)` without plumbing the provider through. -## Public API - -All public symbols live under `localpost.di._services` (module layout is -being stabilised; expect a top-level `__init__` re-export): - -| Symbol | Notes | -| ----------------------------------------------- | ---------------------------------------- | -| `ServiceRegistry()` | Mutable registry of descriptors | -| `registry.register_instance(value, service_type=None, scope=None)` | Bind an already-built value | -| `registry.register(service_type, factory=None, scope=None, create_on_enter=False)` | Register a type / factory | -| `registry.app_scope()` | Context manager yielding a `ServiceProvider` | -| `ServiceProvider` (Protocol) | `resolve[T](type)`, `sp[Type]`, `create[T](type, **kw)` | -| `AppContext` | Default scope type | -| `service_provider` | `CurrentServiceProvider` proxy (contextvar-backed) | -| `ServiceDescriptor` | Frozen (service_type, scope_type, factory) tuple | -| `ResolutionContext` (Protocol) | Implement for custom scopes | -| `ServiceNotRegisteredError`, `NoResolutionContextError` | Exceptions | - -### Flask adapter — `localpost.di.flask` +## Module layout -| Symbol | Notes | -| ----------------------------------------- | -------------------------------------------- | -| `RequestContext` | Per-request scope | -| `init_app(app, registry, provider)` | Opens a `RequestContext` per Flask request; registers `flask.Request` for auto-injection | +Symbols currently live under `localpost.di._services` — `localpost/di/__init__.py` +is empty and a top-level re-export is pending. Treat the module path as +unstable until that lands. -A Quart adapter (`localpost/di/quart.py`) exists as a stub only. +The Flask adapter (`localpost.di.flask`) provides `RequestContext` (per-request +scope) and `init_app(app, registry, provider)` which opens a `RequestContext` +per Flask request and registers `flask.Request` for auto-injection. A Quart +adapter (`localpost/di/quart.py`) exists as a stub only. ## Defining a custom scope diff --git a/localpost/hosting/README.md b/localpost/hosting/README.md index 1c1e2ba..8e27fe4 100644 --- a/localpost/hosting/README.md +++ b/localpost/hosting/README.md @@ -1,7 +1,5 @@ # localpost.hosting -> **Status:** stable — public API is not expected to break in patch/minor releases. - Service lifecycle management and orchestration. A `service` is any async (or sync) function wrapped with a lifecycle — it goes through `Starting → Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child @@ -52,22 +50,6 @@ See `examples/host/finite_service.py`, `examples/host/channel.py`. - **`current_service()` / `current_app()`** — read-only views of the enclosing lifetimes via contextvars, without threading them through every call. -## Public API - -| Symbol | Where | Notes | -| -------------------------------- | -------------------- | ------------------------------------------ | -| `service` | decorator | Wrap a factory into a hosted service | -| `run_app(*services)` | entry point | Signal handling + `anyio.run` | -| `run(svc, parent=None)` | low-level | Run a single service, return exit code | -| `serve(svc, *, parent=None)` | low-level | Async CM yielding `ServiceLifetimeView` | -| `observe_services(*lifetimes)` | async CM | Wait for all to start; shut down on exit | -| `current_service()` | contextvar accessor | Raises if outside a hosting context | -| `ServiceLifetime` | dataclass | Mutable lifetime (internal-ish) | -| `ServiceLifetimeView` | dataclass | Read-only view + `observe()`, `shutdown()` | -| `ServiceState` | type alias | `Starting` \| `Running` \| `ShuttingDown` \| `Stopped` | -| `Starting` / `Running` / `ShuttingDown` / `Stopped` | dataclasses | Individual states | -| `shutdown_on_signal(*signals)` | middleware | SIGINT + SIGTERM by default | - ## Writing a service Four signatures are supported; `@service` picks the right adapter: @@ -100,26 +82,19 @@ def my_middleware(arg) -> Callable[[ServiceF], ServiceF]: ## Adapters for external servers (`services/`) -| Adapter | What it wraps | -| ------------- | ----------------------------------------- | -| `uvicorn.py` | `uvicorn.Server` (`config` input; reload and multi-worker disabled) | -| `hypercorn.py`| `hypercorn.asyncio.serve(app, config)` with a shutdown trigger | -| `grpc.py` | `grpc.aio.Server` with configurable grace period | -| `_asgi.py` | Shared ASGI lifespan helpers | - -Each adapter is decorated with `@hosting.service`, so it plugs into -`run_app()` the same way as any other service. +`uvicorn.py` wraps `uvicorn.Server` (with reload and multi-worker disabled); +`hypercorn.py` wraps `hypercorn.asyncio.serve(app, config)` with a shutdown +trigger; `grpc.py` wraps `grpc.aio.Server` with a configurable grace period; +`_asgi.py` holds shared ASGI lifespan helpers. Each adapter is decorated +with `@hosting.service`, so it plugs into `run_app()` the same way as any +other service. ## Host as RSGI for Granian -uvicorn / hypercorn run *inside* our process — they're hosted services -in `run_app()`. **Granian is the opposite direction**: it's a process -supervisor that spawns workers, then loads our app via its RSGI -interface. To deploy a hosted app (multiple services + an HTTP handler) -under Granian, flip the topology: the host *itself* implements RSGI, -and runs the full hosting lifecycle inside each Granian worker. - -`localpost.hosting.HostRSGIApp` does this: +`localpost.hosting.HostRSGIApp` runs the full hosting lifecycle (multiple +services + an HTTP handler) inside each Granian worker. Granian is a process +supervisor that spawns workers and loads our app via its RSGI interface, so +the topology flips: the host *itself* implements RSGI. ```python from localpost import hosting @@ -147,32 +122,16 @@ rsgi_app = hosting.HostRSGIApp( # granian --interface rsgi --workers 4 myapp:rsgi_app ``` -Per-worker behaviour: - -- `__rsgi_init__` schedules a long-lived task that enters - `hosting.serve(...)` for the supplied services. The task holds the - lifetime open for the worker's whole life. -- `__rsgi__` waits on a "ready" event before dispatching the first - request, so requests never see a half-started host. -- `__rsgi_del__` signals the lifecycle task to exit its - `serve()` block — services drain and stop in dependency order before - the worker finishes shutdown. - `shutdown_on_signal` is **not** applied — Granian owns signal handling; -`__rsgi_del__` is how shutdown reaches us. - -> **Per-worker side effects.** Granian spawns N workers; every service -> in `services=` runs in *each* worker. Cron-style "run once" jobs need -> either `--workers 1` or external coordination (DB lock, leader -> election). Process-shared state across workers doesn't exist — that's -> normal multi-process territory. +`__rsgi_del__` is how shutdown reaches us. Every service in `services=` +runs in *each* worker, so cron-style "run once" jobs need either +`--workers 1` or external coordination (DB lock, leader election). For the bridge layer (RSGI translation, no hosting integration), see -[`localpost.http.rsgi`](../http/README.md#localposthttprsgi). -The -[deployment-topologies design note](../../docs/design/deployment-topologies.md) -explains why uvicorn-as-a-hosted-service and Granian-as-a-supervisor -are asymmetric. +[`localpost.http.rsgi`](../http/README.md#localposthttprsgi). The +asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor +(plus per-worker lifecycle details) is covered in +[deployment-topologies.md](../../docs/design/deployment-topologies.md). ## Implementation notes diff --git a/localpost/scheduler/README.md b/localpost/scheduler/README.md index bfc918a..458d9cf 100644 --- a/localpost/scheduler/README.md +++ b/localpost/scheduler/README.md @@ -1,7 +1,5 @@ # localpost.scheduler -> **Status:** stable — public API is not expected to break in patch/minor releases. - Composable in-process task scheduler. Tasks are triggered by **conditions** (time intervals, cron expressions, completion of another task), and triggers are built up with operators — `//` to compose middleware, `>>` to extend a @@ -79,23 +77,6 @@ See [`examples/scheduler/`](../../examples/scheduler/) for more signal handling) or `localpost.hosting.serve(...)` (async CM, e.g. for embedding in a FastAPI lifespan). -## Public API - -| Symbol | Notes | -| ------------------------------- | --------------------------------------------- | -| `scheduled_task(trigger)` | Decorator that wraps a handler into a task | -| `Task` | Base task type | -| `ScheduledTask` | Runtime task object | -| `ScheduledTaskTemplate` | Trigger + middleware, not yet bound to handler | -| `Scheduler` | Groups multiple tasks. Itself a `ServiceF` — pass to `run_app(...)` / `serve(...)` from `localpost.hosting` | -| `every(period)` | Trigger: every `timedelta` or string ("3s") | -| `after(task)` | Trigger: after each success of `task` | -| `after_all(task)` | Trigger: after every run of `task` (success or failure) | -| `delay(factor)` | Middleware: sleep between events | -| `take_first(n)` | Middleware: emit first N, then stop | -| `trigger_factory_middleware` | Helper for writing middleware | -| `scheduler.cond.cron.cron(...)` | Cron-string trigger (needs `[cron]` extra) | - ## Writing a custom trigger A trigger is a frozen dataclass / callable that, given a `TaskGroup` and a From 929c57bf6577752bc7ce262f4f0d6872b4008e54 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 01:10:44 +0400 Subject: [PATCH 220/286] docs(adr): seed initial decision records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstrap docs/adr/ with four backfilled ADRs capturing decisions that were already visible in the prose but lacked a permanent home. ADRs are dated and immutable; new decisions get a new ADR rather than edits to an old one. Different from docs/design/, which describes the system as it is today and is edited freely. - index.md: what ADRs are, when to write one, numbering convention, table of records. - template.md: Nygard-format boilerplate (Status, Date, Context, Decision, Consequences). - 0001 AnyIO as async runtime — why AnyIO over raw asyncio, Trio portability, structured concurrency. - 0002 h11 and httptools coexist — why two parallel parser backends instead of a unified Protocol; the parsers don't fit one shape. - 0003 Sync-only native HTTP server — async traffic goes through the ASGI bridge to uvicorn/hypercorn/granian, not a parallel async path inside the native server. - 0004 Pull-based disconnect detection — why we replaced the earlier push-based "watchdog" mode with on-demand MSG_PEEK polling; collapses the connection state machine from three nodes to two. Cross-links from docs/design/connection-model.md and docs/design/server-backends.md to the relevant ADRs. docs/index.md gains an "Architecture decisions" section and a tightened "Plans" section describing the plan → ADR + design-note → CHANGELOG lifecycle. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/adr/0001-anyio-as-async-runtime.md | 59 ++++++++++++++ docs/adr/0002-h11-httptools-coexist.md | 73 +++++++++++++++++ docs/adr/0003-sync-native-http-server.md | 71 ++++++++++++++++ .../0004-pull-based-disconnect-detection.md | 80 +++++++++++++++++++ docs/adr/index.md | 51 ++++++++++++ docs/adr/template.md | 22 +++++ docs/design/connection-model.md | 7 +- docs/design/server-backends.md | 3 + docs/index.md | 13 ++- 9 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0001-anyio-as-async-runtime.md create mode 100644 docs/adr/0002-h11-httptools-coexist.md create mode 100644 docs/adr/0003-sync-native-http-server.md create mode 100644 docs/adr/0004-pull-based-disconnect-detection.md create mode 100644 docs/adr/index.md create mode 100644 docs/adr/template.md diff --git a/docs/adr/0001-anyio-as-async-runtime.md b/docs/adr/0001-anyio-as-async-runtime.md new file mode 100644 index 0000000..357a313 --- /dev/null +++ b/docs/adr/0001-anyio-as-async-runtime.md @@ -0,0 +1,59 @@ +# 0001 — AnyIO as async runtime + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +The framework needs an async runtime for the hosting layer, scheduler, +async HTTP handlers, and the ASGI/RSGI bridges. Two practical choices in +the Python ecosystem: + +- **Raw asyncio** — broadest compatibility, biggest ecosystem of + libraries, the Python "default." +- **AnyIO** — a thin abstraction over asyncio *and* Trio. Provides + structured concurrency primitives (`TaskGroup`, cancel scopes, + `to_thread`) with the same API on both backends. + +Forces in play: + +- We want **structured concurrency** as the default — task groups and + cancel scopes, not bare `asyncio.create_task` with manual lifetime + bookkeeping. +- We want users to be able to run on **Trio** if they prefer it. + `asyncio` is fine for many workloads, but Trio's strict structured + concurrency catches a class of bugs (orphaned tasks, surprising + cancellation propagation) that asyncio is more permissive about. +- The framework's surface is small and we control all internal async + code. We don't need to consume large async libraries that are + asyncio-specific. +- We pay a thin abstraction cost (one extra import layer) but gain + portability between two backends with one codebase. + +## Decision + +All async code under `localpost/` uses **AnyIO**, not raw `asyncio`. +The code runs on either asyncio or Trio depending on what +`anyio.run` is given (the hosting layer picks via +`choose_anyio_backend`, defaulting to asyncio for compatibility). + +We do not vendor an asyncio-only fast path. If a feature can't be +expressed in AnyIO, we either contribute upstream or design around it. + +## Consequences + +- Public API surfaces AnyIO types where backend-specific types would + otherwise leak (`anyio.abc.TaskGroup`, `anyio.Event`). +- Tests use `anyio_mode = "auto"` in `pyproject.toml`, which runs every + async test under both asyncio and Trio. Catches portability bugs at + CI time. +- Documentation phrases everything in AnyIO terms ("structured + concurrency", "task group", "cancel scope") rather than asyncio-isms + ("event loop", "Task", "shield"). +- Some asyncio-only third-party libraries (uvicorn, hypercorn, + grpc.aio) are still consumed via adapters in + `localpost/hosting/services/` — those adapters explicitly pin the + backend to asyncio. +- We don't get the "use any random asyncio library" ergonomics. In + practice this rarely bites, because the framework's job is to host + user code, not to consume async libraries itself. diff --git a/docs/adr/0002-h11-httptools-coexist.md b/docs/adr/0002-h11-httptools-coexist.md new file mode 100644 index 0000000..b4827fc --- /dev/null +++ b/docs/adr/0002-h11-httptools-coexist.md @@ -0,0 +1,73 @@ +# 0002 — h11 and httptools coexist as parallel HTTP backends + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +The native HTTP server in `localpost.http` needs an HTTP/1.1 parser. +Two well-maintained choices in the Python ecosystem: + +- **h11** — pure Python, sans-IO, pull-based events + (`receive_data` → iterate events). Parses *and* serialises responses. + Easy to read; portable to free-threaded builds. +- **httptools** — a thin Cython wrapper over node.js's `llhttp`. + Push-based callbacks (`on_url`, `on_header`, `on_body`, + `on_message_complete`). Parse-only — the user serialises responses + themselves. Faster header parsing. + +We initially shipped only h11 (pure Python is friendlier to debug and +to free-threaded CPython). Profiling under realistic JSON-API +workloads showed parser overhead as a meaningful slice of the hot +path; httptools cuts it materially. + +Two viable shapes for adding httptools: + +1. **Unify behind a parser Protocol** — define an abstract parser + interface, implement it on top of both h11 and httptools, switch + via config. +2. **Two parallel backends** — both parsers live as full + implementations, sharing only what already factors cleanly + (selector loop, op queue, stale-conn sweep, shutdown + coordination). + +Forces: + +- The two parsers don't fit one shape. h11 is pull-events + parse+serialise; httptools is push-callbacks parse-only. Forcing + one Protocol over both either hides h11's serialiser (we'd write + our own — duplicating h11's work) or papers over httptools's + callback model (we'd buffer events into a fake pull queue — + defeating the perf gain). +- The shared concerns (selector, accept policy, shutdown) genuinely + factor — they're already in `_base.py`. The non-shared concerns + (parser drive loop, response framing) genuinely don't. +- We don't need to swap parsers at runtime. The choice is per-server + via `ServerConfig.backend`. + +## Decision + +Two parallel backends. They share `_base.py` (selector, op queue, +listening socket, stale-conn sweep, shutdown). They each have their +own connection class, parser drive loop, and response writer. +`ServerConfig.backend` selects between them at server construction. + +There is **no** abstract parser Protocol mediating between the two. +Code that wants to compose with both backends does so by depending +only on the neutral `Request` / `NativeResponse` types in +`localpost.http`, which both backends populate. + +## Consequences + +- h11 stays the default — pure Python, no extra deps. Users opt in + to httptools via the `[http-fast]` extra. +- The httptools backend has initial-scope limitations (`Content-Length` + response bodies only, HTTP Upgrade returns 400) — documented in + [docs/design/server-backends.md](../design/server-backends.md). + These are follow-ups, not architectural constraints. +- Adding a third backend (e.g. picohttpparser) means writing another + full backend, not implementing a Protocol. That's the cost we + accept for not paying the abstraction tax on the two we have. +- Hot-path code paths are duplicated across the two backends. The + duplication is finite (request reading, response writing) and the + shared base prevents drift in everything else. diff --git a/docs/adr/0003-sync-native-http-server.md b/docs/adr/0003-sync-native-http-server.md new file mode 100644 index 0000000..b8b2766 --- /dev/null +++ b/docs/adr/0003-sync-native-http-server.md @@ -0,0 +1,71 @@ +# 0003 — Native HTTP server stays sync-only + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +`localpost.http.RequestHandler` is `Callable[[HTTPReqCtx], None]` — +synchronous. The selector accepts connections, parses HTTP/1.1, and +either dispatches the request inline on the selector thread (e.g. for +a 404 from `Router`) or hands it off to a worker thread (when the user +composes `thread_pool_handler` into the chain). + +Recurring pressure to add an async path inside this server: + +- "What if I want async DB calls in my handler?" +- "Should `RequestHandler` accept either sync or async functions?" +- "Could we run the selector inside an event loop?" + +Forces: + +- Async HTTP servers in Python are a solved problem. uvicorn, + hypercorn, and granian are mature, well-tested, and out-perform + anything we'd build. Async users have first-class options today. +- We integrate with those servers via + `localpost.http.asgi.to_asgi(handler)`, which adapts an + `AsyncRequestHandler` (`Callable[[AsyncHTTPReqCtx], Awaitable[None]]`) + to ASGI 3. The hosting adapters in `localpost.hosting.services/` + plug them into `run_app` cleanly. +- Adding a parallel async path inside the native server would mean a + second connection state machine, a second parser drive loop, a + second body-reading contract — all maintained alongside the sync + ones. The selector-thread + thread-pool model is small precisely + because there's only one path. +- The sync server's design (blocking sockets, `selectors` poller, + TRACKED/BORROWED state machine) is what makes it small and + free-threaded-ready. An event loop inside it would change all of + that. + +## Decision + +The native server in `localpost.http` is sync-only. `RequestHandler` +will not be widened to accept async callables. There is **no plan** +to add an async path here. + +Async handlers are reached via the ASGI bridge +(`localpost.http.asgi.to_asgi`) plugged into uvicorn / hypercorn / +granian. The handler still expresses the same conceptual contract +(read request, write response) — just an async-flavoured Protocol +(`AsyncHTTPReqCtx`) instead of the sync one. + +Both Protocols share the data side (`request`, `body`, `attrs`, +addrs, `disconnected`) and the terminal write methods (`complete`, +`stream`, `sendfile`, `receive`), so a handler that only touches the +core surface is portable between transports — see +[`docs/design/connection-model.md`](../design/connection-model.md#sync-vs-async-request-context-surface). + +## Consequences + +- Anyone who needs an async HTTP server uses `to_asgi(handler)` + + uvicorn / hypercorn / granian. We point them there explicitly. +- The native server stays small (~540 lines of sync code, plus a + parallel httptools backend — see ADR-0002). Free-threaded CPython + builds are a clean target because there's no event loop to + reason about. +- We don't compete with uvicorn / hypercorn / granian on async perf. + We compete on small surface area, predictable threading, and + composability with the hosting layer. +- The framework's user-facing surface (`HttpApp` / `HttpAsyncApp`) + splits along sync/async like `httpx.Client` / `httpx.AsyncClient`. + Choose flavour per app; don't mix. diff --git a/docs/adr/0004-pull-based-disconnect-detection.md b/docs/adr/0004-pull-based-disconnect-detection.md new file mode 100644 index 0000000..fa5e4fe --- /dev/null +++ b/docs/adr/0004-pull-based-disconnect-detection.md @@ -0,0 +1,80 @@ +# 0004 — Pull-based client-disconnect detection + +- **Status:** Accepted +- **Date:** 2026-05-07 (backfilled — decision predates the ADR practice) + +## Context + +When a worker thread holds a borrowed connection (the BORROWED state +in [`docs/design/connection-model.md`](../design/connection-model.md)), +we still need to detect that the client closed the socket. Two +reasons: + +- Long-running handlers (SSE generators, large computed responses) + should short-circuit when the client is gone, rather than burn CPU + / DB time producing data nobody will read. +- The hosted service's shutdown signal needs to reach in-flight + requests so they can cancel cleanly. + +Originally the server used a **push-based** design. While a worker +held a borrowed connection, the selector kept the socket registered +in a third connection mode — call it "watchdog" — separate from +TRACKED and BORROWED. The selector watched only for read-readiness +on the watchdog socket; when it fired, the selector inferred peer +FIN (no more bytes were expected from the client mid-response) and +posted a disconnect event to the worker. + +Forces in play: + +- The worker and selector both touch the same socket. The watchdog + mode meant a third concurrent reader, which created races: the + worker reading body bytes while the selector polled the same fd + could trigger spurious "disconnected" callbacks, or worse, race + the actual response write. +- The state machine grew a third node and several new edges. Every + transition had to handle "what if a watchdog event fires *during* + this transition" — fiddly, and a place we shipped real bugs. +- We were paying selector-side syscalls and book-keeping for a + signal that's only consulted occasionally inside long handlers. + The workload that benefits most (SSE generators) already loops + per-event; making the check explicit and on-demand fits naturally. + +## Decision + +Disconnect detection moves to a **pull-based** model. While a worker +holds a borrowed connection: + +- `HTTPReqCtx.disconnected` does a non-blocking + `recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket. `b""` + means peer FIN; the flag is sticky once `True`. +- `check_cancelled()` raises `RequestCancelled` if the client + disconnected (same `MSG_PEEK` check) or the hosted service is + shutting down. Sync handlers without `ctx` in scope can call it + from anywhere on the request thread. +- Handlers doing regular I/O surface disconnects naturally via + `EPIPE` / `ECONNRESET` from the socket write — no explicit check + needed. + +The selector no longer has a "watchdog" mode. The connection has +two states (TRACKED / BORROWED) plus closed. + +## Consequences + +- The connection state machine collapses from three nodes to two. + Documented as the load-bearing diagram in + [`docs/design/connection-model.md`](../design/connection-model.md). +- One syscall per `check_cancelled()` call, but only when the + handler actually asks. Idle handlers pay nothing. +- The contract is symmetric across sync and async: sync uses + `MSG_PEEK`; async transports flip `ctx.disconnected` on + `http.disconnect`. Same field name, same semantics. +- The WSGI bridge has no socket handle, so it always reports + `disconnected = False` and surfaces disconnects via + `BrokenPipeError` from the host's per-chunk write. Documented as + an asymmetric corner of the surface table in the connection model + doc. +- Handlers that never poll and never write (rare — they'd have to + block on something else, like a queue) won't notice peer-gone. + That's fine: the same handler shape would also miss a service + shutdown signal, and the answer is the same — call + `check_cancelled()` periodically. diff --git a/docs/adr/index.md b/docs/adr/index.md new file mode 100644 index 0000000..22c4dc8 --- /dev/null +++ b/docs/adr/index.md @@ -0,0 +1,51 @@ +# Architecture Decision Records + +ADRs are dated, immutable notes about decisions made in this repo — +the *moments* and *trade-offs*, not the current shape of the code. +Once accepted, an ADR is not edited; if a decision is reversed or +refined, a new ADR is added that supersedes the previous one. + +This is different from `docs/design/`: design notes describe the +system *as it currently is* and are edited freely. ADRs describe +*why we got here*. + +## Format + +We use the [Nygard format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions): + +- **Status** — Proposed / Accepted / Superseded by ADR-NNNN +- **Date** — when the decision was accepted +- **Context** — what's going on, what forces are in play +- **Decision** — what we're going to do +- **Consequences** — what follows, good and bad + +See [`template.md`](template.md) for the boilerplate. + +## When to write one + +Add an ADR when: + +- A non-trivial direction was picked between viable alternatives, and + the rejected ones aren't obviously wrong. +- A constraint or invariant in the code only makes sense if you know + the history (e.g. why two parsers coexist, why the native server + is sync-only). +- A previously-accepted ADR is being reversed or narrowed. + +Don't add an ADR for things that are self-evident from the code, or +for purely tactical decisions (file naming, refactor shape). + +## Numbering + +Sequential, zero-padded to four digits, starting at `0001`. Once +issued, a number is never reused — even for superseded ADRs. Slug the +title in the filename: `0001-anyio-as-async-runtime.md`. + +## Records + +| # | Title | Status | +| ---- | ------------------------------------------------------------ | -------- | +| 0001 | [AnyIO as async runtime](0001-anyio-as-async-runtime.md) | Accepted | +| 0002 | [h11 and httptools coexist](0002-h11-httptools-coexist.md) | Accepted | +| 0003 | [Sync-only native HTTP server](0003-sync-native-http-server.md) | Accepted | +| 0004 | [Pull-based client-disconnect detection](0004-pull-based-disconnect-detection.md) | Accepted | diff --git a/docs/adr/template.md b/docs/adr/template.md new file mode 100644 index 0000000..ac0bab2 --- /dev/null +++ b/docs/adr/template.md @@ -0,0 +1,22 @@ +# NNNN — Title + +- **Status:** Proposed | Accepted | Superseded by ADR-NNNN +- **Date:** YYYY-MM-DD + +## Context + +What's the problem? What forces are in play (constraints, requirements, +prior decisions, external factors)? Keep this neutral — describe the +situation, not the answer yet. + +## Decision + +What did we decide to do? State it as a present-tense fact: "We use X." +"The native server stays sync-only." Include the alternatives that were +seriously considered and why they were rejected. + +## Consequences + +What follows from this decision? Both the good and the bad — what does +this make easier, what does it make harder, what new constraints does +it impose? List downstream changes that need to happen. diff --git a/docs/design/connection-model.md b/docs/design/connection-model.md index 3eb88fa..04f49e6 100644 --- a/docs/design/connection-model.md +++ b/docs/design/connection-model.md @@ -103,7 +103,8 @@ This replaces an earlier push-based design where the selector kept the socket registered in a third "watchdog" mode and fired EOF events. That worked but introduced a 3-way state machine with cross-thread races. The pull-based variant collapses to two states -and one syscall per `check_cancelled()` call. +and one syscall per `check_cancelled()` call. Full rationale in +[ADR-0004](../adr/0004-pull-based-disconnect-detection.md). ## Sync vs. async request-context surface @@ -129,3 +130,7 @@ informational responses (100 Continue / 102 Processing) are emitted by the server through that internal path. Handlers express responses declaratively via `complete()` (one-shot) or `stream(response, chunks)` (chunk iterator) — both portable across sync and async. + +Why the native server doesn't grow an async path of its own (and +why async traffic goes through the ASGI bridge instead) is covered +in [ADR-0003](../adr/0003-sync-native-http-server.md). diff --git a/docs/design/server-backends.md b/docs/design/server-backends.md index d161aca..f909304 100644 --- a/docs/design/server-backends.md +++ b/docs/design/server-backends.md @@ -36,6 +36,9 @@ push-callbacks + parse-only. Forcing one shape over both restricts the faster backend without buying anything portable, and the share that *could* be hoisted (`_base.py`) already is. +The full rationale, including alternatives we rejected, is in +[ADR-0002](../adr/0002-h11-httptools-coexist.md). + ## httptools backend caveats (initial scope) - `Content-Length` response bodies only — chunked transfer-encoding diff --git a/docs/index.md b/docs/index.md index 5bba9f5..378d6d6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,9 +37,18 @@ freely as the design evolves. process supervisor that runs the host *inside* its workers. Why the two cases are asymmetric and what `HostRSGIApp` does about it. +## Architecture decisions + +[`docs/adr/`](adr/index.md) holds dated, immutable records of the +non-trivial decisions in the codebase — the *moments* and +*trade-offs*, not the current shape. Read these when you want to +know *why* something is the way it is, especially if the rationale +isn't obvious from the code. + ## Plans Work-in-progress design plans live at the repo root under [`plans/`](../plans/) — e.g. RSGI deployment, the dynamic worker pool. -They're forward-looking; once a plan lands, its rationale moves into a -design note here and the plan file is removed. +They're forward-looking; once a plan lands, the *why* becomes an +ADR, the stable narrative becomes a design note here, and the plan +file is removed. From cba155990a3487496125a684ab82e4c28bfd0ecd Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 13:50:53 +0400 Subject: [PATCH 221/286] docs: bootstrap mkdocs site with end-user landing and module pages Adds a Material-themed mkdocs site mirroring the Docs URL already in pyproject.toml. Per-module docs are surfaced via include-markdown so the canonical READMEs next to the code stay the single source of truth. A small mkdocs hook rewrites repo-relative links (cross-module README, source files, examples) to either the corresponding site page or absolute GitHub URLs, so the same READMEs render correctly in both contexts. - pyproject.toml: docs dep group (mkdocs, mkdocs-material, mkdocs-include-markdown-plugin); INP001 per-file ignore for docs/. - justfile: docs-serve, docs-build. - mkdocs.yml: nav covering Home, Getting started, Modules, Design notes, ADRs. - docs/index.md: replaced the contributor-oriented map with an end-user landing (the original map's content is fully absorbed into the nav). - docs/getting-started.md: minimal scheduler quickstart + sync/async note. - docs/modules/*.md: thin includes of localpost//README.md. - docs/_hooks.py: link rewriter (cross-module README -> site page; source/examples -> GitHub blob/tree URLs). - .github/workflows/docs.yaml: build + gh-deploy on push to main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yaml | 31 ++++ docs/_hooks.py | 47 ++++++ docs/getting-started.md | 81 ++++++++++ docs/index.md | 110 +++++++------- docs/modules/di.md | 4 + docs/modules/hosting.md | 4 + docs/modules/http.md | 4 + docs/modules/openapi.md | 4 + docs/modules/scheduler.md | 4 + justfile | 8 + mkdocs.yml | 77 ++++++++++ pyproject.toml | 9 ++ uv.lock | 293 +++++++++++++++++++++++++++++++++++- 13 files changed, 621 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/docs.yaml create mode 100644 docs/_hooks.py create mode 100644 docs/getting-started.md create mode 100644 docs/modules/di.md create mode 100644 docs/modules/hosting.md create mode 100644 docs/modules/http.md create mode 100644 docs/modules/openapi.md create mode 100644 docs/modules/scheduler.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..1076ab6 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,31 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write # required for `mkdocs gh-deploy` to push to gh-pages + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # gh-deploy needs the full history of gh-pages + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Sync docs deps + run: uv sync --group docs + + - name: Build & deploy to gh-pages + run: uv run mkdocs gh-deploy --force --strict diff --git a/docs/_hooks.py b/docs/_hooks.py new file mode 100644 index 0000000..23afd3a --- /dev/null +++ b/docs/_hooks.py @@ -0,0 +1,47 @@ +"""MkDocs hook: rewrite repo-relative links to work on the docs site. + +The per-module READMEs and design notes use links like +``../../localpost/http/_base.py`` and ``../../examples/http/`` so they +render correctly when viewed on GitHub. Inside the rendered docs site +those targets don't exist (they're outside ``docs/``), so we rewrite +them here: + +- ``localpost//README.md`` (cross-module) -> ``modules/.md`` +- ``localpost/``, ``examples/``, ``benchmarks/`` + (source / example files) -> absolute GitHub URLs + +The rewrite runs after ``mkdocs-include-markdown-plugin`` has already +inlined per-module READMEs into ``docs/modules/*.md``. +""" + +from __future__ import annotations + +import re + +REPO = "https://github.com/alexeyshockov/localpost.py" +BRANCH = "main" + +_MODULES = ("hosting", "scheduler", "http", "di", "openapi") +_MODULE_README_RE = re.compile(r"\]\((?:\.\./)+localpost/(" + "|".join(_MODULES) + r")/README\.md(#[^)]*)?\)") +# Anything else under localpost/, examples/, benchmarks/ -> blob/tree URL on GitHub. +_REPO_PATH_RE = re.compile(r"\]\((?:\.\./)+(localpost|examples|benchmarks)/([^)]+)\)") + + +def _module_readme_sub(match: re.Match[str]) -> str: + module = match.group(1) + fragment = match.group(2) or "" + return f"](../modules/{module}.md{fragment})" + + +def _repo_path_sub(match: re.Match[str]) -> str: + top = match.group(1) + rest = match.group(2) + # Files (have a '.', or anchor) -> /blob/. Directories -> /tree/. + kind = "tree" if rest.endswith("/") else "blob" + return f"]({REPO}/{kind}/{BRANCH}/{top}/{rest})" + + +def on_page_markdown(markdown: str, **_: object) -> str: + markdown = _MODULE_README_RE.sub(_module_readme_sub, markdown) + markdown = _REPO_PATH_RE.sub(_repo_path_sub, markdown) + return markdown diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..f8a49be --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,81 @@ +# Getting started + +## Install + +```bash +pip install localpost +``` + +For the scheduler and HTTP examples below you'll want a couple of extras: + +```bash +pip install 'localpost[scheduler,http]' +``` + +See the [extras table on the home page](index.md#install) for the full list. + +## A scheduled task + +The scheduler triggers callables on **conditions** — `every(...)`, +`after(...)`, `cron(...)` — composed with operators. `run_app` from the hosting +module wires up signal handling and runs everything until SIGINT / SIGTERM. + +```python +import random +import sys + +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first + + +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") + + +if __name__ == "__main__": + sys.exit(run_app(task1, task2)) +``` + +What's happening: + +- `every("3s") // delay((0, 1))` — fire every 3 seconds, jittered by 0–1s. +- `after(task1) // take_first(3)` — fire when `task1` completes, take only its + first 3 emissions. +- `run_app(...)` — start every service in parallel, exit cleanly when they all + stop. + +## A sync function works too + +The scheduler accepts both sync and async callables. Sync ones are offloaded +to a thread pool via `anyio.to_thread`: + +```python +from localpost.scheduler import every, scheduled_task + + +@scheduled_task(every("10s")) +def collect_metrics(): # plain `def`, no `async` + ... +``` + +## Where to next + +- **[hosting](modules/hosting.md)** — service lifecycles, signal handling, + middleware, and adapters for Uvicorn / Hypercorn / gRPC. +- **[scheduler](modules/scheduler.md)** — full trigger reference, operators, + cron support. +- **[http](modules/http.md)** — the lightweight HTTP/1.1 server, WSGI / ASGI + bridges, router. +- **[di](modules/di.md)** — scoped IoC container. +- **[openapi](modules/openapi.md)** — typed HTTP framework with built-in + OpenAPI 3.2 generation. + +Working examples for every module live in +[`examples/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples) +in the repository. diff --git a/docs/index.md b/docs/index.md index 378d6d6..80a8fe2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,54 +1,56 @@ -# LocalPost docs - -Concept docs and deeper dives that don't fit in the per-module READMEs. -Layout is mkdocs-ready (one section per top-level dir); a static site -generator can be wired up later without moving anything. - -## Modules - -User-facing references live next to the code: - -- [`localpost.hosting`](../localpost/hosting/README.md) -- [`localpost.scheduler`](../localpost/scheduler/README.md) -- [`localpost.http`](../localpost/http/README.md) -- [`localpost.openapi`](../localpost/openapi/README.md) -- [`localpost.di`](../localpost/di/README.md) - -## Design notes - -Stable explanations of how the system works today and why. Edited -freely as the design evolves. - -- [Connection model](design/connection-model.md) — dispatch chain, - two-state TRACKED/BORROWED machine, pull-based client-disconnect - detection, and the sync-vs-async request-context asymmetry. -- [Threading topologies](design/threading-topologies.md) — single - selector vs `selectors=N` vs acceptor + N workers; the - handler/router/`http_server` composition pattern. -- [Server backends](design/server-backends.md) — h11 and httptools - coexistence, why they aren't unified behind a parser Protocol, - httptools caveats. -- [Request body handling across transports](design/request-body-handling.md) — - what `ctx.receive(size)` does on the native server, WSGI, ASGI, and - RSGI; how the pre-buffer / streaming distinction is a transport - choice, not a Protocol switch. -- [Deployment topologies](design/deployment-topologies.md) — uvicorn / - hypercorn run as hosted services *inside* `run_app`; Granian is a - process supervisor that runs the host *inside* its workers. Why the - two cases are asymmetric and what `HostRSGIApp` does about it. - -## Architecture decisions - -[`docs/adr/`](adr/index.md) holds dated, immutable records of the -non-trivial decisions in the codebase — the *moments* and -*trade-offs*, not the current shape. Read these when you want to -know *why* something is the way it is, especially if the rationale -isn't obvious from the code. - -## Plans - -Work-in-progress design plans live at the repo root under -[`plans/`](../plans/) — e.g. RSGI deployment, the dynamic worker pool. -They're forward-looking; once a plan lands, the *why* becomes an -ADR, the stable narrative becomes a design note here, and the plan -file is removed. +# LocalPost + +A small async Python framework for long-running processes: + +- **hosting** — service lifecycle + orchestration (start / stop / signals) +- **scheduler** — in-process, composable task scheduler +- **http** — lightweight sync HTTP/1.1 server (h11, ~400 LOC) +- **di** — `.NET`-style scoped IoC container + +Built on [AnyIO](https://anyio.readthedocs.io/) — runs on **asyncio** *and* +**Trio**. Python 3.12+. + +LocalPost is not a monolith: each module is usable on its own. Pick what you +need. + +## Install + +```bash +pip install localpost +``` + +Optional extras turn on individual subsystems: + +| Extra | Adds | +| ---------------- | ------------------------------------------------------- | +| `[scheduler]` | `humanize`, `pytimeparse2` — string durations | +| `[cron]` | `croniter` — cron-expression trigger | +| `[http]` | `h11` — HTTP server | +| `[http-fast]` | `httptools` — alternative C-based parser | +| `[http-compress]`| `brotli` — Brotli compression (gzip is stdlib) | +| `[openapi]` | `msgspec` — typed HTTP framework with OpenAPI 3.2 | +| `[rsgi]` | `granian` — RSGI bridge for Granian deployments | + +## Where to next + +- **[Getting started](getting-started.md)** — the 60-second tour. +- **Modules** — per-module references: + [hosting](modules/hosting.md), + [scheduler](modules/scheduler.md), + [http](modules/http.md), + [di](modules/di.md), + [openapi](modules/openapi.md). +- **Design notes** — how the system works today and why + ([connection model](design/connection-model.md), + [threading topologies](design/threading-topologies.md), …). +- **[ADRs](adr/index.md)** — dated, immutable records of non-trivial design + decisions. + +## Status + +Beta — actively developed. All four core modules have settled public APIs and +are not expected to break in patch or minor releases. See the +[CHANGELOG](https://github.com/alexeyshockov/localpost.py/blob/main/CHANGELOG.md) +for history. + +MIT licensed. diff --git a/docs/modules/di.md b/docs/modules/di.md new file mode 100644 index 0000000..b1be608 --- /dev/null +++ b/docs/modules/di.md @@ -0,0 +1,4 @@ +{% + include-markdown "../../localpost/di/README.md" + rewrite-relative-urls=true +%} diff --git a/docs/modules/hosting.md b/docs/modules/hosting.md new file mode 100644 index 0000000..0182d91 --- /dev/null +++ b/docs/modules/hosting.md @@ -0,0 +1,4 @@ +{% + include-markdown "../../localpost/hosting/README.md" + rewrite-relative-urls=true +%} diff --git a/docs/modules/http.md b/docs/modules/http.md new file mode 100644 index 0000000..5a8a93a --- /dev/null +++ b/docs/modules/http.md @@ -0,0 +1,4 @@ +{% + include-markdown "../../localpost/http/README.md" + rewrite-relative-urls=true +%} diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md new file mode 100644 index 0000000..56660b3 --- /dev/null +++ b/docs/modules/openapi.md @@ -0,0 +1,4 @@ +{% + include-markdown "../../localpost/openapi/README.md" + rewrite-relative-urls=true +%} diff --git a/docs/modules/scheduler.md b/docs/modules/scheduler.md new file mode 100644 index 0000000..2ff80a0 --- /dev/null +++ b/docs/modules/scheduler.md @@ -0,0 +1,4 @@ +{% + include-markdown "../../localpost/scheduler/README.md" + rewrite-relative-urls=true +%} diff --git a/justfile b/justfile index aaff876..f6ba507 100755 --- a/justfile +++ b/justfile @@ -89,6 +89,14 @@ bench-micro *args: why package: uv tree --invert --package {{ package }} +[doc("Serve docs locally with live reload")] +docs-serve: + uv run --all-groups --all-extras mkdocs serve + +[doc("Build docs to ./site")] +docs-build: + uv run --all-groups --all-extras mkdocs build + [doc("Find unused code with vulture (config in pyproject.toml). Pass extra args to override.")] deadcode *args: vulture {{ args }} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..9ce7036 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,77 @@ +site_name: LocalPost +site_description: >- + A small async Python framework for long-running processes: service hosting, + in-process task scheduling, and a lightweight HTTP server. +site_url: https://alexeyshockov.github.io/localpost.py/ +repo_url: https://github.com/alexeyshockov/localpost.py +repo_name: alexeyshockov/localpost.py +edit_uri: edit/main/docs/ + +docs_dir: docs + +theme: + name: material + features: + - navigation.sections + - navigation.top + - navigation.tracking + - content.code.copy + - content.action.edit + - search.suggest + - search.highlight + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode + +plugins: + - search + - include-markdown + +hooks: + - docs/_hooks.py + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - pymdownx.details + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - toc: + permalink: true + +not_in_nav: | + /adr/template.md + +nav: + - Home: index.md + - Getting started: getting-started.md + - Modules: + - hosting: modules/hosting.md + - scheduler: modules/scheduler.md + - http: modules/http.md + - di: modules/di.md + - openapi: modules/openapi.md + - Design notes: + - Connection model: design/connection-model.md + - Threading topologies: design/threading-topologies.md + - Server backends: design/server-backends.md + - Request body handling: design/request-body-handling.md + - Deployment topologies: design/deployment-topologies.md + - ADRs: + - Index: adr/index.md + - 0001 — AnyIO as async runtime: adr/0001-anyio-as-async-runtime.md + - 0002 — h11 + httptools coexist: adr/0002-h11-httptools-coexist.md + - 0003 — Sync-only native HTTP server: adr/0003-sync-native-http-server.md + - 0004 — Pull-based disconnect detection: adr/0004-pull-based-disconnect-detection.md diff --git a/pyproject.toml b/pyproject.toml index ef83fce..2269ade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -157,6 +157,13 @@ bench-openapi = [ # Pydantic v2 is what both peers drive; LocalPost tests its msgspec path. "pydantic ~=2.7", ] +docs = [ + "mkdocs ~=1.6", + "mkdocs-material ~=9.5", + # Surfaces per-module READMEs (localpost//README.md) on the docs site + # without duplication. + "mkdocs-include-markdown-plugin ~=7.0", +] [tool.coverage.run] omit = ["tests/*"] @@ -259,6 +266,8 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "examples/*" = ["T201"] "benchmarks/*" = ["T201"] +# Not a Python package; just a hook file mkdocs loads by path. +"docs/*" = ["INP001"] [tool.uv] build-backend.module-root = "" diff --git a/uv.lock b/uv.lock index 4f7e8c3..d8c7517 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -74,6 +96,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + [[package]] name = "brotli" version = "1.2.0" @@ -437,6 +468,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/11/66f0d176f578c512a917b925eab152d518b9981f7face129773463af780a/flask_openapi-5.0.0rc1-py3-none-any.whl", hash = "sha256:5cd00c6c26435a9e981c67b9580d5e30e1ee7557386752340289b11774691c65", size = 42894, upload-time = "2026-02-09T07:36:16.363Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.74.0" @@ -778,6 +821,9 @@ openapi-attrs = [ { name = "attrs" }, { name = "cattrs" }, ] +rsgi = [ + { name = "granian" }, +] scheduler = [ { name = "humanize" }, { name = "pytimeparse2" }, @@ -828,6 +874,11 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-include-markdown-plugin" }, + { name = "mkdocs-material" }, +] examples = [ { name = "fastapi-slim" }, ] @@ -851,13 +902,14 @@ requires-dist = [ { name = "brotli", marker = "extra == 'http-compress'", specifier = "~=1.1" }, { name = "cattrs", marker = "extra == 'openapi-attrs'", specifier = ">=24" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, + { name = "granian", marker = "extra == 'rsgi'", specifier = "~=2.7" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, { name = "httptools", marker = "extra == 'http-fast'", git = "https://github.com/MagicStack/httptools.git" }, { name = "humanize", marker = "extra == 'scheduler'", specifier = ">=3.0,<5.0" }, { name = "msgspec", marker = "extra == 'openapi'", specifier = "~=0.19" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "openapi", "openapi-attrs"] +provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "rsgi", "openapi", "openapi-attrs"] [package.metadata.requires-dev] bench = [ @@ -900,6 +952,11 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, ] +docs = [ + { name = "mkdocs", specifier = "~=1.6" }, + { name = "mkdocs-include-markdown-plugin", specifier = "~=7.0" }, + { name = "mkdocs-material", specifier = "~=9.5" }, +] examples = [{ name = "fastapi-slim", specifier = "~=0.128" }] tests = [ { name = "pytest", specifier = "~=9.0" }, @@ -914,6 +971,15 @@ tests-unit = [ { name = "pytest-mock", specifier = "~=3.14" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -977,6 +1043,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-include-markdown-plugin" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/2d/bdf1aee3f4f7b34148b0f62298b62f03415160cb2707f09503c99a0a7cd5/mkdocs_include_markdown_plugin-7.2.2.tar.gz", hash = "sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9", size = 25415, upload-time = "2026-03-29T15:15:14.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/a5/f6b2f0aa805dbda52f6265e9aff1450c8643195442facf29d475bdeba15d/mkdocs_include_markdown_plugin-7.2.2-py3-none-any.whl", hash = "sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1", size = 29528, upload-time = "2026-03-29T15:15:13.079Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + [[package]] name = "more-itertools" version = "11.0.2" @@ -1160,6 +1317,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1310,6 +1494,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -1420,6 +1617,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -1642,6 +1897,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8" From 4dc981a96facbb7d5694fbe9b6dfd7ea6908d714 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 15:41:54 +0400 Subject: [PATCH 222/286] docs: migrate from mkdocs to Zensical; promote module READMEs to site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the docs site to Zensical (Material team's successor to mkdocs). Zensical reads mkdocs.yml natively, but doesn't yet support the include-markdown plugin or mkdocs hooks, so the previous "include README, rewrite links via hook" mechanic is replaced with a clean separation: - Per-module reference docs live in docs/modules/.md (full content). - Per-module READMEs become short pointers — name, intro, one-snippet example, link to the docs site. Designed to grow richer on the site over time; READMEs remain the elevator pitch you see on GitHub. Tooling: - pyproject.toml: replace mkdocs / mkdocs-material / include-markdown with zensical >=0.0.40,<0.1 (pre-1.0; early-adopter risk accepted). - justfile: docs-serve / docs-build now invoke `zensical`. - mkdocs.yml: drop hooks, drop include-markdown plugin, drop not_in_nav (Zensical doesn't surface unlisted pages as warnings). Everything else (theme.features, edit_uri, palette, markdown_extensions) is preserved and works under Zensical. - docs/_hooks.py: removed (no longer referenced). Workflow: - .github/workflows/docs.yaml: `mkdocs gh-deploy` is gone in Zensical, so the workflow now runs `zensical build --clean` and uploads to GitHub Pages via actions/upload-pages-artifact + actions/deploy-pages. Manual one-off: change Pages source in repo Settings from "branch: gh-pages" to "GitHub Actions". Content: - docs/modules/{hosting,scheduler,http,di,openapi}.md: full module references, with cross-module / source / examples links rewritten by hand (cross-module -> sibling .md, source/examples -> absolute GitHub URLs). - docs/design/{server-backends,request-body-handling}.md: source-file links switched from repo-relative to absolute GitHub URLs so they resolve in the docs site (and remain correct on GitHub). Verified: `zensical build --clean --strict` produces zero warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yaml | 27 +- docs/_hooks.py | 47 --- docs/design/request-body-handling.md | 20 +- docs/design/server-backends.md | 2 +- docs/modules/di.md | 136 +++++++- docs/modules/hosting.md | 154 ++++++++- docs/modules/http.md | 436 +++++++++++++++++++++++- docs/modules/openapi.md | 477 ++++++++++++++++++++++++++- docs/modules/scheduler.md | 135 +++++++- justfile | 4 +- localpost/di/README.md | 115 +------ localpost/hosting/README.md | 133 +------- localpost/http/README.md | 413 +---------------------- localpost/openapi/README.md | 452 +------------------------ localpost/scheduler/README.md | 120 +------ mkdocs.yml | 7 - pyproject.toml | 8 +- uv.lock | 303 +++++------------ 18 files changed, 1487 insertions(+), 1502 deletions(-) delete mode 100644 docs/_hooks.py diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 1076ab6..4d8f486 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -6,26 +6,37 @@ on: workflow_dispatch: permissions: - contents: write # required for `mkdocs gh-deploy` to push to gh-pages + contents: read + pages: write + id-token: write concurrency: group: docs-${{ github.ref }} cancel-in-progress: true jobs: - build-and-deploy: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # gh-deploy needs the full history of gh-pages - - uses: astral-sh/setup-uv@v5 with: enable-cache: true - - name: Sync docs deps run: uv sync --group docs + - name: Build site + run: uv run zensical build --clean + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v4 + with: + path: site - - name: Build & deploy to gh-pages - run: uv run mkdocs gh-deploy --force --strict + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/_hooks.py b/docs/_hooks.py deleted file mode 100644 index 23afd3a..0000000 --- a/docs/_hooks.py +++ /dev/null @@ -1,47 +0,0 @@ -"""MkDocs hook: rewrite repo-relative links to work on the docs site. - -The per-module READMEs and design notes use links like -``../../localpost/http/_base.py`` and ``../../examples/http/`` so they -render correctly when viewed on GitHub. Inside the rendered docs site -those targets don't exist (they're outside ``docs/``), so we rewrite -them here: - -- ``localpost//README.md`` (cross-module) -> ``modules/.md`` -- ``localpost/``, ``examples/``, ``benchmarks/`` - (source / example files) -> absolute GitHub URLs - -The rewrite runs after ``mkdocs-include-markdown-plugin`` has already -inlined per-module READMEs into ``docs/modules/*.md``. -""" - -from __future__ import annotations - -import re - -REPO = "https://github.com/alexeyshockov/localpost.py" -BRANCH = "main" - -_MODULES = ("hosting", "scheduler", "http", "di", "openapi") -_MODULE_README_RE = re.compile(r"\]\((?:\.\./)+localpost/(" + "|".join(_MODULES) + r")/README\.md(#[^)]*)?\)") -# Anything else under localpost/, examples/, benchmarks/ -> blob/tree URL on GitHub. -_REPO_PATH_RE = re.compile(r"\]\((?:\.\./)+(localpost|examples|benchmarks)/([^)]+)\)") - - -def _module_readme_sub(match: re.Match[str]) -> str: - module = match.group(1) - fragment = match.group(2) or "" - return f"](../modules/{module}.md{fragment})" - - -def _repo_path_sub(match: re.Match[str]) -> str: - top = match.group(1) - rest = match.group(2) - # Files (have a '.', or anchor) -> /blob/. Directories -> /tree/. - kind = "tree" if rest.endswith("/") else "blob" - return f"]({REPO}/{kind}/{BRANCH}/{top}/{rest})" - - -def on_page_markdown(markdown: str, **_: object) -> str: - markdown = _MODULE_README_RE.sub(_module_readme_sub, markdown) - markdown = _REPO_PATH_RE.sub(_repo_path_sub, markdown) - return markdown diff --git a/docs/design/request-body-handling.md b/docs/design/request-body-handling.md index 64ad968..41f0a34 100644 --- a/docs/design/request-body-handling.md +++ b/docs/design/request-body-handling.md @@ -3,8 +3,10 @@ `localpost.http` exposes two surfaces for accessing the request body: `ctx.body: bytes` (the buffered body) and `ctx.receive(size) -> bytes` (read up to `size` bytes). Both are present on the sync -[`HTTPReqCtx`](../../localpost/http/_base.py) and async -[`AsyncHTTPReqCtx`](../../localpost/http/_async_base.py) Protocols. +[`HTTPReqCtx`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_base.py) +and async +[`AsyncHTTPReqCtx`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_async_base.py) +Protocols. This note explains what `receive(size)` does in each transport — and why the same Protocol cleanly covers four very different host servers. @@ -35,22 +37,24 @@ implements one strategy that matches what its host transport gave it. ### Native h11 (sync) The h11 parser exposes per-chunk events -([`server_h11.py:422`](../../localpost/http/server_h11.py)): +([`server_h11.py:422`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/server_h11.py)): `receive(size)` pumps `parser.next_event()`, calls `conn.receive(size)` (i.e. `recv` on the socket) when h11 says `NEED_DATA`, and returns the next `h11.Data` chunk. **It always reads from the wire** — there's no internal buffer. `ctx.body` gets populated by the **selector**, not by the ctx itself, -when a [`RequestHandler`](../../localpost/http/_base.py) returns a -[`BodyHandler`](../../localpost/http/_base.py) continuation. The +when a [`RequestHandler`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_base.py) +returns a +[`BodyHandler`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_base.py) +continuation. The selector reads the full body into `ctx.body` *before* invoking the continuation, then runs it. After that, `parser.their_state == h11.DONE`, so a follow-up `ctx.receive(...)` correctly returns `b""`. This is the JSON-API common case: handler decides "I need the body" by returning a `BodyHandler`, then reads `ctx.body` directly. -The streaming case ([`streaming_pool_handler`](../../localpost/http/_pool.py)) +The streaming case ([`streaming_pool_handler`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/_pool.py)) runs the handler on a borrowed conn *without* pre-buffering — the handler calls `ctx.receive(size)` to pull chunks off the wire as they arrive. Use it for streaming uploads / large bodies where buffering is @@ -58,7 +62,7 @@ undesirable. ### WSGI bridge (sync) -WSGI ([`wsgi.py`](../../localpost/http/wsgi.py)) doesn't expose +WSGI ([`wsgi.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/wsgi.py)) doesn't expose chunked input — the WSGI server has already buffered the body for us in `wsgi.input`. The bridge reads it once into `ctx.body`; `ctx.receive(size)` slices that buffer. There's no wire to read from @@ -66,7 +70,7 @@ on this side. ### ASGI bridge (async) -ASGI ([`asgi.py`](../../localpost/http/asgi.py)) hands us body chunks +ASGI ([`asgi.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/asgi.py)) hands us body chunks via `http.request` events on the receive channel. The bridge consumes them upfront (capped by `max_body_size`) into `ctx.body` before dispatch; `ctx.receive(size)` slices the buffered body, same shape as diff --git a/docs/design/server-backends.md b/docs/design/server-backends.md index f909304..905ac55 100644 --- a/docs/design/server-backends.md +++ b/docs/design/server-backends.md @@ -48,4 +48,4 @@ The full rationale, including alternatives we rejected, is in if/when WebSockets come back on the roadmap. For perf context, see -[`benchmarks/http/PERF_FINDINGS.md`](../../benchmarks/http/PERF_FINDINGS.md). +[`benchmarks/http/PERF_FINDINGS.md`](https://github.com/alexeyshockov/localpost.py/blob/main/benchmarks/http/PERF_FINDINGS.md). diff --git a/docs/modules/di.md b/docs/modules/di.md index b1be608..d12d9b9 100644 --- a/docs/modules/di.md +++ b/docs/modules/di.md @@ -1,4 +1,132 @@ -{% - include-markdown "../../localpost/di/README.md" - rewrite-relative-urls=true -%} +# localpost.di + +Small, `.NET`-style inversion-of-control container: scoped service resolution +with automatic constructor wiring. No decorators, no async, no "one interface, +many implementations" — just a registry + provider + scope. + +## Install + +Comes with core `localpost` — no extra needed. The Flask adapter requires +`flask` in your environment. + +## Quick start + +```python +from dataclasses import dataclass +from localpost.di._services import ServiceRegistry + + +@dataclass +class Config: + host: str + port: int + + +class Server: + def __init__(self, config: Config): + self.config = config + + +services = ServiceRegistry() +services.register_instance(Config(host="127.0.0.1", port=8080)) +services.register(Server) # auto-wires Config via __init__ + +with services.app_scope() as sp: + server = sp.resolve(Server) + print(server.config) +``` + +With cleanup (generator factory): + +```python +def create_db_pool(conf: Config): + pool = DBPool(conf.db_dsn) + try: + yield pool + finally: + pool.close() + + +services.register(DBPool, create_db_pool) +``` + +See [`examples/di/basic.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/di/basic.py), +[`basic_cleanup.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/di/basic_cleanup.py), +[`flask_app.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/di/flask_app.py). + +## Design goals (and non-goals) + +- Inspired by .NET `Microsoft.Extensions.DependencyInjection`. +- No `async` (for now). +- **Scope is defined by its type** (`AppContext`, `RequestContext`, …). +- **Only one registration per `(service_type, scope_type)` pair** (the opposite + of .NET's `IEnumerable` pattern). A type can still be registered + multiple times — once per scope. +- **Service Locator** style — no magic `@inject` decorator. Ask the provider + for what you need. +- Constructor dependencies are **auto-wired** from type hints when resolved + via the provider. +- Factories can be plain callables, or **generator functions** for + setup/teardown (enter on resolve, `finally` on scope exit). +- Instances are **lazily resolved** by default; use `create_on_enter=True` to + build them eagerly when a scope is entered. + +## Key concepts + +- **`ResolutionContext`** (Protocol) — anything with `enter(cm)`. `AppContext` + is the default app-wide scope; `RequestContext` (in the Flask adapter) is + per-request. Define your own for other lifetimes. +- **`ServiceRegistry`** — the mutable catalogue. Holds `ServiceDescriptor`s + keyed by `(service_type, scope_type)`. +- **`ServiceProvider`** — resolves services. The default provider walks the + scope chain (request → app) to find a descriptor. +- **Scope stack** — child scopes hold a reference to their parent provider, so + resolving a parent-scoped service from inside a child scope transparently + reuses it. +- **`service_provider` proxy** — a `CurrentServiceProvider` singleton that + delegates to whichever provider is active in the current `contextvar`. + Lets request-scoped code call `service_provider.resolve(...)` without + plumbing the provider through. + +## Module layout + +Symbols currently live under `localpost.di._services` — `localpost/di/__init__.py` +is empty and a top-level re-export is pending. Treat the module path as +unstable until that lands. + +The Flask adapter (`localpost.di.flask`) provides `RequestContext` (per-request +scope) and `init_app(app, registry, provider)` which opens a `RequestContext` +per Flask request and registers `flask.Request` for auto-injection. A Quart +adapter (`localpost/di/quart.py`) exists as a stub only. + +## Defining a custom scope + +1. Create a dataclass implementing `ResolutionContext`: + + ```python + @dataclass(frozen=True, eq=False, slots=True) + class JobContext: + ctx: ExitStack = field(default_factory=ExitStack) + def enter[T](self, cm): return self.ctx.enter_context(cm) + ``` + +2. Register services under it: `services.register(JobRepo, scope=JobContext)`. + +3. Open the scope when you start a job: + + ```python + from localpost.di._services import DefaultServiceProvider, scope + job_ctx = JobContext() + provider = DefaultServiceProvider(parent_provider, registry, job_ctx, JobContext) + with job_ctx.ctx, scope(provider): + run_job() + ``` + +The Flask adapter in +[`flask.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/di/flask.py) +is a compact reference. + +## See also + +- Examples: [`examples/di/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/di/) +- Core: [`_services.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/di/_services.py) diff --git a/docs/modules/hosting.md b/docs/modules/hosting.md index 0182d91..09c626a 100644 --- a/docs/modules/hosting.md +++ b/docs/modules/hosting.md @@ -1,4 +1,150 @@ -{% - include-markdown "../../localpost/hosting/README.md" - rewrite-relative-urls=true -%} +# localpost.hosting + +Service lifecycle management and orchestration. A `service` is any async (or +sync) function wrapped with a lifecycle — it goes through `Starting → +Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child +services in the same task group. + +## Quick start + +```python +import sys +import time +from localpost.hosting import ServiceLifetime, run_app, service + + +@service +def a_sync_service(): + def svc(lt: ServiceLifetime): + print("Service started") + lt.set_started() + print("Service running") + time.sleep(5) + print("Service is done") # host stops when all services stop + return svc + + +if __name__ == "__main__": + sys.exit(run_app(a_sync_service())) +``` + +`run_app()` wires `shutdown_on_signal()` for you (SIGINT / SIGTERM), runs the +service with AnyIO (picking asyncio or Trio via `choose_anyio_backend`), and +returns an exit code. + +See [`examples/host/finite_service.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/host/finite_service.py), +[`examples/host/channel.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/host/channel.py). + +## Key concepts + +- **`ServiceLifetime`** — the handle passed to every service. Exposes + `started`, `shutting_down`, `stopped` events (as `Event` / `EventView`), + an anyio `TaskGroup` (`lt.tg`) for spawning child tasks, and `defer` / + `adefer` to stash context managers / closable resources. +- **`ServiceState`** — the union `Starting | Running | ShuttingDown | Stopped` + (immutable dataclasses). Accessible via `lt.view.state`. +- **`@service` decorator** — turns a factory (returning a service function + or async generator) into a `_ResolvedService`, a callable that doubles as + an async context manager. +- **Middleware** — ordinary function decorators over the service function. + Examples: `shutdown_on_signal(*signals)`, `start_timeout(seconds)`. +- **`current_service()` / `current_app()`** — read-only views of the enclosing + lifetimes via contextvars, without threading them through every call. + +## Writing a service + +Four signatures are supported; `@service` picks the right adapter: + +1. **Async function** — `async def svc(lt: ServiceLifetime) -> None` +2. **Sync function** — runs in a worker thread via `to_thread.run_sync` +3. **Async generator** — `@service async def factory(): setup; yield; teardown` + (wrapped with `@asynccontextmanager`; `lt.set_started()` is called after + `yield`-in). +4. **Factory returning one of the above** + +Always call `lt.set_started()` once your service is ready. Services that +never call it block `observe_services` forever. + +## Writing middleware + +Middleware is a plain decorator over `ServiceF = Callable[[ServiceLifetime], +Awaitable[None]]`. Reference: `shutdown_on_signal` in `middleware.py`: + +```python +def my_middleware(arg) -> Callable[[ServiceF], ServiceF]: + def decorator(func: ServiceF) -> ServiceF: + @wraps(func) + def wrapper(lt: ServiceLifetime) -> Awaitable[None]: + lt.tg.start_soon(my_background_task, lt.view) + return func(lt) + return wrapper + return decorator +``` + +## Adapters for external servers (`services/`) + +`uvicorn.py` wraps `uvicorn.Server` (with reload and multi-worker disabled); +`hypercorn.py` wraps `hypercorn.asyncio.serve(app, config)` with a shutdown +trigger; `grpc.py` wraps `grpc.aio.Server` with a configurable grace period; +`_asgi.py` holds shared ASGI lifespan helpers. Each adapter is decorated +with `@hosting.service`, so it plugs into `run_app()` the same way as any +other service. + +## Host as RSGI for Granian + +`localpost.hosting.HostRSGIApp` runs the full hosting lifecycle (multiple +services + an HTTP handler) inside each Granian worker. Granian is a process +supervisor that spawns workers and loads our app via its RSGI interface, so +the topology flips: the host *itself* implements RSGI. + +```python +from localpost import hosting +from localpost.openapi import HttpAsyncApp +from localpost.scheduler import every, scheduled_task + + +app = HttpAsyncApp() + + +@app.get("/") +async def root() -> str: + return "ok" + + +@scheduled_task(every(seconds=5)) +async def heartbeat() -> None: ... + + +rsgi_app = hosting.HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, +) + +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +`shutdown_on_signal` is **not** applied — Granian owns signal handling; +`__rsgi_del__` is how shutdown reaches us. Every service in `services=` +runs in *each* worker, so cron-style "run once" jobs need either +`--workers 1` or external coordination (DB lock, leader election). + +For the bridge layer (RSGI translation, no hosting integration), see +[`localpost.http.rsgi`](http.md#localposthttprsgi). The asymmetry between +uvicorn-as-a-hosted-service and Granian-as-a-supervisor (plus per-worker +lifecycle details) is covered in +[deployment topologies](../design/deployment-topologies.md). + +## Implementation notes + +- A service may spawn child services via `lt.start(child_svc)` — they run in + `lt.tg`, so when the parent's service function returns, the child task group + is cancelled. If you want the children to complete, `await` them explicitly + before returning. +- `lt.defer(cm)` / `await lt.adefer(acm)` tie a resource's lifetime to the + service — it's released when the service stops. + +## See also + +- Examples: [`examples/host/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/host/) +- Middleware source: [`middleware.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/hosting/middleware.py) +- Core: [`_host.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/hosting/_host.py) diff --git a/docs/modules/http.md b/docs/modules/http.md index 5a8a93a..2b9d091 100644 --- a/docs/modules/http.md +++ b/docs/modules/http.md @@ -1,4 +1,432 @@ -{% - include-markdown "../../localpost/http/README.md" - rewrite-relative-urls=true -%} +# localpost.http + +A small synchronous HTTP/1.1 server built on +[h11](https://h11.readthedocs.io/), plus a URI-template router, WSGI / ASGI +bridges, and a small framework (`HttpApp`) on top. Three layers, each +usable on its own: + +- **Server**: `start_http_server` accepts connections, parses HTTP + (h11 by default; httptools opt-in via `ServerConfig.backend`), + dispatches to a `RequestHandler`. ~540 lines of sync code. +- **Router**: thin URI-template dispatcher. Matches the request, + attaches a `RouteMatch` to `ctx.attrs[RouteMatch]`, delegates to + the registered handler. 404 / 405 inline. +- **HttpApp**: decorator-driven framework — parameter injection, + response conversion, worker-pool dispatch, middleware. + +Pair with `localpost.hosting` for lifecycle management, or run any of +the three layers standalone. + +## Scope + +In-process only (no `fork` / `spawn`); for multi-core fanout, run multiple +processes under an external supervisor. Sync server only — async handlers +are reached via `localpost.http.asgi.to_asgi(handler)` plugged into +uvicorn / hypercorn / granian. CPython 3.12+ is the baseline; free-threaded +builds are an accepted target. + +The hot path is tuned for the JSON-API common case: handlers can reject +before the body (no worker hop, `Content-Length` pre-checked against +`max_body_size`); read the body explicitly via `read_body(ctx)` / +`aread_body(ctx)` when needed; one-shot `complete()` flushes +status+headers+body in a single `sendall`. No HTTP/1.1 pipelining. + +## Install + +```bash +pip install localpost[http-server] # h11 backend (default, pure Python) +pip install localpost[http-server,http-fast] # also adds the httptools backend +``` + +## Quick start + +The recommended path is `HttpApp`: + +```python +import sys +from localpost.hosting import run_app +from localpost.http import HTTPReqCtx, ServerConfig +from localpost.http.app import HttpApp + + +app = HttpApp() + + +@app.get("/{name}") +def hello(name: str): + return f"Hello, {name}!" + + +@app.post("/{name}/profile") +def update_profile(ctx: HTTPReqCtx, name: str): + import json + from localpost.http import read_body + profile = json.loads(read_body(ctx)) + return {"updated": name, "profile": profile} + + +sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) +``` + +Or stay close to the wire — `start_http_server` directly: + +```python +import h11 +from localpost.http import HTTPReqCtx, ServerConfig, start_http_server + + +def simple_app(ctx: HTTPReqCtx): + ctx.complete( + h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), + b"Hello, World!\n", + ) + + +with start_http_server(ServerConfig(), simple_app) as server: + while True: + server.run() +``` + +Running under hosting: + +```python +import sys + +from localpost.hosting import run_app +from localpost.http import http_server, ServerConfig + +# `simple_app` from the Quick start above +sys.exit(run_app(http_server(ServerConfig(), simple_app))) +``` + +See [`examples/http/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/http/). + +## Key concepts + +- **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, + `keep_alive_timeout`, `max_body_size`. +- **`start_http_server(config, handler)`** — context manager; yields a + `Server` bound to a non-blocking listening socket with a `selectors` + poller. The handler is fixed for the server's lifetime. +- **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, + the raw socket, headers, and `complete(response, body)`. Request bodies + are streamed via `receive(n_bytes)`. +- **`RequestHandler = Callable[[HTTPReqCtx], None]`** — the handler + interface. `Server.run()` dispatches each accepted request to it. +- **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style + variables). `match(uri) → dict | None`. +- **`Routes`** — mutable builder. Accumulate routes via decorators + (`@routes.get("/path")`, `.post`, `.put`, `.delete`, `.patch`, `.add`), + then call `.build()` to compile into a `Router`. +- **`Router`** — immutable, compiled URI-template dispatcher. One regex + alternation over all templates, templates ordered by longest literal + prefix, `Allow` headers pre-rendered. Exposes `.as_handler()` (native + `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian + / etc.). Build via `routes.build()` or `Router.from_routes(routes)`. + +## Sub-modules + +User-facing prose for the bridges and middlewares. The connection +model, threading topologies, and backend rationale live in `docs/design/` +— see the [Design](#design) section. + +### `localpost.http.wsgi` + +`wrap_wsgi(app)` turns a WSGI app into a `RequestHandler`; +`to_wsgi(handler)` turns a `RequestHandler` into a WSGI app. + +### `localpost.http.asgi` + +The async sibling of `localpost.http.wsgi` — bridges between a foreign +async protocol (ASGI 3) and the framework's async request-context shape +(`AsyncHTTPReqCtx`, defined in `localpost.http`). `localpost.http` +itself doesn't ship an async server; this module plugs an +`AsyncRequestHandler` into uvicorn / hypercorn / granian / any ASGI 3 +server. + +`to_asgi(handler, *, max_body_size=1<<20)` wraps an `AsyncRequestHandler` +as an ASGI 3 app. Body bytes are pulled lazily via +`await ctx.receive(size)` (or `aread_body(ctx)` for the whole-body common +case); `Content-Length` is pre-checked against `max_body_size` when +present (413 before dispatch). + +```python +# myapp.py +from localpost.http import Response +from localpost.http.asgi import to_asgi + + +async def hello(ctx): + await ctx.complete(Response(200), b"hi") + + +asgi_app = to_asgi(hello) +``` + +```bash +uvicorn myapp:asgi_app +hypercorn myapp:asgi_app +granian --interface asgi myapp:asgi_app +``` + +For the framework-flavoured surface (decorators, OpenAPI generation, +typed handlers) on top of the same bridge, see +[`localpost.openapi.HttpAsyncApp`](openapi.md#async-flavour-httpasyncapp) +— `app.asgi()` is sugar over `to_asgi(self._build_async_handler())`. + +Cancellation: `ctx.disconnected` flips on `http.disconnect`. SSE +generators / long handlers poll it between events to short-circuit +cleanly. Body-handling contract across transports lives in +[request body handling](../design/request-body-handling.md). + +### `localpost.http.rsgi` + +The Granian-flavoured sibling of `localpost.http.asgi`. Same +`AsyncHTTPReqCtx` Protocol; different wire surface (RSGI exposes +richer per-method calls than ASGI's two-event response dance), and +different deployment topology (Granian is a process supervisor, not +an in-process server). Install: `pip install 'localpost[rsgi]'`. + +`to_rsgi(handler, *, max_body_size=1<<20)` wraps an +`AsyncRequestHandler` for `granian --interface rsgi`. Single eager +`proto.response_bytes` per `complete`; zero-copy +`proto.response_file_range` for `sendfile` when the file has a path; +chunked stream fallback otherwise. + +```python +# myapp.py +from localpost.http import Response +from localpost.http.rsgi import to_rsgi + + +async def hello(ctx): + await ctx.complete(Response(200), b"hi") + + +rsgi_app = to_rsgi(hello) +``` + +```bash +granian --interface rsgi myapp:rsgi_app +``` + +For framework-flavoured deployment, see +[`localpost.openapi.HttpAsyncApp.as_rsgi()`](openapi.md#async-flavour-httpasyncapp). +For deployments where the HTTP app shares a worker process with **other +hosted services** (scheduler / gRPC / custom workers), +[`localpost.hosting.HostRSGIApp`](hosting.md#host-as-rsgi-for-granian) +runs the full hosting lifecycle inside each Granian worker. The +asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor +is covered in +[deployment topologies](../design/deployment-topologies.md). + +### `localpost.http.static` + +`static_handler(root, *, prefix=b"/", cache_control=None, +index="index.html")` builds a `RequestHandler` that serves files under +`root` via `socket.sendfile()` — zero-copy from the page cache to the +socket. Designed for **CDN-fronted deployments**: pair with proper +`Cache-Control` headers and origin sees roughly one hit per file per +edge per cache lifetime, which is why we skip on-the-fly compression +(the CDN handles `gzip` / `br` at the edge). + +Behaviour: + +- **Methods**: `GET` and `HEAD`. Anything else returns 405 + `Allow: GET, HEAD`. +- **Resolution**: percent-decoded URL path (with `prefix` stripped) is + joined under `root`, resolved, and checked with `Path.is_relative_to`. + `..` segments are rejected before resolution. +- **Conditional GET**: strong `ETag` (size + mtime in nanoseconds) plus + `Last-Modified`. `If-None-Match` (with weak comparison) and + `If-Modified-Since` short-circuit to 304 inline on the selector — no + worker hop, no file open. +- **Range**: single byte-range only (`bytes=N-M`, `bytes=N-`, `bytes=-K`). + Multi-range / unparseable falls back to 200 (RFC 7233 §3.1 compliant). + Out-of-bounds → 416 with `Content-Range: bytes */`. +- **Body**: 200 / 206 GET opens the file and calls `ctx.sendfile(...)` + — wrap in `thread_pool_handler` to dispatch the syscall to a worker. + HEAD success / 304 / 416 / 404 / 405 complete inline on the selector. + +A static handler in its own pool, separate from the API pool, so a few +slow downloads can't pin all the API workers: + +```python +from localpost.http import ( + Routes, ServerConfig, http_server, static_handler, thread_pool_handler, +) + +routes = Routes() +# ... register API routes ... + +api = thread_pool_handler(routes.build().as_handler()) +static = thread_pool_handler( + static_handler("/var/www", prefix=b"/static/", + cache_control="public, max-age=31536000, immutable"), +) + +async with api as api_h, static as static_h: + def root(ctx): + return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) + async with http_server(ServerConfig(), root): + ... +``` + +Both wrappers share the process-wide worker pool — workers are spawned +on demand and reused. See +[`examples/http/static_files.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/http/static_files.py). + +`ctx.sendfile(response, file, offset, count)` is part of the public +`HTTPReqCtx` Protocol and can be used directly for any zero-copy body. +Requires `Content-Length: ` on the response (chunked is rejected); +both backends keep their parser state consistent with what the kernel +writes out-of-band. + +### `localpost.http.compress` + +`compress_handler(inner, *, algorithms=("br","gzip"), min_size=1024, +compressible_types=...)` wraps a `RequestHandler` so eligible +`complete()` responses are compressed — `gzip` (stdlib, always +available) plus `br` (optional via `[http-compress]`). Pair with a JSON +/ HTML / XML API; **not** intended for static files (compression and +zero-copy `sendfile` are at odds). + +Behind a CDN you usually don't need this: the CDN compresses at the +edge from an uncompressed origin. `compress_handler` is for deployments +*not* behind a CDN, or when the CDN doesn't compress (rare). + +The middleware skips compression when any of these hold (response sent +verbatim): + +- `Accept-Encoding` doesn't list any configured `algorithms` with q>0 +- Method is `HEAD` (no body to compress) +- `body is None` or `len(body) < min_size` +- Status is `1xx` / `204` / `304` (no body) or `206` (range — compressing + breaks byte semantics) +- Response already has `Content-Encoding` (other than `identity`) +- Response has `Cache-Control: no-transform` (RFC 9111) +- `Content-Type` main-type is not in `compressible_types` + +When eligible: body is compressed, `Content-Length` is replaced, +`Content-Encoding` is added, and `Accept-Encoding` is merged into +`Vary` (existing `Vary: Cookie` becomes `Vary: Cookie, Accept-Encoding`; +`Vary: *` is left alone). + +`compress_handler` intercepts both `complete(...)` and `stream(...)`. +The middleware decides per-request: + +- One-shot path → compress the whole body in memory; replace + `Content-Length`. +- Streaming path with **no** `Content-Length` declared → wrap the chunk + iterator with an incremental compressor; each input chunk is emitted + compressed + sync-flushed so the decompressor sees each chunk + promptly. The backend auto-frames `Transfer-Encoding: chunked` on + HTTP/1.1. +- Streaming path **with** `Content-Length` → pass through. + +For SSE (`Content-Type: text/event-stream`, in +`DEFAULT_COMPRESSIBLE_TYPES`), each event your generator yields reaches +the client decompressed and parseable by `EventSource` — same approach +nginx uses with `gzip on; gzip_types text/event-stream`. `sendfile` +always passes through uncompressed — composition with the static +handler stays zero-copy. + +Limitations: the one-shot path allocates a compressed buffer per +response (fine for typical JSON; for multi-MB single-shot payloads, +hand `compress_handler` a `stream(response, chunks)` instead). Brotli +is opt-in (`pip install localpost[http-compress]`); if `"br"` is in +`algorithms` without the extra, `compress_handler` raises +`ImportError` at construction time. See +[`examples/http/compressed_api.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/http/compressed_api.py). + +### `localpost.http.flask` + +Native Flask adapter — optional extra `[http-flask]`. `flask_handler(app)` +turns a Flask app into a `RequestHandler`; `flask_server(config, app)` +hosts it as a service (selector-thread, no pool). Bypasses WSGI on both +sides: drives Flask's pipeline directly and streams the Werkzeug +`Response` straight to h11. + +### `localpost.http.router_sentry` / `localpost.http.flask_sentry` + +Sentry tracing wrappers. `sentry_router_handler(router, *, op=...)` +wraps a `Router` in a Sentry transaction per request — transaction +named `"METHOD /books/{id}"` (the URI template, low cardinality) on a +match, or `"METHOD /raw/path"` on a miss. Optional extra +`[http-sentry]`. + +`sentry_flask_handler(app, *, op=...)` wraps the native Flask adapter. +Requires both `[http-flask]` and `[http-sentry]`. Sentry's stock +`FlaskIntegration` ends the transaction when the WSGI `wsgi_app` +returns — *before* the body is iterated. Spans / errors inside a +streaming generator land outside the request transaction (or are +dropped). Because our Flask adapter holds the request context (and the +transaction) open through `response.iter_encoded()`, this fix-pack +version keeps everything on the same transaction. Behaviour +differences from `wsgi_server`: Flask's request context is active +during response-body iteration (a generator returned from a view can +use `flask.request`, `session`, `g` without `@stream_with_context`); +`teardown_request` / `teardown_appcontext` run **after** the body is +fully sent. Adapter touches Werkzeug/Flask internals (stable across +Flask 3.x but not a long-term contract) — use `wsgi_server` for +framework-agnostic WSGI. + +### Hosting integration + +`http_server(config, handler, *, selectors=1, acceptor=False)` is the +`@hosting.service`-decorated wrapper. `wsgi_server(config, app, ...)` +is the same shape for a generic WSGI app. `flask_server(config, app)` +covers the native Flask adapter. `thread_pool_handler(inner)` is an +async CM that yields a `RequestHandler` running `inner` on a shared +worker thread (process-wide pool, workers spawned on demand, no +concurrency cap). + +Threading topology (`selectors=N`, `acceptor=True`) is covered in +[threading topologies](../design/threading-topologies.md). + +## Sync vs. async surface + +`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side +and the terminal write methods. A handler that touches only the core +surface is portable between transports. The full asymmetry table and +why each member differs lives in +[connection model](../design/connection-model.md#sync-vs-async-request-context-surface). + +## Cancellation + +`HTTPReqCtx.disconnected` is a pull-style poll for peer-gone, mirroring +`AsyncHTTPReqCtx.disconnected`. Native backends do a non-blocking +`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket and stick `True` +once seen; the WSGI bridge always returns `False` (no socket handle). + +For sync handlers without `ctx` in scope, `check_cancelled()` raises +`RequestCancelled` if the client disconnected (detected via +non-blocking `MSG_PEEK`) or the hosted service is shutting down. Call +periodically in long-running handlers. + +## Design + +The design rationale lives in `docs/design/`: + +- [Connection model](../design/connection-model.md) — dispatch chain, + two-state TRACKED/BORROWED machine, pull-based disconnect detection, + sync-vs-async asymmetry. +- [Threading topologies](../design/threading-topologies.md) — + `selectors=N` / acceptor topology, composition pattern, three orthogonal + concerns (handler / router / `http_server`). +- [Server backends](../design/server-backends.md) — h11 and httptools + coexistence, why no unified parser Protocol, httptools caveats. +- [Request body handling](../design/request-body-handling.md) — the + `ctx.receive(size)` contract across native sync, WSGI, ASGI, and RSGI. +- [Deployment topologies](../design/deployment-topologies.md) — hosted + services *inside* `run_app` (uvicorn / hypercorn) vs Granian as a + process supervisor that runs the host *inside* its workers. + +The native server is sync-only by design — async transports plug in via +`localpost.http.asgi.to_asgi` rather than growing a parallel async +request path inside this module. + +## See also + +- Examples: [`examples/http/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/http/) +- Server source: [`server_h11.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/server_h11.py) +- Router source: [`router.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/http/router.py) diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index 56660b3..8caa3dd 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -1,4 +1,473 @@ -{% - include-markdown "../../localpost/openapi/README.md" - rewrite-relative-urls=true -%} +# localpost.openapi + +Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of +[`localpost.http`](http.md). FastAPI-inspired; the differences: + +- **Response shapes are union return types**, not a separate `response_model=` + declaration: + ```python + def get_book(book_id: str) -> Book | NotFound[str]: ... + ``` + Both branches end up in the OpenAPI doc with proper schemas; the runtime + short-circuits to the right status code. +- **OpenAPI-aware middleware.** Middlewares and auth contribute to the + spec themselves (security schemes, extra parameters, extra response codes) + via an `update_doc` hook. There's no second place to declare auth. +- **msgspec-first.** [msgspec](https://jcristharif.com/msgspec/) does request + body decoding, response encoding, and JSON Schema generation. Pydantic + models *and* `attrs.define`'d classes are recognised automatically — but + neither is a runtime dependency of the `openapi` extra; install them + yourself if you want to use them. + +## Install + +```bash +pip install 'localpost[http,openapi]' +``` + +For Pydantic models in handlers: + +```bash +pip install pydantic +``` + +For `attrs` classes in handlers (uses `cattrs` for structuring): + +```bash +pip install 'localpost[openapi-attrs]' +# or, equivalently: +pip install attrs cattrs +``` + +## Quick start + +```python +import sys +from dataclasses import dataclass + +from localpost import hosting +from localpost.http import ServerConfig +from localpost.openapi import HttpApp, NotFound, BadRequest, Created + + +@dataclass +class Book: + id: str + title: str + author: str + + +app = HttpApp() + + +@app.get("/hello/{name}") +def hello(name: str) -> str | BadRequest[str]: + if name.lower() == "donald": + return BadRequest("Sorry, you are not welcome here") + return f"Hello, {name}!" + + +@app.get("/books/{book_id}") +def get_book(book_id: str) -> Book | NotFound[str]: + if book_id != "42": + return NotFound(f"Book not found: {book_id}") + return Book(id=book_id, title="HHGTTG", author="Adams") + + +@app.post("/books") +def create_book(book: Book) -> Created[Book]: + return Created(book, headers={"Location": f"/books/{book.id}"}) + + +if __name__ == "__main__": + sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) +``` + +```bash +curl http://localhost:8000/hello/world +curl http://localhost:8000/openapi.json # OpenAPI 3.2 doc +open http://localhost:8000/docs # Swagger UI +open http://localhost:8000/docs/redoc # ReDoc +open http://localhost:8000/docs/scalar # Scalar +``` + +## Concepts + +### Operations + +Plain Python functions registered with `@app.get(path)`, `.post`, `.put`, +`.delete`, `.patch`. The path follows +[`URITemplate`](http.md) syntax (`/books/{id}`). + +### Argument resolvers + +One per parameter. Picked from the annotation; explicit factories override: + +| Source | Factory | Auto-picked when… | +|---|---|---| +| Path variable | `FromPath()` | param name matches `{name}` in template | +| Query string | `FromQuery()` | scalar parameter not in path, no body type | +| Header | `FromHeader("X-…")` | only via explicit `Annotated[...]` | +| Request body | `FromBody()` | param annotated as `msgspec.Struct` / dataclass / pydantic model / `attrs` class | +| Request ctx | (none) | param annotated as `HTTPReqCtx` | + +Each resolver may short-circuit by returning an `OpResult` (e.g. validation +failure → `BadRequest`). + +### `OpResult` hierarchy + +| Class | Status | Notes | +|---|---|---| +| `Ok[T]` | 200 | Implicit when you return a plain value. | +| `Created[T]` | 201 | | +| `Accepted[T]` | 202 | | +| `NoContent` | 204 | No body. | +| `EventStreamResult[T]` | 200 | SSE stream — see below. | +| `BadRequest[T]` | 400 | | +| `Unauthorized[T]` | 401 | | +| `Forbidden[T]` | 403 | | +| `NotFound[T]` | 404 | | +| `Conflict[T]` | 409 | | +| `UnprocessableEntity[T]` | 422 | | +| `TooManyRequests[T]` | 429 | | +| `InternalServerError[T]` | 500 | | + +Use the *class* in return annotations (`Book | NotFound[str]`) so the OpenAPI +doc picks up the body type per status code. + +### `OpMiddleware` + +A middleware wraps the operation core. It receives the request context and +a `call_next` callable that runs the rest of the chain — and knows how to +describe itself in the OpenAPI doc: + +```python +ApiOperation = Callable[[HTTPReqCtx], OpResult] + + +class OpMiddleware(Protocol): + def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: ... + def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: ... + def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: ... +``` + +- `__call__` runs around the operation. Return `call_next(ctx)` to forward + (optionally post-processing the result), or short-circuit by returning + any other `OpResult`. +- `contribute_root` is called once at spec-build time (typically to register + a `SecurityScheme`). +- `contribute_operation` is called for each operation that the middleware is + attached to (typically to add a 401 response and a `security` requirement). + +Apply app-wide: + +```python +app = HttpApp(middlewares=[my_middleware]) +``` + +or per-operation: + +```python +@app.get("/admin", middlewares=[my_middleware]) +def admin() -> str: ... +``` + +### `@op_middleware` — middlewares as tiny operations + +For the common case, write a middleware the same way you'd write a handler: +declare your inputs (plus the `call_next` parameter), declare your possible +failure responses in the return type, and let the framework do the OpenAPI +bookkeeping: + +```python +from typing import Annotated + +from localpost.http import HTTPReqCtx +from localpost.openapi import ( + ApiOperation, + FromHeader, + OpResult, + TooManyRequests, + op_middleware, +) + + +@op_middleware +def rate_limit( + ctx: HTTPReqCtx, + call_next: ApiOperation, + x_client: Annotated[str, FromHeader("X-Client-Id")], +) -> TooManyRequests[str] | OpResult: + if quota_exhausted(x_client): + return TooManyRequests("Slow down") + return call_next(ctx) + + +@app.get("/expensive", middlewares=[rate_limit]) +def expensive() -> str: ... +``` + +Without writing any spec code, every operation that uses this middleware +gets: +- the `X-Client-Id` header in its `parameters`, +- `429 Too Many Requests` in its `responses` (with the body schema for `str`). + +The return-type union *is* the OpenAPI contract. Bare `OpResult` in the +union is the *passthrough* sentinel and contributes nothing; subclasses +contribute their response code. Middlewares must return an `OpResult` +(typically by calling `call_next(ctx)`); anything else raises `TypeError` +at request time. + +> **SSE caveat.** When the wrapped operation streams an SSE response, +> `call_next` returns an `EventStreamResult` whose body is an iterator. A +> middleware can wrap that iterator (transforming/dropping events) but +> can't post-process headers after streaming has started — they're sent +> before the first event. + +### Server-Sent Events (SSE) + +Return a generator (or any iterator) and the operation auto-promotes to an +SSE stream — `Content-Type: text/event-stream`, chunked transfer encoding, +one event per yielded value: + +```python +from collections.abc import Generator +from dataclasses import dataclass + +from localpost.openapi import HttpApp, Event + + +@dataclass +class Tick: + n: int + + +app = HttpApp() + + +@app.get("/clock") +def clock() -> Generator[Event[Tick]]: + for n in range(60): + yield Event(data=Tick(n=n), id=str(n)) + time.sleep(1) +``` + +Yield bare values for `data:`-only events; yield `Event` instances for +control over `event:` / `id:` / `retry:` / comment fields. The OpenAPI doc +emits `text/event-stream` (with the schema for `Event[Tick]`) instead of +`application/json` for that operation. + +Cancellation: the framework calls `localpost.http.check_cancelled` between +events, so client disconnects (and pool shutdowns) terminate the stream +without leaking workers — provided the app runs under +`thread_pool_handler` (the default for `HttpApp.service(...)`). + +For an iterator you've already constructed, wrap it in `EventStream(...)` +to be explicit; otherwise just return the generator directly. + +### Built-in auth middlewares + +```python +from localpost.openapi import HttpApp, HttpBearerAuth, HttpBasicAuth + +def validate_token(token: str) -> dict | None: + # Return any truthy principal on success, None on failure. + # The principal is stashed on ctx.attrs[] for handlers to read. + return decode_jwt(token) + + +app = HttpApp(middlewares=[HttpBearerAuth(validator=validate_token)]) + + +@app.get("/me") +def me() -> dict: + # Pull the principal from a custom resolver, or just inject the ctx. + ... +``` + +`HttpBearerAuth` registers an HTTP `bearer` security scheme; `HttpBasicAuth` +registers `basic` and sends a `WWW-Authenticate` challenge on 401. Both +attach a 401 response and a `security` requirement to every operation they +cover. `OpenIDConnectAuth` is a follow-up. + +## Hosting + +`HttpApp.service(config)` returns a `localpost.hosting.service` you feed to +`hosting.run_app(...)` or `hosting.serve(...)`. It composes: + +1. A worker pool (`thread_pool_handler`) so user fns run on threads, not the + selector. +2. The HTTP server (`http_server`). + +Pass `selectors=N` and/or `acceptor=True` to use multi-selector topology. + +## Async flavour (`HttpAsyncApp`) + +Parallel to `HttpApp`, like `httpx.Client` and `httpx.AsyncClient`. +Same decorator API, same OpenAPI 3.2 emission, same `OpResult` +hierarchy — handlers are `async def`, middleware is built with +`@async_op_middleware`, and the deployment target is **ASGI** (uvicorn, +hypercorn, or `granian --interface asgi`): + +```python +import sys +from dataclasses import dataclass + +import uvicorn + +from localpost.openapi import HttpAsyncApp, NotFound + + +@dataclass +class Book: + id: str + title: str + + +app = HttpAsyncApp() + + +@app.get("/books/{book_id}") +async def get_book(book_id: str) -> Book | NotFound[str]: + book = await fetch_book(book_id) # your async DB call, etc. + if book is None: + return NotFound(f"Book not found: {book_id}") + return book + + +if __name__ == "__main__": + sys.exit(uvicorn.run(app.asgi(), host="127.0.0.1", port=8000)) +``` + +For `localpost.hosting` integration use +`app.service(uvicorn.Config(...))` and feed it to +`hosting.run_app(...)` — same shape as `HttpApp.service(config)` but +running the ASGI app under uvicorn. + +A side-by-side example ports the sync `examples/openapi/app.py` +verbatim: see [`examples/openapi/async_app.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/openapi/async_app.py). + +### What's different + +- **Handlers are async.** Plain `async def` for normal routes; + `async def` generators for SSE. Sync generators are rejected at + request time — `HttpAsyncApp` won't silently bridge a blocking + generator onto the event loop. +- **Middleware is async.** Use `@async_op_middleware` to build them. + Sync `OpMiddleware` instances are rejected at app construction time: + + ```python + from typing import Annotated + + from localpost.openapi import ( + AsyncApiOperation, AsyncHTTPReqCtx, FromHeader, + OpResult, Unauthorized, async_op_middleware, + ) + + + @async_op_middleware + async def require_api_key( + ctx: AsyncHTTPReqCtx, + call_next: AsyncApiOperation, + x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", + ) -> Unauthorized[str] | OpResult: + if x_api_key != "secret": + return Unauthorized("Invalid or missing X-API-Key") + return await call_next(ctx) + ``` +- **Auth.** `AsyncHttpBearerAuth` and `AsyncHttpBasicAuth` mirror their + sync siblings; the validator may be sync or async. +- **Body buffering.** The ASGI bridge pre-buffers the request body + before dispatch (matches the JSON-API common case the sync flavour + is tuned for) so the same `FromBody` resolver works in both + flavours. Cap the size with `HttpAsyncApp(max_body_size=N)` (default + 1 MiB; `-1` disables). Streaming uploads via `ctx.receive(...)` are + on the follow-up list. +- **Resolvers.** `FromPath`, `FromQuery`, `FromHeader`, `FromBody` are + shared — they only read sync attributes (`request`, `body`, `attrs`) + off the ctx, so the same factories work in both apps. + +### Deployment + +Two flavours, depending on which target you ship to. + +**ASGI** — `app.asgi()` returns a plain ASGI 3 callable; any ASGI +server runs it: + +```python +# myapp.py +from localpost.openapi import HttpAsyncApp + +app = HttpAsyncApp() +# … register routes … +asgi_app = app.asgi() +``` + +```bash +uvicorn myapp:asgi_app +hypercorn myapp:asgi_app +granian --interface asgi myapp:asgi_app +``` + +**RSGI (Granian native)** — `app.as_rsgi()` returns an RSGI application: + +```python +rsgi_app = app.as_rsgi() +``` + +```bash +granian --interface rsgi myapp:rsgi_app +``` + +The RSGI bridge (`localpost.http.to_rsgi`) is wire-format-only — the +same handler chain serves both ASGI and RSGI traffic. Granian's RSGI +gives a single eager `response_bytes` per response (vs ASGI's +two-event start+body), zero-copy `sendfile` via +`response_file_range`, and direct `async for` body reads in streaming +mode. Requires the `[rsgi]` extra (`pip install 'localpost[rsgi]'`). + +**Hosted apps under Granian** — when the HTTP app shares its worker +process with other hosted services (scheduler, gRPC, custom workers), +deploy through `localpost.hosting.HostRSGIApp` instead, which runs the +full hosting lifecycle inside each Granian worker: + +```python +from localpost import hosting +from localpost.scheduler import every, scheduled_task + +@scheduled_task(every(seconds=5)) +async def heartbeat(): ... + + +rsgi_app = hosting.HostRSGIApp( + services=[heartbeat.service()], + rsgi_handler=app, +) + +# granian --interface rsgi --workers 4 myapp:rsgi_app +``` + +See [hosting](hosting.md#host-as-rsgi-for-granian) for the topology details. + +## Design notes + +See the in-tree design doc for rationale and a layout map: keep this README +focused on user-facing concepts. + +## Sub-modules + +| Module | Provides | +|---|---| +| `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs (sync) | +| `operation.py` | `Operation`: sync runtime closure | +| `_operation_core.py` | Shared type-level core (signature parsing, return-type → response-shape, doc helpers, response encoding) — used by both flavours | +| `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories — shared between sync and async | +| `results.py` | `OpResult` hierarchy (incl. `EventStreamResult`) | +| `middleware.py` | `OpMiddleware` protocol, `@op_middleware`, `ApiOperation` (sync) | +| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth middlewares (sync) | +| `aio/` | Async flavour: `HttpAsyncApp`, `AsyncOperation`, `AsyncOpMiddleware`, `@async_op_middleware`, `AsyncHttpBearerAuth`, `AsyncHttpBasicAuth`, `AsyncHTTPReqCtx`, ASGI bridge | +| `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events (sync drive) | +| `spec.py` | OpenAPI 3.2 dataclasses | +| `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | +| `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | +| `_docs.py` | HTML for Swagger UI / ReDoc / Scalar (CDN-loaded) | diff --git a/docs/modules/scheduler.md b/docs/modules/scheduler.md index 2ff80a0..edeb899 100644 --- a/docs/modules/scheduler.md +++ b/docs/modules/scheduler.md @@ -1,4 +1,131 @@ -{% - include-markdown "../../localpost/scheduler/README.md" - rewrite-relative-urls=true -%} +# localpost.scheduler + +Composable in-process task scheduler. Tasks are triggered by **conditions** +(time intervals, cron expressions, completion of another task), and triggers +are built up with operators — `//` to compose middleware, `>>` to extend a +trigger's own middleware pipeline. Tasks publish their outputs as `Result[T]`, +so downstream tasks can subscribe. + +Use it when you need to schedule background work inside the same process — for +example, refresh a cached dataframe every minute. For distributed / +persistent tasks, use Celery, APScheduler with a DB store, etc. + +## Install + +```bash +pip install localpost[scheduler] # human-readable periods (pytimeparse2, humanize) +pip install localpost[scheduler,cron] # also the cron() trigger (croniter) +``` + +## Quick start + +```python +import random +import sys +from localpost.hosting import run_app +from localpost.scheduler import after, delay, every, scheduled_task, take_first + + +@scheduled_task(every("3s") // delay((0, 1))) +async def task1(): + return random.randint(1, 22) + + +@scheduled_task(after(task1) // take_first(3)) +async def task2(task1_result: int): + print(f"task1 emitted: {task1_result}") + + +if __name__ == "__main__": + sys.exit(run_app(task1, task2)) +``` + +Cron: + +```python +from localpost.scheduler import delay, scheduled_task +from localpost.scheduler.cond.cron import cron + + +@scheduled_task(cron("*/1 * * * *") // delay((0, 10))) +async def job(): + print("running") +``` + +See [`examples/scheduler/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/scheduler/) +for more (`cancellation.py`, `failing_tasks.py`, `fastapi_lifespan.py`, +`finite_task.py`, `serve_sync.py`, `fastdepends.py`). + +## Key concepts + +- **`ScheduledTask[T, R]`** — the runtime object: a handler paired with a + trigger factory. Publishes a `Result[R]` stream that others can subscribe to. +- **`ScheduledTaskTemplate[T]`** — a pre-bound trigger factory waiting for a + handler. Composable with `//` (compose trigger middleware) and `>>` + (extend the trigger's own middleware tuple). `every(...)`, `after(...)`, + `after_all(...)`, and `cron(...)` all return templates. +- **`TriggerFactory[T]`** — `Callable[[TaskGroup, EventView], + AbstractAsyncContextManager[AsyncIterator[T]]]`. Under the hood, a trigger is + an async iterator of events fired inside the task's lifetime. +- **Trigger middleware** — an async generator that consumes one `AsyncIterator` + and yields another. `delay` and `take_first` are built-in; write your own the + same way. +- **`Result[T]`** — output envelope (`Ok` or failure). `after()` filters + successes and forwards the `T`; `after_all()` forwards every result. +- **Hosting integration** — both individual tasks and `Scheduler` instances + are `ServiceF`s. Pass them to `localpost.hosting.run_app(...)` (entry point, + signal handling) or `localpost.hosting.serve(...)` (async CM, e.g. for + embedding in a FastAPI lifespan). + +## Writing a custom trigger + +A trigger is a frozen dataclass / callable that, given a `TaskGroup` and a +`shutting_down` event, returns an async context manager yielding an +`AsyncIterator[T]`. Pattern (adapted from `_cond.py:Every`): + +```python +from dataclasses import dataclass, replace +from contextlib import asynccontextmanager + +@dataclass(frozen=True, slots=True) +class MyTrigger[T]: + middlewares: tuple[TriggerMiddleware, ...] = () + + def __rshift__(self, mw): # >> adds a middleware + return replace(self, middlewares=self.middlewares + (mw,)) + + @asynccontextmanager + async def __call__(self, tg, shutting_down): + async with apply_middlewares(my_source(), self.middlewares) as stream: + yield stream +``` + +Then wrap it: `my_trigger = ScheduledTaskTemplate(MyTrigger())`. + +## Writing a custom middleware + +A middleware is a regular async generator: + +```python +from collections.abc import AsyncIterator +from localpost._utils import maybe_closing + +def skip_every_other(): + async def middleware[T](events: AsyncIterator[T]) -> AsyncIterator[T]: + async with maybe_closing(events): + i = 0 + async for event in events: + if i % 2 == 0: + yield event + i += 1 + return middleware +``` + +Use it via `every("1s") // skip_every_other()`. + +## See also + +- Examples: [`examples/scheduler/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/scheduler/) +- Cron source: [`cond/cron.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/scheduler/cond/cron.py) +- Built-in triggers: [`_cond.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/scheduler/_cond.py) +- Core: [`_scheduler.py`](https://github.com/alexeyshockov/localpost.py/blob/main/localpost/scheduler/_scheduler.py) diff --git a/justfile b/justfile index f6ba507..edb8824 100755 --- a/justfile +++ b/justfile @@ -91,11 +91,11 @@ why package: [doc("Serve docs locally with live reload")] docs-serve: - uv run --all-groups --all-extras mkdocs serve + uv run --all-groups --all-extras zensical serve [doc("Build docs to ./site")] docs-build: - uv run --all-groups --all-extras mkdocs build + uv run --all-groups --all-extras zensical build [doc("Find unused code with vulture (config in pyproject.toml). Pass extra args to override.")] deadcode *args: diff --git a/localpost/di/README.md b/localpost/di/README.md index 385f057..db7a953 100644 --- a/localpost/di/README.md +++ b/localpost/di/README.md @@ -2,14 +2,8 @@ Small, `.NET`-style inversion-of-control container: scoped service resolution with automatic constructor wiring. No decorators, no async, no "one interface, -many implementations" — just a registry + provider + scope. - -## Install - -Comes with core `localpost` — no extra needed. The Flask adapter requires -`flask` in your environment. - -## Quick start +many implementations" — just a registry + provider + scope. Comes with core +`localpost`; the optional Flask adapter requires `flask` in your environment. ```python from dataclasses import dataclass @@ -17,114 +11,21 @@ from localpost.di._services import ServiceRegistry @dataclass -class Config: - host: str - port: int +class Config: host: str; port: int class Server: - def __init__(self, config: Config): - self.config = config + def __init__(self, config: Config): self.config = config services = ServiceRegistry() services.register_instance(Config(host="127.0.0.1", port=8080)) -services.register(Server) # auto-wires Config via __init__ +services.register(Server) # auto-wires Config with services.app_scope() as sp: - server = sp.resolve(Server) - print(server.config) -``` - -With cleanup (generator factory): - -```python -def create_db_pool(conf: Config): - pool = DBPool(conf.db_dsn) - try: - yield pool - finally: - pool.close() - - -services.register(DBPool, create_db_pool) + print(sp.resolve(Server).config) ``` -See [`examples/di/basic.py`](../../examples/di/basic.py), -[`basic_cleanup.py`](../../examples/di/basic_cleanup.py), -[`flask_app.py`](../../examples/di/flask_app.py). - -## Design goals (and non-goals) - -- Inspired by .NET `Microsoft.Extensions.DependencyInjection`. -- No `async` (for now). -- **Scope is defined by its type** (`AppContext`, `RequestContext`, …). -- **Only one registration per `(service_type, scope_type)` pair** (the opposite - of .NET's `IEnumerable` pattern). A type can still be registered - multiple times — once per scope. -- **Service Locator** style — no magic `@inject` decorator. Ask the provider - for what you need. -- Constructor dependencies are **auto-wired** from type hints when resolved - via the provider. -- Factories can be plain callables, or **generator functions** for - setup/teardown (enter on resolve, `finally` on scope exit). -- Instances are **lazily resolved** by default; use `create_on_enter=True` to - build them eagerly when a scope is entered. - -## Key concepts - -- **`ResolutionContext`** (Protocol) — anything with `enter(cm)`. `AppContext` - is the default app-wide scope; `RequestContext` (in the Flask adapter) is - per-request. Define your own for other lifetimes. -- **`ServiceRegistry`** — the mutable catalogue. Holds `ServiceDescriptor`s - keyed by `(service_type, scope_type)`. -- **`ServiceProvider`** — resolves services. The default provider walks the - scope chain (request → app) to find a descriptor. -- **Scope stack** — child scopes hold a reference to their parent provider, so - resolving a parent-scoped service from inside a child scope transparently - reuses it. -- **`service_provider` proxy** — a `CurrentServiceProvider` singleton that - delegates to whichever provider is active in the current `contextvar`. - Lets request-scoped code call `service_provider.resolve(...)` without - plumbing the provider through. - -## Module layout - -Symbols currently live under `localpost.di._services` — `localpost/di/__init__.py` -is empty and a top-level re-export is pending. Treat the module path as -unstable until that lands. - -The Flask adapter (`localpost.di.flask`) provides `RequestContext` (per-request -scope) and `init_app(app, registry, provider)` which opens a `RequestContext` -per Flask request and registers `flask.Request` for auto-injection. A Quart -adapter (`localpost/di/quart.py`) exists as a stub only. - -## Defining a custom scope - -1. Create a dataclass implementing `ResolutionContext`: - - ```python - @dataclass(frozen=True, eq=False, slots=True) - class JobContext: - ctx: ExitStack = field(default_factory=ExitStack) - def enter[T](self, cm): return self.ctx.enter_context(cm) - ``` - -2. Register services under it: `services.register(JobRepo, scope=JobContext)`. - -3. Open the scope when you start a job: - - ```python - from localpost.di._services import DefaultServiceProvider, scope - job_ctx = JobContext() - provider = DefaultServiceProvider(parent_provider, registry, job_ctx, JobContext) - with job_ctx.ctx, scope(provider): - run_job() - ``` - -The Flask adapter in [`flask.py`](flask.py) is a compact reference. - -## See also +**Full reference:** -- Examples: [`examples/di/`](../../examples/di/) -- Core: [`_services.py`](_services.py) +Examples: [`examples/di/`](../../examples/di/). diff --git a/localpost/hosting/README.md b/localpost/hosting/README.md index 8e27fe4..d68c0ce 100644 --- a/localpost/hosting/README.md +++ b/localpost/hosting/README.md @@ -5,145 +5,26 @@ sync) function wrapped with a lifecycle — it goes through `Starting → Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child services in the same task group. -## Quick start - ```python -import sys -import time +import sys, time from localpost.hosting import ServiceLifetime, run_app, service @service -def a_sync_service(): +def my_service(): def svc(lt: ServiceLifetime): - print("Service started") lt.set_started() - print("Service running") time.sleep(5) - print("Service is done") # host stops when all services stop return svc if __name__ == "__main__": - sys.exit(run_app(a_sync_service())) + sys.exit(run_app(my_service())) ``` -`run_app()` wires `shutdown_on_signal()` for you (SIGINT / SIGTERM), runs the -service with AnyIO (picking asyncio or Trio via `choose_anyio_backend`), and -returns an exit code. - -See `examples/host/finite_service.py`, `examples/host/channel.py`. - -## Key concepts - -- **`ServiceLifetime`** — the handle passed to every service. Exposes - `started`, `shutting_down`, `stopped` events (as `Event` / `EventView`), - an anyio `TaskGroup` (`lt.tg`) for spawning child tasks, and `defer` / - `adefer` to stash context managers / closable resources. -- **`ServiceState`** — the union `Starting | Running | ShuttingDown | Stopped` - (immutable dataclasses). Accessible via `lt.view.state`. -- **`@service` decorator** — turns a factory (returning a service function - or async generator) into a `_ResolvedService`, a callable that doubles as - an async context manager. -- **Middleware** — ordinary function decorators over the service function. - Examples: `shutdown_on_signal(*signals)`, `start_timeout(seconds)`. -- **`current_service()` / `current_app()`** — read-only views of the enclosing - lifetimes via contextvars, without threading them through every call. - -## Writing a service - -Four signatures are supported; `@service` picks the right adapter: - -1. **Async function** — `async def svc(lt: ServiceLifetime) -> None` -2. **Sync function** — runs in a worker thread via `to_thread.run_sync` -3. **Async generator** — `@service async def factory(): setup; yield; teardown` - (wrapped with `@asynccontextmanager`; `lt.set_started()` is called after - `yield`-in). -4. **Factory returning one of the above** - -Always call `lt.set_started()` once your service is ready. Services that -never call it block `observe_services` forever. - -## Writing middleware - -Middleware is a plain decorator over `ServiceF = Callable[[ServiceLifetime], -Awaitable[None]]`. Reference: `shutdown_on_signal` in `middleware.py`: - -```python -def my_middleware(arg) -> Callable[[ServiceF], ServiceF]: - def decorator(func: ServiceF) -> ServiceF: - @wraps(func) - def wrapper(lt: ServiceLifetime) -> Awaitable[None]: - lt.tg.start_soon(my_background_task, lt.view) - return func(lt) - return wrapper - return decorator -``` - -## Adapters for external servers (`services/`) - -`uvicorn.py` wraps `uvicorn.Server` (with reload and multi-worker disabled); -`hypercorn.py` wraps `hypercorn.asyncio.serve(app, config)` with a shutdown -trigger; `grpc.py` wraps `grpc.aio.Server` with a configurable grace period; -`_asgi.py` holds shared ASGI lifespan helpers. Each adapter is decorated -with `@hosting.service`, so it plugs into `run_app()` the same way as any -other service. - -## Host as RSGI for Granian - -`localpost.hosting.HostRSGIApp` runs the full hosting lifecycle (multiple -services + an HTTP handler) inside each Granian worker. Granian is a process -supervisor that spawns workers and loads our app via its RSGI interface, so -the topology flips: the host *itself* implements RSGI. - -```python -from localpost import hosting -from localpost.openapi import HttpAsyncApp -from localpost.scheduler import every, scheduled_task - - -app = HttpAsyncApp() - - -@app.get("/") -async def root() -> str: - return "ok" - - -@scheduled_task(every(seconds=5)) -async def heartbeat() -> None: ... - - -rsgi_app = hosting.HostRSGIApp( - services=[heartbeat.service()], - rsgi_handler=app, -) - -# granian --interface rsgi --workers 4 myapp:rsgi_app -``` - -`shutdown_on_signal` is **not** applied — Granian owns signal handling; -`__rsgi_del__` is how shutdown reaches us. Every service in `services=` -runs in *each* worker, so cron-style "run once" jobs need either -`--workers 1` or external coordination (DB lock, leader election). - -For the bridge layer (RSGI translation, no hosting integration), see -[`localpost.http.rsgi`](../http/README.md#localposthttprsgi). The -asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor -(plus per-worker lifecycle details) is covered in -[deployment-topologies.md](../../docs/design/deployment-topologies.md). - -## Implementation notes - -- A service may spawn child services via `lt.start(child_svc)` — they run in - `lt.tg`, so when the parent's service function returns, the child task group - is cancelled. If you want the children to complete, `await` them explicitly - before returning. -- `lt.defer(cm)` / `await lt.adefer(acm)` tie a resource's lifetime to the - service — it's released when the service stops. +`run_app()` wires `shutdown_on_signal()` (SIGINT / SIGTERM), runs services +under AnyIO (asyncio or Trio), and returns an exit code. -## See also +**Full reference:** -- Examples: [`examples/host/`](../../examples/host/) -- Middleware source: [`middleware.py`](middleware.py) -- Core: [`_host.py`](_host.py) +Examples: [`examples/host/`](../../examples/host/). diff --git a/localpost/http/README.md b/localpost/http/README.md index 015d522..cf21585 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -3,49 +3,23 @@ A small synchronous HTTP/1.1 server built on [h11](https://h11.readthedocs.io/), plus a URI-template router, WSGI / ASGI bridges, and a small framework (`HttpApp`) on top. Three layers, each -usable on its own: +usable on its own; pair with `localpost.hosting` for lifecycle, or run +standalone. -- **Server**: `start_http_server` accepts connections, parses HTTP - (h11 by default; httptools opt-in via `ServerConfig.backend`), - dispatches to a `RequestHandler`. ~540 lines of sync code. -- **Router**: thin URI-template dispatcher. Matches the request, - attaches a `RouteMatch` to `ctx.attrs[RouteMatch]`, delegates to - the registered handler. 404 / 405 inline. -- **HttpApp**: decorator-driven framework — parameter injection, - response conversion, worker-pool dispatch, middleware. - -Pair with `localpost.hosting` for lifecycle management, or run any of -the three layers standalone. - -## Scope - -In-process only (no `fork` / `spawn`); for multi-core fanout, run multiple -processes under an external supervisor. Sync server only — async handlers -are reached via `localpost.http.asgi.to_asgi(handler)` plugged into -uvicorn / hypercorn / granian. CPython 3.12+ is the baseline; free-threaded -builds are an accepted target. - -The hot path is tuned for the JSON-API common case: handlers can reject -before the body (no worker hop, `Content-Length` pre-checked against -`max_body_size`); read the body explicitly via `read_body(ctx)` / -`aread_body(ctx)` when needed; one-shot `complete()` flushes -status+headers+body in a single `sendall`. No HTTP/1.1 pipelining. - -## Install +In-process only — for multi-core fanout, run multiple processes under an +external supervisor. Sync server only; async handlers are reached via +`localpost.http.asgi.to_asgi(handler)` plugged into uvicorn / hypercorn / +granian. ```bash pip install localpost[http-server] # h11 backend (default, pure Python) pip install localpost[http-server,http-fast] # also adds the httptools backend ``` -## Quick start - -The recommended path is `HttpApp`: - ```python import sys from localpost.hosting import run_app -from localpost.http import HTTPReqCtx, ServerConfig +from localpost.http import ServerConfig from localpost.http.app import HttpApp @@ -57,378 +31,9 @@ def hello(name: str): return f"Hello, {name}!" -@app.post("/{name}/profile") -def update_profile(ctx: HTTPReqCtx, name: str): - import json - from localpost.http import read_body - profile = json.loads(read_body(ctx)) - return {"updated": name, "profile": profile} - - sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) ``` -Or stay close to the wire — `start_http_server` directly: - -```python -import h11 -from localpost.http import HTTPReqCtx, ServerConfig, start_http_server - - -def simple_app(ctx: HTTPReqCtx): - ctx.complete( - h11.Response(status_code=200, headers=[(b"Content-Type", b"text/plain")]), - b"Hello, World!\n", - ) - - -with start_http_server(ServerConfig(), simple_app) as server: - while True: - server.run() -``` - -Running under hosting: - -```python -import sys - -from localpost.hosting import run_app -from localpost.http import http_server, ServerConfig - -# `simple_app` from the Quick start above -sys.exit(run_app(http_server(ServerConfig(), simple_app))) -``` - -See [`examples/http/`](../../examples/http/). - -## Key concepts - -- **`ServerConfig`** — host, port, backlog, `select_timeout`, `rw_timeout`, - `keep_alive_timeout`, `max_body_size`. -- **`start_http_server(config, handler)`** — context manager; yields a - `Server` bound to a non-blocking listening socket with a `selectors` - poller. The handler is fixed for the server's lifetime. -- **`HTTPReqCtx`** — per-request context carrying the parsed h11 request, - the raw socket, headers, and `complete(response, body)`. Request bodies - are streamed via `receive(n_bytes)`. -- **`RequestHandler = Callable[[HTTPReqCtx], None]`** — the handler - interface. `Server.run()` dispatches each accepted request to it. -- **`URITemplate`** — RFC 6570 Level 1 only (`/books/{id}` style - variables). `match(uri) → dict | None`. -- **`Routes`** — mutable builder. Accumulate routes via decorators - (`@routes.get("/path")`, `.post`, `.put`, `.delete`, `.patch`, `.add`), - then call `.build()` to compile into a `Router`. -- **`Router`** — immutable, compiled URI-template dispatcher. One regex - alternation over all templates, templates ordered by longest literal - prefix, `Allow` headers pre-rendered. Exposes `.as_handler()` (native - `RequestHandler`) and `.wsgi` (for deployment under Gunicorn / Granian - / etc.). Build via `routes.build()` or `Router.from_routes(routes)`. - -## Sub-modules - -User-facing prose for the bridges and middlewares. The connection -model, threading topologies, and backend rationale live in `docs/design/` -— see the [Design](#design) section. - -### `localpost.http.wsgi` - -`wrap_wsgi(app)` turns a WSGI app into a `RequestHandler`; -`to_wsgi(handler)` turns a `RequestHandler` into a WSGI app. - -### `localpost.http.asgi` - -The async sibling of `localpost.http.wsgi` — bridges between a foreign -async protocol (ASGI 3) and the framework's async request-context shape -(`AsyncHTTPReqCtx`, defined in `localpost.http`). `localpost.http` -itself doesn't ship an async server; this module plugs an -`AsyncRequestHandler` into uvicorn / hypercorn / granian / any ASGI 3 -server. - -`to_asgi(handler, *, max_body_size=1<<20)` wraps an `AsyncRequestHandler` -as an ASGI 3 app. Body bytes are pulled lazily via -`await ctx.receive(size)` (or `aread_body(ctx)` for the whole-body common -case); `Content-Length` is pre-checked against `max_body_size` when -present (413 before dispatch). - -```python -# myapp.py -from localpost.http import Response -from localpost.http.asgi import to_asgi - - -async def hello(ctx): - await ctx.complete(Response(200), b"hi") - - -asgi_app = to_asgi(hello) -``` - -```bash -uvicorn myapp:asgi_app -hypercorn myapp:asgi_app -granian --interface asgi myapp:asgi_app -``` - -For the framework-flavoured surface (decorators, OpenAPI generation, -typed handlers) on top of the same bridge, see -[`localpost.openapi.HttpAsyncApp`](../openapi/README.md#async-flavour-httpasyncapp) -— `app.asgi()` is sugar over `to_asgi(self._build_async_handler())`. - -Cancellation: `ctx.disconnected` flips on `http.disconnect`. SSE -generators / long handlers poll it between events to short-circuit -cleanly. Body-handling contract across transports lives in -[docs/design/request-body-handling.md](../../docs/design/request-body-handling.md). - -### `localpost.http.rsgi` - -The Granian-flavoured sibling of `localpost.http.asgi`. Same -`AsyncHTTPReqCtx` Protocol; different wire surface (RSGI exposes -richer per-method calls than ASGI's two-event response dance), and -different deployment topology (Granian is a process supervisor, not -an in-process server). Install: `pip install 'localpost[rsgi]'`. - -`to_rsgi(handler, *, max_body_size=1<<20)` wraps an -`AsyncRequestHandler` for `granian --interface rsgi`. Single eager -`proto.response_bytes` per `complete`; zero-copy -`proto.response_file_range` for `sendfile` when the file has a path; -chunked stream fallback otherwise. - -```python -# myapp.py -from localpost.http import Response -from localpost.http.rsgi import to_rsgi - - -async def hello(ctx): - await ctx.complete(Response(200), b"hi") - - -rsgi_app = to_rsgi(hello) -``` - -```bash -granian --interface rsgi myapp:rsgi_app -``` - -For framework-flavoured deployment, see -[`localpost.openapi.HttpAsyncApp.as_rsgi()`](../openapi/README.md#async-flavour-httpasyncapp). -For deployments where the HTTP app shares a worker process with **other -hosted services** (scheduler / gRPC / custom workers), -[`localpost.hosting.HostRSGIApp`](../hosting/README.md#host-as-rsgi-for-granian) -runs the full hosting lifecycle inside each Granian worker. The -asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor -is covered in -[docs/design/deployment-topologies.md](../../docs/design/deployment-topologies.md). - -### `localpost.http.static` - -`static_handler(root, *, prefix=b"/", cache_control=None, -index="index.html")` builds a `RequestHandler` that serves files under -`root` via `socket.sendfile()` — zero-copy from the page cache to the -socket. Designed for **CDN-fronted deployments**: pair with proper -`Cache-Control` headers and origin sees roughly one hit per file per -edge per cache lifetime, which is why we skip on-the-fly compression -(the CDN handles `gzip` / `br` at the edge). - -Behaviour: - -- **Methods**: `GET` and `HEAD`. Anything else returns 405 + `Allow: GET, HEAD`. -- **Resolution**: percent-decoded URL path (with `prefix` stripped) is - joined under `root`, resolved, and checked with `Path.is_relative_to`. - `..` segments are rejected before resolution. -- **Conditional GET**: strong `ETag` (size + mtime in nanoseconds) plus - `Last-Modified`. `If-None-Match` (with weak comparison) and - `If-Modified-Since` short-circuit to 304 inline on the selector — no - worker hop, no file open. -- **Range**: single byte-range only (`bytes=N-M`, `bytes=N-`, `bytes=-K`). - Multi-range / unparseable falls back to 200 (RFC 7233 §3.1 compliant). - Out-of-bounds → 416 with `Content-Range: bytes */`. -- **Body**: 200 / 206 GET opens the file and calls `ctx.sendfile(...)` - — wrap in `thread_pool_handler` to dispatch the syscall to a worker. - HEAD success / 304 / 416 / 404 / 405 complete inline on the selector. - -A static handler in its own pool, separate from the API pool, so a few -slow downloads can't pin all the API workers: - -```python -from localpost.http import ( - Routes, ServerConfig, http_server, static_handler, thread_pool_handler, -) - -routes = Routes() -# ... register API routes ... - -api = thread_pool_handler(routes.build().as_handler()) -static = thread_pool_handler( - static_handler("/var/www", prefix=b"/static/", - cache_control="public, max-age=31536000, immutable"), -) - -async with api as api_h, static as static_h: - def root(ctx): - return (static_h if ctx.request.path.startswith(b"/static/") else api_h)(ctx) - async with http_server(ServerConfig(), root): - ... -``` - -Both wrappers share the process-wide worker pool — workers are spawned -on demand and reused. See -[`examples/http/static_files.py`](../../examples/http/static_files.py). - -`ctx.sendfile(response, file, offset, count)` is part of the public -`HTTPReqCtx` Protocol and can be used directly for any zero-copy body. -Requires `Content-Length: ` on the response (chunked is rejected); -both backends keep their parser state consistent with what the kernel -writes out-of-band. - -### `localpost.http.compress` - -`compress_handler(inner, *, algorithms=("br","gzip"), min_size=1024, -compressible_types=...)` wraps a `RequestHandler` so eligible -`complete()` responses are compressed — `gzip` (stdlib, always -available) plus `br` (optional via `[http-compress]`). Pair with a JSON -/ HTML / XML API; **not** intended for static files (compression and -zero-copy `sendfile` are at odds). - -Behind a CDN you usually don't need this: the CDN compresses at the -edge from an uncompressed origin. `compress_handler` is for deployments -*not* behind a CDN, or when the CDN doesn't compress (rare). - -The middleware skips compression when any of these hold (response sent -verbatim): - -- `Accept-Encoding` doesn't list any configured `algorithms` with q>0 -- Method is `HEAD` (no body to compress) -- `body is None` or `len(body) < min_size` -- Status is `1xx` / `204` / `304` (no body) or `206` (range — compressing - breaks byte semantics) -- Response already has `Content-Encoding` (other than `identity`) -- Response has `Cache-Control: no-transform` (RFC 9111) -- `Content-Type` main-type is not in `compressible_types` - -When eligible: body is compressed, `Content-Length` is replaced, -`Content-Encoding` is added, and `Accept-Encoding` is merged into -`Vary` (existing `Vary: Cookie` becomes `Vary: Cookie, Accept-Encoding`; -`Vary: *` is left alone). - -`compress_handler` intercepts both `complete(...)` and `stream(...)`. -The middleware decides per-request: - -- One-shot path → compress the whole body in memory; replace - `Content-Length`. -- Streaming path with **no** `Content-Length` declared → wrap the chunk - iterator with an incremental compressor; each input chunk is emitted - compressed + sync-flushed so the decompressor sees each chunk - promptly. The backend auto-frames `Transfer-Encoding: chunked` on - HTTP/1.1. -- Streaming path **with** `Content-Length` → pass through. - -For SSE (`Content-Type: text/event-stream`, in -`DEFAULT_COMPRESSIBLE_TYPES`), each event your generator yields reaches -the client decompressed and parseable by `EventSource` — same approach -nginx uses with `gzip on; gzip_types text/event-stream`. `sendfile` -always passes through uncompressed — composition with the static -handler stays zero-copy. - -Limitations: the one-shot path allocates a compressed buffer per -response (fine for typical JSON; for multi-MB single-shot payloads, -hand `compress_handler` a `stream(response, chunks)` instead). Brotli -is opt-in (`pip install localpost[http-compress]`); if `"br"` is in -`algorithms` without the extra, `compress_handler` raises -`ImportError` at construction time. See -[`examples/http/compressed_api.py`](../../examples/http/compressed_api.py). - -### `localpost.http.flask` - -Native Flask adapter — optional extra `[http-flask]`. `flask_handler(app)` -turns a Flask app into a `RequestHandler`; `flask_server(config, app)` -hosts it as a service (selector-thread, no pool). Bypasses WSGI on both -sides: drives Flask's pipeline directly and streams the Werkzeug -`Response` straight to h11. - -### `localpost.http.router_sentry` / `localpost.http.flask_sentry` - -Sentry tracing wrappers. `sentry_router_handler(router, *, op=...)` -wraps a `Router` in a Sentry transaction per request — transaction -named `"METHOD /books/{id}"` (the URI template, low cardinality) on a -match, or `"METHOD /raw/path"` on a miss. Optional extra -`[http-sentry]`. - -`sentry_flask_handler(app, *, op=...)` wraps the native Flask adapter. -Requires both `[http-flask]` and `[http-sentry]`. Sentry's stock -`FlaskIntegration` ends the transaction when the WSGI `wsgi_app` -returns — *before* the body is iterated. Spans / errors inside a -streaming generator land outside the request transaction (or are -dropped). Because our Flask adapter holds the request context (and the -transaction) open through `response.iter_encoded()`, this fix-pack -version keeps everything on the same transaction. Behaviour -differences from `wsgi_server`: Flask's request context is active -during response-body iteration (a generator returned from a view can -use `flask.request`, `session`, `g` without `@stream_with_context`); -`teardown_request` / `teardown_appcontext` run **after** the body is -fully sent. Adapter touches Werkzeug/Flask internals (stable across -Flask 3.x but not a long-term contract) — use `wsgi_server` for -framework-agnostic WSGI. - -### Hosting integration - -`http_server(config, handler, *, selectors=1, acceptor=False)` is the -`@hosting.service`-decorated wrapper. `wsgi_server(config, app, ...)` -is the same shape for a generic WSGI app. `flask_server(config, app)` -covers the native Flask adapter. `thread_pool_handler(inner)` is an -async CM that yields a `RequestHandler` running `inner` on a shared -worker thread (process-wide pool, workers spawned on demand, no -concurrency cap). - -Threading topology (`selectors=N`, `acceptor=True`) is covered in -[docs/design/threading-topologies.md](../../docs/design/threading-topologies.md). - -## Sync vs. async surface - -`HTTPReqCtx` (sync) and `AsyncHTTPReqCtx` (async) share the data side -and the terminal write methods. A handler that touches only the core -surface is portable between transports. The full asymmetry table and -why each member differs lives in -[docs/design/connection-model.md](../../docs/design/connection-model.md#sync-vs-async-request-context-surface). - -## Cancellation - -`HTTPReqCtx.disconnected` is a pull-style poll for peer-gone, mirroring -`AsyncHTTPReqCtx.disconnected`. Native backends do a non-blocking -`recv(1, MSG_PEEK | MSG_DONTWAIT)` on the request socket and stick `True` -once seen; the WSGI bridge always returns `False` (no socket handle). - -For sync handlers without `ctx` in scope, `check_cancelled()` raises -`RequestCancelled` if the client disconnected (detected via -non-blocking `MSG_PEEK`) or the hosted service is shutting down. Call -periodically in long-running handlers. - -## Design - -The design rationale lives in `docs/design/`: - -- [connection-model.md](../../docs/design/connection-model.md) — - dispatch chain, two-state TRACKED/BORROWED machine, pull-based - disconnect detection, sync-vs-async asymmetry. -- [threading-topologies.md](../../docs/design/threading-topologies.md) - — `selectors=N` / acceptor topology, composition pattern, three - orthogonal concerns (handler / router / `http_server`). -- [server-backends.md](../../docs/design/server-backends.md) — h11 and - httptools coexistence, why no unified parser Protocol, httptools - caveats. -- [request-body-handling.md](../../docs/design/request-body-handling.md) - — the `ctx.receive(size)` contract across native sync, WSGI, ASGI, - and RSGI. -- [deployment-topologies.md](../../docs/design/deployment-topologies.md) - — hosted services *inside* `run_app` (uvicorn / hypercorn) vs Granian - as a process supervisor that runs the host *inside* its workers. - -The native server is sync-only by design — async transports plug in via -`localpost.http.asgi.to_asgi` rather than growing a parallel async -request path inside this module. - -## See also +**Full reference:** -- Examples: [`examples/http/`](../../examples/http/) -- Server source: [`server.py`](server.py) -- Router source: [`router.py`](router.py) +Examples: [`examples/http/`](../../examples/http/). diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index 12b7622..1e1d66f 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -1,474 +1,46 @@ # localpost.openapi Type-driven HTTP framework with built-in **OpenAPI 3.2** generation, on top of -[`localpost.http`](../http/README.md). FastAPI-inspired; the differences: - -- **Response shapes are union return types**, not a separate `response_model=` - declaration: - ```python - def get_book(book_id: str) -> Book | NotFound[str]: ... - ``` - Both branches end up in the OpenAPI doc with proper schemas; the runtime - short-circuits to the right status code. -- **OpenAPI-aware middleware.** Middlewares and auth contribute to the - spec themselves (security schemes, extra parameters, extra response codes) - via an `update_doc` hook. There's no second place to declare auth. -- **msgspec-first.** [msgspec](https://jcristharif.com/msgspec/) does request - body decoding, response encoding, and JSON Schema generation. Pydantic - models *and* `attrs.define`'d classes are recognised automatically — but - neither is a runtime dependency of the `openapi` extra; install them - yourself if you want to use them. - -## Install +[`localpost.http`](../http/README.md). FastAPI-inspired; response shapes are +union return types (`Book | NotFound[str]`), middleware is OpenAPI-aware +(security schemes and extra responses contributed by the middleware itself), +and msgspec drives encoding / decoding / schema generation. Pydantic and +`attrs` classes are recognised automatically when present. ```bash pip install 'localpost[http,openapi]' ``` -For Pydantic models in handlers: - -```bash -pip install pydantic -``` - -For `attrs` classes in handlers (uses `cattrs` for structuring): - -```bash -pip install 'localpost[openapi-attrs]' -# or, equivalently: -pip install attrs cattrs -``` - -## Quick start - ```python import sys from dataclasses import dataclass - from localpost import hosting from localpost.http import ServerConfig -from localpost.openapi import HttpApp, NotFound, BadRequest, Created +from localpost.openapi import HttpApp, NotFound @dataclass class Book: - id: str - title: str - author: str + id: str; title: str app = HttpApp() -@app.get("/hello/{name}") -def hello(name: str) -> str | BadRequest[str]: - if name.lower() == "donald": - return BadRequest("Sorry, you are not welcome here") - return f"Hello, {name}!" - - @app.get("/books/{book_id}") def get_book(book_id: str) -> Book | NotFound[str]: if book_id != "42": return NotFound(f"Book not found: {book_id}") - return Book(id=book_id, title="HHGTTG", author="Adams") - - -@app.post("/books") -def create_book(book: Book) -> Created[Book]: - return Created(book, headers={"Location": f"/books/{book.id}"}) + return Book(id=book_id, title="HHGTTG") if __name__ == "__main__": sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) ``` -```bash -curl http://localhost:8000/hello/world -curl http://localhost:8000/openapi.json # OpenAPI 3.2 doc -open http://localhost:8000/docs # Swagger UI -open http://localhost:8000/docs/redoc # ReDoc -open http://localhost:8000/docs/scalar # Scalar -``` - -## Concepts - -### Operations - -Plain Python functions registered with `@app.get(path)`, `.post`, `.put`, -`.delete`, `.patch`. The path follows -[`URITemplate`](../http/README.md) syntax (`/books/{id}`). - -### Argument resolvers - -One per parameter. Picked from the annotation; explicit factories override: - -| Source | Factory | Auto-picked when… | -|---|---|---| -| Path variable | `FromPath()` | param name matches `{name}` in template | -| Query string | `FromQuery()` | scalar parameter not in path, no body type | -| Header | `FromHeader("X-…")` | only via explicit `Annotated[...]` | -| Request body | `FromBody()` | param annotated as `msgspec.Struct` / dataclass / pydantic model / `attrs` class | -| Request ctx | (none) | param annotated as `HTTPReqCtx` | - -Each resolver may short-circuit by returning an `OpResult` (e.g. validation -failure → `BadRequest`). - -### `OpResult` hierarchy - -| Class | Status | Notes | -|---|---|---| -| `Ok[T]` | 200 | Implicit when you return a plain value. | -| `Created[T]` | 201 | | -| `Accepted[T]` | 202 | | -| `NoContent` | 204 | No body. | -| `EventStreamResult[T]` | 200 | SSE stream — see below. | -| `BadRequest[T]` | 400 | | -| `Unauthorized[T]` | 401 | | -| `Forbidden[T]` | 403 | | -| `NotFound[T]` | 404 | | -| `Conflict[T]` | 409 | | -| `UnprocessableEntity[T]` | 422 | | -| `TooManyRequests[T]` | 429 | | -| `InternalServerError[T]` | 500 | | - -Use the *class* in return annotations (`Book | NotFound[str]`) so the OpenAPI -doc picks up the body type per status code. - -### `OpMiddleware` - -A middleware wraps the operation core. It receives the request context and -a `call_next` callable that runs the rest of the chain — and knows how to -describe itself in the OpenAPI doc: - -```python -ApiOperation = Callable[[HTTPReqCtx], OpResult] - - -class OpMiddleware(Protocol): - def __call__(self, ctx: HTTPReqCtx, call_next: ApiOperation, /) -> OpResult: ... - def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spec.OpenAPI: ... - def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: ... -``` - -- `__call__` runs around the operation. Return `call_next(ctx)` to forward - (optionally post-processing the result), or short-circuit by returning - any other `OpResult`. -- `contribute_root` is called once at spec-build time (typically to register - a `SecurityScheme`). -- `contribute_operation` is called for each operation that the middleware is - attached to (typically to add a 401 response and a `security` requirement). - -Apply app-wide: - -```python -app = HttpApp(middlewares=[my_middleware]) -``` - -or per-operation: - -```python -@app.get("/admin", middlewares=[my_middleware]) -def admin() -> str: ... -``` - -### `@op_middleware` — middlewares as tiny operations - -For the common case, write a middleware the same way you'd write a handler: -declare your inputs (plus the `call_next` parameter), declare your possible -failure responses in the return type, and let the framework do the OpenAPI -bookkeeping: - -```python -from typing import Annotated - -from localpost.http import HTTPReqCtx -from localpost.openapi import ( - ApiOperation, - FromHeader, - OpResult, - TooManyRequests, - op_middleware, -) - - -@op_middleware -def rate_limit( - ctx: HTTPReqCtx, - call_next: ApiOperation, - x_client: Annotated[str, FromHeader("X-Client-Id")], -) -> TooManyRequests[str] | OpResult: - if quota_exhausted(x_client): - return TooManyRequests("Slow down") - return call_next(ctx) - - -@app.get("/expensive", middlewares=[rate_limit]) -def expensive() -> str: ... -``` - -Without writing any spec code, every operation that uses this middleware -gets: -- the `X-Client-Id` header in its `parameters`, -- `429 Too Many Requests` in its `responses` (with the body schema for `str`). - -The return-type union *is* the OpenAPI contract. Bare `OpResult` in the -union is the *passthrough* sentinel and contributes nothing; subclasses -contribute their response code. Middlewares must return an `OpResult` -(typically by calling `call_next(ctx)`); anything else raises `TypeError` -at request time. - -> **SSE caveat.** When the wrapped operation streams an SSE response, -> `call_next` returns an `EventStreamResult` whose body is an iterator. A -> middleware can wrap that iterator (transforming/dropping events) but -> can't post-process headers after streaming has started — they're sent -> before the first event. - -### Server-Sent Events (SSE) - -Return a generator (or any iterator) and the operation auto-promotes to an -SSE stream — `Content-Type: text/event-stream`, chunked transfer encoding, -one event per yielded value: - -```python -from collections.abc import Generator -from dataclasses import dataclass - -from localpost.openapi import HttpApp, Event - - -@dataclass -class Tick: - n: int - - -app = HttpApp() - - -@app.get("/clock") -def clock() -> Generator[Event[Tick]]: - for n in range(60): - yield Event(data=Tick(n=n), id=str(n)) - time.sleep(1) -``` - -Yield bare values for `data:`-only events; yield `Event` instances for -control over `event:` / `id:` / `retry:` / comment fields. The OpenAPI doc -emits `text/event-stream` (with the schema for `Event[Tick]`) instead of -`application/json` for that operation. - -Cancellation: the framework calls `localpost.http.check_cancelled` between -events, so client disconnects (and pool shutdowns) terminate the stream -without leaking workers — provided the app runs under -`thread_pool_handler` (the default for `HttpApp.service(...)`). - -For an iterator you've already constructed, wrap it in `EventStream(...)` -to be explicit; otherwise just return the generator directly. - -### Built-in auth middlewares - -```python -from localpost.openapi import HttpApp, HttpBearerAuth, HttpBasicAuth - -def validate_token(token: str) -> dict | None: - # Return any truthy principal on success, None on failure. - # The principal is stashed on ctx.attrs[] for handlers to read. - return decode_jwt(token) - - -app = HttpApp(middlewares=[HttpBearerAuth(validator=validate_token)]) - - -@app.get("/me") -def me() -> dict: - # Pull the principal from a custom resolver, or just inject the ctx. - ... -``` - -`HttpBearerAuth` registers an HTTP `bearer` security scheme; `HttpBasicAuth` -registers `basic` and sends a `WWW-Authenticate` challenge on 401. Both -attach a 401 response and a `security` requirement to every operation they -cover. `OpenIDConnectAuth` is a follow-up. - -## Hosting - -`HttpApp.service(config)` returns a `localpost.hosting.service` you feed to -`hosting.run_app(...)` or `hosting.serve(...)`. It composes: - -1. A worker pool (`thread_pool_handler`) so user fns run on threads, not the - selector. -2. The HTTP server (`http_server`). - -Pass `selectors=N` and/or `acceptor=True` to use multi-selector topology. - -## Async flavour (`HttpAsyncApp`) - -Parallel to `HttpApp`, like `httpx.Client` and `httpx.AsyncClient`. -Same decorator API, same OpenAPI 3.2 emission, same `OpResult` -hierarchy — handlers are `async def`, middleware is built with -`@async_op_middleware`, and the deployment target is **ASGI** (uvicorn, -hypercorn, or `granian --interface asgi`): - -```python -import sys -from dataclasses import dataclass - -import uvicorn - -from localpost.openapi import HttpAsyncApp, NotFound - - -@dataclass -class Book: - id: str - title: str - - -app = HttpAsyncApp() - - -@app.get("/books/{book_id}") -async def get_book(book_id: str) -> Book | NotFound[str]: - book = await fetch_book(book_id) # your async DB call, etc. - if book is None: - return NotFound(f"Book not found: {book_id}") - return book - - -if __name__ == "__main__": - sys.exit(uvicorn.run(app.asgi(), host="127.0.0.1", port=8000)) -``` - -For `localpost.hosting` integration use -`app.service(uvicorn.Config(...))` and feed it to -`hosting.run_app(...)` — same shape as `HttpApp.service(config)` but -running the ASGI app under uvicorn. - -A side-by-side example ports the sync `examples/openapi/app.py` -verbatim: see [`examples/openapi/async_app.py`](../../examples/openapi/async_app.py). - -### What's different - -- **Handlers are async.** Plain `async def` for normal routes; - `async def` generators for SSE. Sync generators are rejected at - request time — `HttpAsyncApp` won't silently bridge a blocking - generator onto the event loop. -- **Middleware is async.** Use `@async_op_middleware` to build them. - Sync `OpMiddleware` instances are rejected at app construction time: - - ```python - from typing import Annotated - - from localpost.openapi import ( - AsyncApiOperation, AsyncHTTPReqCtx, FromHeader, - OpResult, Unauthorized, async_op_middleware, - ) - - - @async_op_middleware - async def require_api_key( - ctx: AsyncHTTPReqCtx, - call_next: AsyncApiOperation, - x_api_key: Annotated[str, FromHeader("X-API-Key")] = "", - ) -> Unauthorized[str] | OpResult: - if x_api_key != "secret": - return Unauthorized("Invalid or missing X-API-Key") - return await call_next(ctx) - ``` -- **Auth.** `AsyncHttpBearerAuth` and `AsyncHttpBasicAuth` mirror their - sync siblings; the validator may be sync or async. -- **Body buffering.** The ASGI bridge pre-buffers the request body - before dispatch (matches the JSON-API common case the sync flavour - is tuned for) so the same `FromBody` resolver works in both - flavours. Cap the size with `HttpAsyncApp(max_body_size=N)` (default - 1 MiB; `-1` disables). Streaming uploads via `ctx.receive(...)` are - on the follow-up list. -- **Resolvers.** `FromPath`, `FromQuery`, `FromHeader`, `FromBody` are - shared — they only read sync attributes (`request`, `body`, `attrs`) - off the ctx, so the same factories work in both apps. - -### Deployment - -Two flavours, depending on which target you ship to. - -**ASGI** — `app.asgi()` returns a plain ASGI 3 callable; any ASGI -server runs it: - -```python -# myapp.py -from localpost.openapi import HttpAsyncApp - -app = HttpAsyncApp() -# … register routes … -asgi_app = app.asgi() -``` - -```bash -uvicorn myapp:asgi_app -hypercorn myapp:asgi_app -granian --interface asgi myapp:asgi_app -``` - -**RSGI (Granian native)** — `app.as_rsgi()` returns an RSGI application: - -```python -rsgi_app = app.as_rsgi() -``` - -```bash -granian --interface rsgi myapp:rsgi_app -``` - -The RSGI bridge (`localpost.http.to_rsgi`) is wire-format-only — the -same handler chain serves both ASGI and RSGI traffic. Granian's RSGI -gives a single eager `response_bytes` per response (vs ASGI's -two-event start+body), zero-copy `sendfile` via -`response_file_range`, and direct `async for` body reads in streaming -mode. Requires the `[rsgi]` extra (`pip install 'localpost[rsgi]'`). - -**Hosted apps under Granian** — when the HTTP app shares its worker -process with other hosted services (scheduler, gRPC, custom workers), -deploy through `localpost.hosting.HostRSGIApp` instead, which runs the -full hosting lifecycle inside each Granian worker: - -```python -from localpost import hosting -from localpost.scheduler import every, scheduled_task - -@scheduled_task(every(seconds=5)) -async def heartbeat(): ... - - -rsgi_app = hosting.HostRSGIApp( - services=[heartbeat.service()], - rsgi_handler=app, -) - -# granian --interface rsgi --workers 4 myapp:rsgi_app -``` - -See the [hosting README](../hosting/README.md#host-as-rsgi-for-granian) -for the topology details. - -## Design notes - -See the in-tree design doc for rationale and a layout map: keep this README -focused on user-facing concepts. +`/openapi.json` plus `/docs` (Swagger UI), `/docs/redoc`, `/docs/scalar` are +served automatically. -## Sub-modules +**Full reference:** -| Module | Provides | -|---|---| -| `app.py` | `HttpApp`, registration, hosting entrypoint, built-in `/openapi.json` + `/docs` UIs (sync) | -| `operation.py` | `Operation`: sync runtime closure | -| `_operation_core.py` | Shared type-level core (signature parsing, return-type → response-shape, doc helpers, response encoding) — used by both flavours | -| `resolvers.py` | `FromPath`, `FromQuery`, `FromHeader`, `FromBody` factories — shared between sync and async | -| `results.py` | `OpResult` hierarchy (incl. `EventStreamResult`) | -| `middleware.py` | `OpMiddleware` protocol, `@op_middleware`, `ApiOperation` (sync) | -| `auth.py` | `HttpBearerAuth`, `HttpBasicAuth` — concrete auth middlewares (sync) | -| `aio/` | Async flavour: `HttpAsyncApp`, `AsyncOperation`, `AsyncOpMiddleware`, `@async_op_middleware`, `AsyncHttpBearerAuth`, `AsyncHttpBasicAuth`, `AsyncHTTPReqCtx`, ASGI bridge | -| `sse.py` | `Event`, `EventStream`, encoder — Server-Sent Events (sync drive) | -| `spec.py` | OpenAPI 3.2 dataclasses | -| `schemas.py` | `SchemaRegistry` — msgspec / pydantic JSON Schema generation | -| `pydantic.py` | Explicit pydantic helpers (auto-detection in `FromBody` works without this) | -| `_docs.py` | HTML for Swagger UI / ReDoc / Scalar (CDN-loaded) | +Examples: [`examples/openapi/`](../../examples/openapi/). diff --git a/localpost/scheduler/README.md b/localpost/scheduler/README.md index 458d9cf..a53fabb 100644 --- a/localpost/scheduler/README.md +++ b/localpost/scheduler/README.md @@ -3,129 +3,29 @@ Composable in-process task scheduler. Tasks are triggered by **conditions** (time intervals, cron expressions, completion of another task), and triggers are built up with operators — `//` to compose middleware, `>>` to extend a -trigger's own middleware pipeline. Tasks publish their outputs as `Result[T]`, -so downstream tasks can subscribe. - -Use it when you need to schedule background work inside the same process — for -example, refresh a cached dataframe every minute. For distributed / -persistent tasks, use Celery, APScheduler with a DB store, etc. - -## Install +trigger's own middleware pipeline. Tasks publish `Result[T]` so downstream +tasks can subscribe. ```bash -pip install localpost[scheduler] # human-readable periods (pytimeparse2, humanize) -pip install localpost[scheduler,cron] # also the cron() trigger (croniter) +pip install localpost[scheduler] # human-readable periods +pip install localpost[scheduler,cron] # also the cron() trigger ``` -## Quick start - ```python -import random -import sys +import sys, random from localpost.hosting import run_app -from localpost.scheduler import after, delay, every, scheduled_task, take_first +from localpost.scheduler import every, scheduled_task -@scheduled_task(every("3s") // delay((0, 1))) +@scheduled_task(every("3s")) async def task1(): return random.randint(1, 22) -@scheduled_task(after(task1) // take_first(3)) -async def task2(task1_result: int): - print(f"task1 emitted: {task1_result}") - - if __name__ == "__main__": - sys.exit(run_app(task1, task2)) -``` - -Cron: - -```python -from localpost.scheduler import delay, scheduled_task -from localpost.scheduler.cond.cron import cron - - -@scheduled_task(cron("*/1 * * * *") // delay((0, 10))) -async def job(): - print("running") -``` - -See [`examples/scheduler/`](../../examples/scheduler/) for more -(`cancellation.py`, `failing_tasks.py`, `fastapi_lifespan.py`, `finite_task.py`, -`serve_sync.py`, `fastdepends.py`). - -## Key concepts - -- **`ScheduledTask[T, R]`** — the runtime object: a handler paired with a - trigger factory. Publishes a `Result[R]` stream that others can subscribe to. -- **`ScheduledTaskTemplate[T]`** — a pre-bound trigger factory waiting for a - handler. Composable with `//` (compose trigger middleware) and `>>` - (extend the trigger's own middleware tuple). `every(...)`, `after(...)`, - `after_all(...)`, and `cron(...)` all return templates. -- **`TriggerFactory[T]`** — `Callable[[TaskGroup, EventView], - AbstractAsyncContextManager[AsyncIterator[T]]]`. Under the hood, a trigger is - an async iterator of events fired inside the task's lifetime. -- **Trigger middleware** — an async generator that consumes one `AsyncIterator` - and yields another. `delay` and `take_first` are built-in; write your own the - same way. -- **`Result[T]`** — output envelope (`Ok` or failure). `after()` filters - successes and forwards the `T`; `after_all()` forwards every result. -- **Hosting integration** — both individual tasks and `Scheduler` instances - are `ServiceF`s. Pass them to `localpost.hosting.run_app(...)` (entry point, - signal handling) or `localpost.hosting.serve(...)` (async CM, e.g. for - embedding in a FastAPI lifespan). - -## Writing a custom trigger - -A trigger is a frozen dataclass / callable that, given a `TaskGroup` and a -`shutting_down` event, returns an async context manager yielding an -`AsyncIterator[T]`. Pattern (adapted from `_cond.py:Every`): - -```python -from dataclasses import dataclass, replace -from contextlib import asynccontextmanager - -@dataclass(frozen=True, slots=True) -class MyTrigger[T]: - middlewares: tuple[TriggerMiddleware, ...] = () - - def __rshift__(self, mw): # >> adds a middleware - return replace(self, middlewares=self.middlewares + (mw,)) - - @asynccontextmanager - async def __call__(self, tg, shutting_down): - async with apply_middlewares(my_source(), self.middlewares) as stream: - yield stream + sys.exit(run_app(task1)) ``` -Then wrap it: `my_trigger = ScheduledTaskTemplate(MyTrigger())`. - -## Writing a custom middleware - -A middleware is a regular async generator: - -```python -from collections.abc import AsyncIterator -from localpost._utils import maybe_closing - -def skip_every_other(): - async def middleware[T](events: AsyncIterator[T]) -> AsyncIterator[T]: - async with maybe_closing(events): - i = 0 - async for event in events: - if i % 2 == 0: - yield event - i += 1 - return middleware -``` - -Use it via `every("1s") // skip_every_other()`. - -## See also +**Full reference:** -- Examples: [`examples/scheduler/`](../../examples/scheduler/) -- Cron source: [`cond/cron.py`](cond/cron.py) -- Built-in triggers: [`_cond.py`](_cond.py) -- Core: [`_scheduler.py`](_scheduler.py) +Examples: [`examples/scheduler/`](../../examples/scheduler/). diff --git a/mkdocs.yml b/mkdocs.yml index 9ce7036..fbd34d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,10 +35,6 @@ theme: plugins: - search - - include-markdown - -hooks: - - docs/_hooks.py markdown_extensions: - admonition @@ -51,9 +47,6 @@ markdown_extensions: - toc: permalink: true -not_in_nav: | - /adr/template.md - nav: - Home: index.md - Getting started: getting-started.md diff --git a/pyproject.toml b/pyproject.toml index 2269ade..6679aae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -158,11 +158,9 @@ bench-openapi = [ "pydantic ~=2.7", ] docs = [ - "mkdocs ~=1.6", - "mkdocs-material ~=9.5", - # Surfaces per-module READMEs (localpost//README.md) on the docs site - # without duplication. - "mkdocs-include-markdown-plugin ~=7.0", + # Pre-1.0; pin to 0.x and accept early-adopter risk. Reads mkdocs.yml + # natively. Replaces mkdocs + mkdocs-material. + "zensical >=0.0.40,<0.1", ] [tool.coverage.run] diff --git a/uv.lock b/uv.lock index d8c7517..27525bd 100644 --- a/uv.lock +++ b/uv.lock @@ -65,28 +65,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, -] - -[[package]] -name = "backrefs" -version = "7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, - { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -96,15 +74,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] -[[package]] -name = "bracex" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, -] - [[package]] name = "brotli" version = "1.2.0" @@ -392,6 +361,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/f5/135d0e57e5bdd2f978388d77ee818f1ac5ac584eb48034362770001f4cad/croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0", size = 23220, upload-time = "2024-10-25T12:22:30.75Z" }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -468,18 +446,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/11/66f0d176f578c512a917b925eab152d518b9981f7face129773463af780a/flask_openapi-5.0.0rc1-py3-none-any.whl", hash = "sha256:5cd00c6c26435a9e981c67b9580d5e30e1ee7557386752340289b11774691c65", size = 42894, upload-time = "2026-02-09T07:36:16.363Z" }, ] -[[package]] -name = "ghp-import" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.74.0" @@ -875,9 +841,7 @@ dev-types = [ { name = "types-grpcio" }, ] docs = [ - { name = "mkdocs" }, - { name = "mkdocs-include-markdown-plugin" }, - { name = "mkdocs-material" }, + { name = "zensical" }, ] examples = [ { name = "fastapi-slim" }, @@ -952,11 +916,7 @@ dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, ] -docs = [ - { name = "mkdocs", specifier = "~=1.6" }, - { name = "mkdocs-include-markdown-plugin", specifier = "~=7.0" }, - { name = "mkdocs-material", specifier = "~=9.5" }, -] +docs = [{ name = "zensical", specifier = ">=0.0.40,<0.1" }] examples = [{ name = "fastapi-slim", specifier = "~=0.128" }] tests = [ { name = "pytest", specifier = "~=9.0" }, @@ -1043,97 +1003,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, -] - -[[package]] -name = "mkdocs-include-markdown-plugin" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, - { name = "wcmatch" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/2d/bdf1aee3f4f7b34148b0f62298b62f03415160cb2707f09503c99a0a7cd5/mkdocs_include_markdown_plugin-7.2.2.tar.gz", hash = "sha256:f052ccb741eccf498116b826c1d78a2d761c56747372594709441cee0963fbc9", size = 25415, upload-time = "2026-03-29T15:15:14.2Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/a5/f6b2f0aa805dbda52f6265e9aff1450c8643195442facf29d475bdeba15d/mkdocs_include_markdown_plugin-7.2.2-py3-none-any.whl", hash = "sha256:f2ec4487cf32d3e33ca528f9366f20fb9280ded9c8d1630eb2bbda244962dcd1", size = 29528, upload-time = "2026-03-29T15:15:13.079Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - [[package]] name = "more-itertools" version = "11.0.2" @@ -1317,33 +1186,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1663,18 +1505,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, -] - [[package]] name = "requests" version = "2.33.1" @@ -1810,6 +1640,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "trio" version = "0.33.0" @@ -1897,42 +1772,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - -[[package]] -name = "wcmatch" -version = "10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bracex" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, -] - [[package]] name = "werkzeug" version = "3.1.8" @@ -1957,6 +1796,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] +[[package]] +name = "zensical" +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" }, + { url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" }, +] + [[package]] name = "zipp" version = "3.23.1" From 8636b7291e4fb2cd8df923e0525d01fceb0e7339 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 20:30:02 +0400 Subject: [PATCH 223/286] fix(scheduler): keep schedule loop alive across handler exceptions Task.__call__ used to publish Result.failure and re-raise, which broke out of the run loop after the first failure (failing_tasks.py example died on the first iteration of every task). It now logs and publishes the failure, swallowing it so the schedule keeps firing. Also drop the dead `>>` plumbing on ScheduledTaskTemplate (handler decorators were never applied) and tighten the `take_first(0)` guard. Tests cover failure recovery and the after()/take_first composition (the suite previously relied on a 0.5s sleep + one-firing assertion and had no coverage for after()). Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/scheduler/_scheduler.py | 24 ++--------- localpost/scheduler/_trigger.py | 4 +- tests/scheduler/scheduler.py | 67 ++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index aca3b7c..053f97b 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -36,7 +36,6 @@ R = TypeVar("R") type TaskHandler[T, R] = Callable[[T], Awaitable[R]] | Callable[[], Awaitable[R]] | Callable[[T], R] | Callable[[], R] -type HandlerDecorator = Callable[[Any], Any] logger = logging.getLogger("localpost.scheduler") @@ -90,14 +89,11 @@ def _publish_result(self, result: Result[R]) -> None: # MessageHandler[T] async def __call__(self, event: T) -> None: try: - result = Result.ok(await self._handle(event)) - self._publish_result(result) - except TypeError: - raise + result: Result[R] = Result.ok(await self._handle(event)) except Exception as e: + logger.exception("Task %r raised", self.name) result = Result.failure(e) - self._publish_result(result) - raise + self._publish_result(result) async def __aenter__(self): self._users += 1 @@ -123,7 +119,6 @@ def ensure(cls, tpl: TriggerFactory[T]) -> ScheduledTaskTemplate[T]: def __init__(self, tf: TriggerFactory[T]): self._tf = tf self._tf_queue: tuple[TriggerFactoryDecorator, ...] = () - self._handler_decorators: tuple[HandlerDecorator, ...] = () # TriggerFactory[T] def __call__(self, *args, **kwargs) -> Trigger[T]: @@ -139,15 +134,6 @@ def __floordiv__(self, decorator: TriggerFactoryDecorator[T, T2]) -> ScheduledTa n._tf_queue = self._tf_queue + (decorator,) return cast(ScheduledTaskTemplate[T2], n) - def __rshift__(self, decorator: HandlerDecorator) -> ScheduledTaskTemplate[T]: - n = ScheduledTaskTemplate[T](self._tf) - n._handler_decorators = self._handler_decorators + (decorator,) - return n - - def resolve_handler(self, task: Task[T, Any]) -> AbstractAsyncContextManager[Callable[[T], Awaitable[None]]]: - # TODO: Support handler decorators (flow module) - return task - @property def tf(self) -> TriggerFactory[T]: tf = self._tf @@ -169,8 +155,6 @@ class _ScheduledTask[T, R]: def __init__(self, task: Task[T, R], tf: TriggerFactory[T]): self.task = task self._trigger_factory = tf - tpl = ScheduledTaskTemplate.ensure(tf) - self._handler = tpl.resolve_handler(task) self._shutting_down: EventView = Event() # Placeholder, resolved in run() def __repr__(self): @@ -188,7 +172,7 @@ async def run(self, shutting_down: EventView) -> None: self._shutting_down = shutting_down trigger = self._trigger_factory(self) - async with trigger as t_events, self._handler as message_handler: + async with trigger as t_events, self.task as message_handler: async for t_event in t_events: await message_handler(t_event) logger.debug("%r trigger is completed", self) diff --git a/localpost/scheduler/_trigger.py b/localpost/scheduler/_trigger.py index 5d6a175..48f3648 100644 --- a/localpost/scheduler/_trigger.py +++ b/localpost/scheduler/_trigger.py @@ -35,10 +35,10 @@ def take_first[T](n: int, /) -> TriggerFactoryDecorator[T, T]: @trigger_factory_middleware async def middleware(source: Trigger[T], _): - iter_n = 0 - if iter_n >= n: + if n == 0: return async with source as events: + iter_n = 0 async for event in events: iter_n += 1 yield event diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py index e2fb0c2..fbc356c 100644 --- a/tests/scheduler/scheduler.py +++ b/tests/scheduler/scheduler.py @@ -6,9 +6,9 @@ from anyio import fail_after from localpost.hosting import serve -from localpost.scheduler import Scheduler, every, scheduled_task +from localpost.scheduler import Scheduler, after, every, scheduled_task, take_first -pytestmark = [pytest.mark.anyio, pytest.mark.integration] +pytestmark = pytest.mark.anyio async def test_task_decorator(): @@ -73,3 +73,66 @@ def sample_task(): lt.shutdown() assert len(results) == 1 + + +async def test_handler_failure_does_not_kill_loop(): + """A raising handler must be logged and published as Result.failure, but the schedule loop must keep firing.""" + runs = 0 + + @scheduled_task(every(timedelta(seconds=0.05)) // take_first(3)) + def flaky(): + nonlocal runs + runs += 1 + raise RuntimeError("boom") + + with fail_after(2): + async with serve(flaky) as lt: + await lt.stopped # ``take_first(3)`` makes the service stop on its own + + assert runs == 3 + + +async def test_after_trigger_chains_results(): + """``after(task1)`` fires task2 with task1's return value on every successful run.""" + scheduler = Scheduler() + seen: list[int] = [] + + @scheduler.task(every(timedelta(seconds=0.05)) // take_first(3)) + def producer() -> int: + return 42 + + @scheduler.task(after(producer)) + def _consumer(value: int) -> None: + seen.append(value) + + with fail_after(2): + async with serve(scheduler) as lt: + await lt.stopped # producer stops after 3 firings → consumer sees EndOfStream → scheduler stops + + assert seen == [42, 42, 42] + + +async def test_after_trigger_skips_failures(): + """``after(task1)`` only fires when task1 succeeds; failures are dropped (``after_all`` would see them).""" + scheduler = Scheduler() + seen: list[int] = [] + n = 0 + + @scheduler.task(every(timedelta(seconds=0.05)) // take_first(4)) + def flaky() -> int: + nonlocal n + n += 1 + if n % 2 == 0: + raise RuntimeError("even runs fail") + return n + + @scheduler.task(after(flaky)) + def _consumer(value: int) -> None: + seen.append(value) + + with fail_after(2): + async with serve(scheduler) as lt: + await lt.stopped + + # n = 1, 3 succeed; n = 2, 4 raise. Only odd values reach ``after``. + assert seen == [1, 3] From bdd0113b0954e7f569308b5e136053cf62b82d10 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 20:45:11 +0400 Subject: [PATCH 224/286] refactor(scheduler): commit to async-generator middleware in trigger types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``TriggerFactoryMiddleware`` was typed as ``AsyncIterable[T2]`` with a ``# TODO`` comment, and ``trigger_factory_middleware`` did a runtime ``isinstance`` check to decide whether to wrap with ``maybe_closing``. Both ``every`` and ``cron`` middleware are async-generator functions, so the heterogeneity wasn't actually in use. Tighten the alias to ``AsyncIterator[T2]``, drop the runtime check and the ``cast``, and collapse the long inline generic types now that ``Trigger[T]`` / ``TriggerFactory[T]`` exist. Drop the empty ``test_after_trigger`` placeholder — superseded by the end-to-end tests added in the prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/scheduler/_scheduler.py | 22 +++++++--------------- localpost/scheduler/_trigger.py | 16 ++++++++-------- tests/scheduler/cond.py | 4 ++-- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 053f97b..7ea042d 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -4,7 +4,7 @@ import inspect import logging import math -from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, ExitStack from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, final @@ -185,20 +185,12 @@ async def __call__(self, lt: ServiceLifetime) -> None: type Trigger[T] = AbstractAsyncContextManager[AsyncIterator[T]] -type TriggerFactory[T] = Callable[ - [ScheduledTask[T, Any]], AbstractAsyncContextManager[AsyncIterator[T]] # Trigger[T] -] -type TriggerFactoryMiddleware[T, T2] = Callable[ - [ - AbstractAsyncContextManager[AsyncIterator[T]], # Trigger[T] (source) - ScheduledTask, - ], - AsyncIterable[T2], # TODO AbstractAsyncContextManager[AsyncIterator[T2]] -] -type TriggerFactoryDecorator[T, T2] = Callable[ - [Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T]]]], # TriggerFactory[T] - Callable[[ScheduledTask], AbstractAsyncContextManager[AsyncIterator[T2]]], # TriggerFactory[T2] -] +type TriggerFactory[T] = Callable[[ScheduledTask[T, Any]], Trigger[T]] +# Middleware is written as an async generator function: it consumes the source ``Trigger[T]`` (a context +# manager so it can install/clean up a task group) and yields ``T2`` items. ``trigger_factory_middleware`` +# wraps the returned async generator into a ``Trigger[T2]`` via ``maybe_closing``. +type TriggerFactoryMiddleware[T, T2] = Callable[[Trigger[T], ScheduledTask], AsyncIterator[T2]] +type TriggerFactoryDecorator[T, T2] = Callable[[TriggerFactory[T]], TriggerFactory[T2]] def scheduled_task( # noqa: UP047 — see TypeVar comment above diff --git a/localpost/scheduler/_trigger.py b/localpost/scheduler/_trigger.py index 48f3648..d22400a 100644 --- a/localpost/scheduler/_trigger.py +++ b/localpost/scheduler/_trigger.py @@ -1,9 +1,7 @@ from __future__ import annotations import logging -from contextlib import AbstractAsyncContextManager from functools import wraps -from typing import cast from localpost._utils import DelayFactory, cancellable_from, ensure_delay_factory, maybe_closing, sleep, td_str @@ -15,14 +13,16 @@ def trigger_factory_middleware[T, T2]( middleware: TriggerFactoryMiddleware[T, T2], ) -> TriggerFactoryDecorator[T, T2]: + """Adapt an async-generator middleware into a ``TriggerFactoryDecorator``. + + The middleware receives the source ``Trigger[T]`` and yields ``T2`` items; we wrap the resulting + async generator with ``maybe_closing`` so it satisfies the ``Trigger[T2]`` (context manager) shape. + """ + def _decorator(source: TriggerFactory[T]) -> TriggerFactory[T2]: @wraps(source) - def _run(task): - source_events = source(task) - events = middleware(source_events, task) - return cast( - "Trigger[T2]", events if isinstance(events, AbstractAsyncContextManager) else maybe_closing(events) - ) + def _run(task) -> Trigger[T2]: + return maybe_closing(middleware(source(task), task)) return _run diff --git a/tests/scheduler/cond.py b/tests/scheduler/cond.py index ab404a7..8d28705 100644 --- a/tests/scheduler/cond.py +++ b/tests/scheduler/cond.py @@ -30,5 +30,5 @@ async def test_every_trigger(): mock_sleep.assert_awaited_once_with(5.0) -async def test_after_trigger(): - pass # TODO Implement +# ``after()`` end-to-end coverage lives in ``tests/scheduler/scheduler.py`` +# (test_after_trigger_chains_results / test_after_trigger_skips_failures). From 83e078725ae005fc19edd642c0801b87d4a5cc83 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 20:46:32 +0400 Subject: [PATCH 225/286] docs(scheduler): clarify Task.subscribe buffer semantics The previous comment said "buffer is unbounded by default" and "if the buffer is full, drop" in the same breath, which is contradictory: with ``math.inf`` the WouldBlock branch never fires. Spell out the trade-off: default buffer trades memory for completeness; a finite buffer switches to drop-on-full. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/scheduler/_scheduler.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 7ea042d..73b8928 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -69,10 +69,9 @@ def __repr__(self): return f"<{self.__class__.__name__} {self.name!r}>" def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStream[Result[R]]: - # By default, a stream is created with a buffer size of 0, which means that any write will be blocked until - # there is a free reader. We do not want to block the task execution flow in any way, so: - # - the buffer is unbounded by default - # - if the buffer is full, the result is dropped (see publish method below) + # ``_publish_result`` always uses ``send_nowait`` and never blocks the task. Defaulting to an + # unbounded buffer means a slow consumer trades memory for never missing a result; pass a finite + # ``buffer_max_size`` to switch to drop-on-full instead (see WouldBlock branch in publish). send_stream, receive_stream = MemoryStream[Result[R]].create(buffer_max_size) self._subscribers.append(self._cm.enter_context(send_stream)) return receive_stream From 7e5aacc5928854d9f70df365fd1d5c85fce6019d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 20:59:56 +0400 Subject: [PATCH 226/286] test(scheduler): cover delay() middleware timing The delay() middleware had no direct test. Verify that consecutive firings are spaced by at least the configured delay when ``every`` emits faster than ``delay`` can pass events through (delay becomes the bottleneck, dictating handler cadence). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/scheduler/scheduler.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py index fbc356c..623f3fd 100644 --- a/tests/scheduler/scheduler.py +++ b/tests/scheduler/scheduler.py @@ -1,4 +1,5 @@ import random +import time from datetime import timedelta import anyio @@ -6,7 +7,7 @@ from anyio import fail_after from localpost.hosting import serve -from localpost.scheduler import Scheduler, after, every, scheduled_task, take_first +from localpost.scheduler import Scheduler, after, delay, every, scheduled_task, take_first pytestmark = pytest.mark.anyio @@ -136,3 +137,25 @@ def _consumer(value: int) -> None: # n = 1, 3 succeed; n = 2, 4 raise. Only odd values reach ``after``. assert seen == [1, 3] + + +async def test_delay_inserts_wait_between_events(): + """``delay(D)`` sleeps ``D`` between receiving an event and forwarding it. With ``every`` emitting + fast and ``take_first(N)`` capping the run, total elapsed time must be at least ``N * D``.""" + timestamps: list[float] = [] + + # ``every(1ms)`` keeps the source emitting fast enough that ``delay`` is the bottleneck; + # the actual cadence between handler firings is the delay duration. + @scheduled_task(every(timedelta(seconds=0.001)) // delay(timedelta(seconds=0.05)) // take_first(3)) + def tick(): + timestamps.append(time.monotonic()) + + with fail_after(2): + async with serve(tick) as lt: + await lt.stopped + + assert len(timestamps) == 3 + # Each consecutive event spent ~50ms inside ``delay``; allow generous slack for CI scheduling jitter. + intervals = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)] + for interval in intervals: + assert interval >= 0.04, f"delay too short: {interval:.3f}s (expected >=0.04s)" From 4aaf1a00ad056da0cd4850e3fb99ad5785f47c96 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 23:09:18 +0400 Subject: [PATCH 227/286] chore(scheduler): drop stale buffer() sketch comment The commented-out line in ``every()`` referenced an unimplemented ``buffer(0, full_mode="drop")`` middleware via the ``>>`` operator that no longer exists. It was the only mention of ``buffer()`` in the codebase and would mislead anyone reading the file. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/scheduler/_cond.py | 1 - 1 file changed, 1 deletion(-) diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index 61213da..c9f0757 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -87,7 +87,6 @@ def every(period: timedelta | str, /) -> ScheduledTaskTemplate[None]: """ Trigger an event every `period`. """ - # return ScheduledTaskTemplate(Every(ensure_td(period))) >> buffer(0, full_mode="drop") return ScheduledTaskTemplate(Every(ensure_td(period))) From 91b1882bb562c260e2cf0f3775faed6a54c62343 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Thu, 7 May 2026 23:16:25 +0400 Subject: [PATCH 228/286] fix(scheduler): raise clear error on subscribe-after-finish + cover graceful mid-iteration shutdown Task is one-shot: once the last user has exited, the underlying ExitStack closes and ``subscribe()`` would raise a cryptic ``RuntimeError: cannot reenter context`` deep inside ``contextlib``. Track a ``_finished`` flag in ``__aexit__`` and raise an explicit error from ``subscribe()`` instead. Also add a regression test that locks in the current graceful-shutdown behavior: when ``lt.shutdown()`` fires while a handler is in flight, the handler runs to completion, no further events are dispatched, and the service stops cleanly. (Forced cancellation requires ``lt.stop()``.) Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/scheduler/_scheduler.py | 6 ++++ tests/scheduler/scheduler.py | 51 ++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 73b8928..b5a7b7b 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -64,6 +64,7 @@ def e_handler(t) -> Callable[[T], Awaitable[R]]: self._cm = ExitStack() self._subscribers: list[MemoryObjectSendStream[Result[R]]] = [] self._users = 0 + self._finished = False def __repr__(self): return f"<{self.__class__.__name__} {self.name!r}>" @@ -72,6 +73,10 @@ def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStr # ``_publish_result`` always uses ``send_nowait`` and never blocks the task. Defaulting to an # unbounded buffer means a slow consumer trades memory for never missing a result; pass a finite # ``buffer_max_size`` to switch to drop-on-full instead (see WouldBlock branch in publish). + if self._finished: + # Task is one-shot: once the last user has exited, the underlying ExitStack is closed and + # cannot be re-entered. A fresh subscriber would silently never receive anything. + raise RuntimeError(f"Cannot subscribe to {self!r}: task has already finished") send_stream, receive_stream = MemoryStream[Result[R]].create(buffer_max_size) self._subscribers.append(self._cm.enter_context(send_stream)) return receive_stream @@ -103,6 +108,7 @@ async def __aexit__(self, exc_type, exc_value, traceback) -> bool | None: # A task can be scheduled multiple times, so we need to keep the results streams open until all the scheduled # tasks are completed if self._users == 0: + self._finished = True return self._cm.__exit__(exc_type, exc_value, traceback) return False # Do not suppress exceptions diff --git a/tests/scheduler/scheduler.py b/tests/scheduler/scheduler.py index 623f3fd..c3e81b2 100644 --- a/tests/scheduler/scheduler.py +++ b/tests/scheduler/scheduler.py @@ -7,7 +7,7 @@ from anyio import fail_after from localpost.hosting import serve -from localpost.scheduler import Scheduler, after, delay, every, scheduled_task, take_first +from localpost.scheduler import Scheduler, Task, after, delay, every, scheduled_task, take_first pytestmark = pytest.mark.anyio @@ -159,3 +159,52 @@ def tick(): intervals = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)] for interval in intervals: assert interval >= 0.04, f"delay too short: {interval:.3f}s (expected >=0.04s)" + + +async def test_subscribe_after_task_finishes_raises(): + """``Task`` is one-shot: once the last user has exited, subscribers can't be added (the underlying + ExitStack is closed). The previous code raised a cryptic ``cannot reenter context`` deep in the + ExitStack; ``subscribe()`` now raises a clear ``RuntimeError`` up-front.""" + + def my_task() -> int: + return 7 + + task: Task[None, int] = Task(my_task) + # Drive the task through one full lifecycle. + async with task: + pass + + with pytest.raises(RuntimeError, match="already finished"): + task.subscribe() + + +async def test_shutdown_during_handler_lets_handler_finish(): + """When shutdown fires while a handler is in flight, the handler completes naturally, no further + events are dispatched, and the service stops cleanly. Locks in the current graceful-shutdown + behavior (forced cancellation requires ``lt.stop()``, not ``lt.shutdown()``).""" + handler_started = anyio.Event() + handler_can_finish = anyio.Event() + runs = 0 + + @scheduled_task(every(timedelta(seconds=0.02))) + async def slow(): + nonlocal runs + runs += 1 + if runs == 1: + handler_started.set() + await handler_can_finish.wait() + + with fail_after(2): + async with serve(slow) as lt: + await handler_started.wait() + lt.shutdown() + # Handler is blocked inside ``handler_can_finish.wait()``; the service must NOT be stopped + # yet — graceful shutdown waits for the in-flight handler to return. + await anyio.sleep(0.1) + assert not lt.stopped, "service stopped while handler was still running" + handler_can_finish.set() + await lt.stopped + + # Only the first event was dispatched; subsequent ``every`` ticks were dropped (consumer busy) + # and after shutdown the trigger closes without firing the handler again. + assert runs == 1, f"Expected exactly 1 handler invocation, got {runs}" From d79bae939ab76924338948422fe3b2453ed8eacc Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 00:44:25 +0400 Subject: [PATCH 229/286] refactor(threadtools): split into a package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the monolithic ``threadtools.py`` into four internal modules grouped by responsibility — sync primitives, channels, and the thread task group — behind an ``__init__.py`` that re-exports the same public API. Drop the leading underscore on module-private names inside ``_task_group`` (the module itself already signals private), and retarget the tests that poke at the idle pool to ``_task_group`` directly so ``monkeypatch.setattr`` hits the variable ``Worker._run`` actually reads. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools.py | 644 ------------------------- localpost/threadtools/__init__.py | 10 + localpost/threadtools/_base.py | 17 + localpost/threadtools/_channel.py | 287 +++++++++++ localpost/threadtools/_sync.py | 113 +++++ localpost/threadtools/_task_group.py | 223 +++++++++ tests/threadtools/thread_task_group.py | 34 +- 7 files changed, 667 insertions(+), 661 deletions(-) delete mode 100644 localpost/threadtools.py create mode 100644 localpost/threadtools/__init__.py create mode 100644 localpost/threadtools/_base.py create mode 100644 localpost/threadtools/_channel.py create mode 100644 localpost/threadtools/_sync.py create mode 100644 localpost/threadtools/_task_group.py diff --git a/localpost/threadtools.py b/localpost/threadtools.py deleted file mode 100644 index 9743896..0000000 --- a/localpost/threadtools.py +++ /dev/null @@ -1,644 +0,0 @@ -from __future__ import annotations - -import contextvars -import dataclasses as dc -import threading -import time -from collections import deque -from collections.abc import Callable, Iterator -from concurrent.futures import Future -from typing import Any, Protocol, Self, final, override - -from anyio import ( - ClosedResourceError, - EndOfStream, - WouldBlock, -) - -__all__ = [ - "Channel", - "ReceiveChannel", - "SendChannel", - "ThreadTaskGroup", - "warmup", -] - -CHECK_TIMEOUT: float = 1.0 -"""Timeout (seconds) for cancellation checks (e.g. in the server loop).""" - - -def _noop_check() -> None: - """Default cancellation probe — a no-op. - - Pass ``anyio.from_thread.check_cancelled`` (or any custom callable) to a - primitive's ``check_cancelled`` argument to opt in to cancellation. - """ - - -current_time = time.monotonic - - -### -# Synchronization primitives -### - - -@final -class CancellableLock: - """Same interface as threading.Lock, but acquire() is cancellation aware. - - The class deliberately mirrors a few CPython-private attributes from - ``threading.RLock`` (``_release_save``, ``_acquire_restore``, ``_is_owned``) - so that ``threading.Condition(lock=CancellableLock(...))`` accepts it - transparently — the stdlib's ``Condition`` does its own duck-typing on - these names. This is intentional, not a leaky abstraction. - """ - - __slots__ = ( - "__exit__", - "_acquire_restore", - "_check_cancelled", - "_is_owned", - "_release_save", - "locked", - "release", - "source", - ) - - def __init__( - self, - lock: threading.Lock | threading.RLock | None = None, - *, - check_cancelled: Callable[[], None] = _noop_check, - ) -> None: - lock = lock or threading.Lock() - self.source = lock - self.release = lock.release - self.__exit__ = lock.__exit__ - self._check_cancelled = check_cancelled - if hasattr(self.source, "locked"): - self.locked = lock.locked # type: ignore - if hasattr(lock, "_release_save"): - self._release_save = lock._release_save # type: ignore - if hasattr(lock, "_acquire_restore"): - self._acquire_restore = lock._acquire_restore # type: ignore - if hasattr(lock, "_is_owned"): - self._is_owned = lock._is_owned # type: ignore - - def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: - if not blocking: - return self.source.acquire(blocking=False) - if timeout is None or timeout < 0: - # No timeout — loop until acquired, checking for cancellation - while not self.source.acquire(timeout=CHECK_TIMEOUT): - self._check_cancelled() - return True - # Finite timeout — respect the deadline - deadline = current_time() + timeout - while (remaining := deadline - current_time()) > 0: - if self.source.acquire(timeout=min(CHECK_TIMEOUT, remaining)): - return True - self._check_cancelled() - return False - - __enter__ = acquire - - -def cancellable_condition( - lock: CancellableLock | None = None, - *, - check_cancelled: Callable[[], None] = _noop_check, -) -> threading.Condition: - # ``Condition`` accepts any duck-typed lock with the right private attrs; - # ``CancellableLock`` mirrors them (see its docstring). - # Note: ``check_cancelled`` controls the condition's ``wait`` loop only. - # The lock keeps whatever probe it was constructed with — pass a lock built - # with the same callable if you want a single coherent policy. - cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore - orig_wait = cond.wait - - def cancellable_wait(timeout: float | None = None) -> bool: - if timeout is None or timeout < 0: - while True: - check_cancelled() - if orig_wait(CHECK_TIMEOUT): - return True - - end_time = current_time() + timeout - while True: - now = current_time() - if now >= end_time: - return False - check_cancelled() - remaining = min(CHECK_TIMEOUT, max(0.0, end_time - now)) - if orig_wait(remaining): - return True - - cond.wait = cancellable_wait # type: ignore - return cond - - -def cancellable_semaphore( - value: int = 1, *, check_cancelled: Callable[[], None] = _noop_check -) -> threading.BoundedSemaphore: - # ``Semaphore`` / ``BoundedSemaphore`` use a private ``_cond`` for blocking - # waits; swap it for our cancellable variant so the semaphore's blocking - # ``acquire`` becomes cancellation-aware. - source = threading.BoundedSemaphore(value) - source._cond = cancellable_condition(check_cancelled=check_cancelled) # type: ignore - return source - - -### -# Channels -### - - -@final -class Channel[T]: - @staticmethod - def create( - capacity: int | None = None, - *, - check_cancelled: Callable[[], None] = _noop_check, - ) -> tuple[SendChannel[T], ReceiveChannel[T]]: - """Create a channel sender/receiver pair. - - Args: - capacity: Buffer size. None means unbounded, 0 means rendezvous - (put blocks until a receiver consumes the item), N>0 means bounded. - check_cancelled: Cancellation probe invoked from blocking lock / - condition waits. Defaults to a no-op; pass - ``anyio.from_thread.check_cancelled`` to make the channel - cancellation-aware from inside an anyio worker thread. - """ - with ChannelState(capacity, check_cancelled=check_cancelled) as state: - state.open_send_channels += 1 - tx = SendChannel(state) - state.open_receive_channels += 1 - rx = ReceiveChannel(state) - return tx, rx - - -class BaseReceiveChannel[T](Protocol): - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def __iter__(self) -> Iterator[T]: - while True: - try: - yield self.get() - except EndOfStream: - break - - def clone(self) -> ReceiveChannel[T]: ... - - # Raises: - # EndOfStream - if the sender has been closed cleanly, and no more objects are coming. This is not an error - # condition. - # ClosedResourceError - if you previously closed this ReceiveChannel object. - # BrokenResourceError - if something has gone wrong, and the channel is broken. - def get(self) -> T: ... - - def close(self): ... - - -class BaseSendChannel[T](Protocol): - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def clone(self) -> SendChannel[T]: ... - - # Raises: - # BrokenResourceError - if something has gone wrong, and the channel is broken. For example, you may get this if - # the receiver has already been closed. - # ClosedResourceError - if you previously closed this SendChannel object, or if another task closes it while - # put() is running. - def put(self, item: T, /) -> None: ... - - def close(self) -> None: ... - - -@final -@dc.dataclass(slots=True) -class ChannelState[T]: - buffer: deque[T] - capacity: int | None - open_send_channels: int - open_receive_channels: int - waiting_receivers: int - pending_handoffs: int - items_consumed: int - _lock: CancellableLock - not_empty: threading.Condition - not_full: threading.Condition - - def __init__(self, capacity: int | None = None, *, check_cancelled: Callable[[], None] = _noop_check): - if capacity is not None and capacity < 0: - raise ValueError("capacity must be >= 0 or None") - self.buffer = deque() - self.capacity = capacity - """ - None: unbounded - 0: rendezvous — put blocks until its own item is consumed by a receiver; put_nowait succeeds - only if an unclaimed waiting receiver is available. With N waiting receivers, up to N - puts can be in flight concurrently. - Positive int: bounded (put blocks until len(buffer) < capacity) - """ - self.open_send_channels = 0 - self.open_receive_channels = 0 - self.waiting_receivers = 0 - # Rendezvous bookkeeping (capacity=0 only). ``pending_handoffs`` is a budget counter for - # buffered items already paired with a waiting receiver; ``items_consumed`` is monotonic - # and lets a blocking ``put`` detect consumption of its own item even when the buffer - # holds other in-flight items. - self.pending_handoffs = 0 - self.items_consumed = 0 - self._lock = CancellableLock(threading.RLock(), check_cancelled=check_cancelled) - self.not_empty = cancellable_condition(self._lock, check_cancelled=check_cancelled) - self.not_full = cancellable_condition(self._lock, check_cancelled=check_cancelled) - - @property - def can_put(self) -> bool: - if self.open_receive_channels == 0: # TODO Make this state permanent - raise ClosedResourceError("no more receivers") - if self.capacity == 0: - return self.waiting_receivers > self.pending_handoffs or len(self.buffer) == 0 - return self.capacity is None or len(self.buffer) < self.capacity - - @property - def can_put_nowait(self) -> bool: - if self.open_receive_channels == 0: # TODO Make this state permanent - raise ClosedResourceError("no more receivers") - if self.capacity == 0: - return self.waiting_receivers > self.pending_handoffs - return self.capacity is None or len(self.buffer) < self.capacity - - def __enter__(self) -> Self: - self._lock.acquire() - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - if self.open_send_channels == 0 or self.open_receive_channels == 0: - self.not_empty.notify_all() - self.not_full.notify_all() - self._lock.release() - - -@final -# TODO dataclass + repr -class SendChannel[T](BaseSendChannel[T]): - def __init__(self, state: ChannelState[T]) -> None: - self._state = state - self._closed = False - - @override - def clone(self): - with self._state as state: - if self._closed: - raise ClosedResourceError("send channel is already closed") - state.open_send_channels += 1 - return SendChannel(state) - - def put_nowait(self, item: T, /) -> None: - with self._state as state: - if self._closed: - raise ClosedResourceError("send channel has been closed") - if not state.can_put_nowait: - raise WouldBlock - state.buffer.append(item) - if state.capacity == 0: - # ``can_put_nowait`` already gated on ``waiting_receivers > pending_handoffs``, - # so this is always a real claim. - state.pending_handoffs += 1 - state.not_empty.notify() - - @override - def put(self, item: T, /) -> None: - state = self._state - my_target = 0 - # Phase 1: wait for space in the buffer. - # Body of ``put_nowait`` is inlined here to avoid re-entering the lock - # and to skip the ``WouldBlock`` exception on the contended path. - # Cancellation is observed inside ``state.__enter__`` (cancellable lock - # acquire) and ``state.not_full.wait`` (cancellable condition). - while True: - with state: - if self._closed: - raise ClosedResourceError("send channel has been closed") - if state.can_put: - state.buffer.append(item) - if state.capacity == 0: - if state.waiting_receivers > state.pending_handoffs: - state.pending_handoffs += 1 - # Snapshot a target for Phase 2: consumption of *our* item bumps - # ``items_consumed`` to (at least) this value, regardless of any other - # in-flight items the buffer holds. - my_target = state.items_consumed + len(state.buffer) - state.not_empty.notify() - break - state.not_full.wait() - - # Phase 2 (rendezvous only): wait until *our* item is consumed - if state.capacity == 0: - while True: - with state: - if self._closed: - raise ClosedResourceError("send channel has been closed") - if state.items_consumed >= my_target: - return # Consumed - if state.open_receive_channels == 0: - return # No receivers left - state.not_full.wait() - - def close(self) -> None: - with self._state as state: - if not self._closed: - self._closed = True - state.open_send_channels -= 1 - state.not_full.notify() # Wake up threads waiting in put() immediately - - -@final -# TODO dataclass + repr -class ReceiveChannel[T](BaseReceiveChannel[T]): - def __init__(self, state: ChannelState[T]) -> None: - self._state = state - self._closed = False - - @override - def clone(self): - with self._state as state: - if self._closed: - raise ClosedResourceError("receive channel is already closed") - state.open_receive_channels += 1 - return ReceiveChannel(state) - - @override - def get(self) -> T: - # Cancellation is observed inside ``state.__enter__`` (cancellable lock - # acquire) and ``state.not_empty.wait`` (cancellable condition). - state = self._state - while not self._closed: - with state: - if state.buffer: - item = state.buffer.popleft() - if state.capacity == 0: - state.items_consumed += 1 - if state.pending_handoffs > 0: - state.pending_handoffs -= 1 - state.not_full.notify() - return item - if state.open_send_channels == 0: - raise EndOfStream("no more senders") - state.waiting_receivers += 1 - try: - state.not_empty.wait() - finally: - state.waiting_receivers -= 1 - raise ClosedResourceError("receive channel has been closed") - - @override - def close(self) -> None: - with self._state as state: - if not self._closed: - self._closed = True - state.open_receive_channels -= 1 - state.not_empty.notify() # Wake up threads waiting in get() immediately - - -### -# ThreadTaskGroup -### - -_IDLE_TIMEOUT: float = 60.0 -"""Seconds an idle worker waits for new work before self-exiting.""" - - -@dc.dataclass(slots=True) -class _Task: - future: Future[Any] - fn: Callable[..., Any] - args: tuple[Any, ...] - kwargs: dict[str, Any] - group: ThreadTaskGroup - context: contextvars.Context - """Snapshot of the caller's ContextVars at ``start_soon`` time. The user - callable runs inside this context; mutations are confined to the task.""" - - def run(self) -> None: - try: - try: - result = self.context.run(self.fn, *self.args, **self.kwargs) - except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything - self.future.set_exception(exc) - self.group._record_error(exc) - else: - self.future.set_result(result) - finally: - self.group._task_done() - - -@final -class _Worker: - """Idle-tracked worker thread with a per-worker inbox. - - Lifecycle: ``alive`` until the thread self-exits after ``_IDLE_TIMEOUT`` - seconds with no work, after which the worker becomes a tombstone in the - global ``_idle`` deque. The dispatcher detects tombstones via - :meth:`submit` returning ``False`` and pops the next worker / spawns a - fresh one. - """ - - __slots__ = ("_alive", "_cv", "_inbox") - - def __init__(self) -> None: - self._inbox: deque[_Task] = deque() - self._cv = threading.Condition(threading.Lock()) - # Set under ``_cv`` before lock release in ``_run``; that ordering is - # what makes ``submit`` race-free vs ``Thread.is_alive()``, which can - # still report ``True`` after ``_run`` has released the lock but - # before CPython's bootstrap marks the thread stopped. - self._alive = True - threading.Thread(target=self._run, daemon=True, name="localpost-worker").start() - - def submit(self, task: _Task) -> bool: - """Hand off a task. Returns ``False`` if the worker has self-exited.""" - with self._cv: - if not self._alive: - return False - self._inbox.append(task) - self._cv.notify() - return True - - def _run(self) -> None: - while True: - with self._cv: - # Re-check inbox after each wait — covers both spurious wakeups - # and the lost-notify race where a submit lands between the - # outer ``inbox.popleft`` and the wait re-acquiring the lock. - while not self._inbox: - if not self._cv.wait(timeout=_IDLE_TIMEOUT): - # Timed out. One more inbox check under the lock to - # close the timeout-vs-notify race. - if not self._inbox: - self._alive = False - return - break - task = self._inbox.popleft() - task.run() - _idle.append(self) - - -_idle: deque[_Worker] = deque() -"""Global LIFO stack of idle workers, shared across all ``ThreadTaskGroup``s.""" - - -def warmup(count: int, /) -> None: - """Pre-spawn ``count`` worker threads and park them in the global idle pool. - - Useful at process startup to amortise thread-creation cost so the first - bursts of work don't pay it. Pre-warmed workers are indistinguishable - from organically spawned ones — they self-exit on the same idle timeout - if unused. - - One-shot: ``warmup(8)`` always spawns 8 fresh workers, regardless of - how many idle workers already exist. Calling it again from a long-idle - process re-warms. - - Raises: - ValueError: ``count`` is negative. - """ - if count < 0: - raise ValueError("count must be >= 0") - for _ in range(count): - _idle.append(_Worker()) - - -@final -class ThreadTaskGroup: - """Trio-style task group running sync callables on a shared thread pool. - - Tasks submitted via :meth:`start_soon` run on a process-wide pool of - worker threads. Workers are spawned on demand, reused across all - ``ThreadTaskGroup`` instances, and self-exit after 60 s of idleness. - There is no concurrency cap — ``start_soon`` always succeeds (modulo - OS thread limits). - - Lifetime: sync context manager. On exit, blocks until every task - started inside the ``with`` block has finished, then re-raises any - task exceptions wrapped in an :class:`ExceptionGroup` (Trio - ``strict_exception_groups=True`` semantics — a body exception and - task exceptions are merged into one group). - - Example:: - - with ThreadTaskGroup() as tg: - fut = tg.start_soon(do_work, arg) - # ... - # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. - - ``start_soon`` is callable from any thread, including from inside a - task running on the same group (recursive spawn). It returns a - :class:`concurrent.futures.Future` that captures the task's result - or exception. Reading the future is optional — a task exception is - surfaced via the ``ExceptionGroup`` raised at ``__exit__`` even if - the future is discarded. - - ``contextvars`` are propagated: each ``start_soon`` snapshots the - caller's context with :func:`contextvars.copy_context`, and the task - runs inside that snapshot. Mutations the task makes to ContextVars - stay confined to its copy — same semantics as - :func:`asyncio.to_thread` and Trio / AnyIO task spawn. - """ - - __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending") - - def __init__(self, *, name: str | None = None) -> None: - self._name = name - self._lock = threading.Lock() - self._cv = threading.Condition(self._lock) - self._pending = 0 - # ``deque.append`` is documented thread-safe (vs ``list.append`` which - # is only atomic by CPython implementation accident). Workers append - # without holding any group-level lock; ``__exit__`` reads after - # drain, so the ``_cv`` release/acquire in ``_task_done`` provides - # the visibility barrier. - self._errors: deque[BaseException] = deque() - self._closed = False - - def __enter__(self) -> Self: - if self._closed: - raise RuntimeError("ThreadTaskGroup cannot be reused") - return self - - def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> None: - # Drain. Nested ``start_soon`` from in-flight tasks is allowed — the - # while loop re-checks after each notify. - with self._cv: - while self._pending > 0: - self._cv.wait() - self._closed = True - all_errors: list[BaseException] = [] - if exc is not None: - all_errors.append(exc) - all_errors.extend(self._errors) - if all_errors: - label = f"ThreadTaskGroup {self._name!r} failed" if self._name else "ThreadTaskGroup failed" - # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every - # member is an ``Exception`` subclass, ``BaseExceptionGroup`` otherwise - # — matches Trio semantics for ``KeyboardInterrupt`` / ``SystemExit``. - raise BaseExceptionGroup(label, all_errors) - - def start_soon[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: - """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future.""" - with self._lock: - if self._closed: - raise RuntimeError("ThreadTaskGroup is closed") - self._pending += 1 - fut: Future[R] = Future() - # Snapshot the caller's context now (matches Trio / AnyIO / asyncio - # ``to_thread`` semantics); each ``start_soon`` captures independently. - task = _Task(fut, fn, args, kwargs, self, contextvars.copy_context()) - while True: - try: - w = _idle.pop() - except IndexError: - # No idle worker — spawn a fresh one. ``submit`` on a fresh - # worker is guaranteed to succeed (``_alive=True``). - _Worker().submit(task) - return fut - if w.submit(task): - return fut - # Tombstone (self-died on idle timeout); pop the next one. - - def _task_done(self) -> None: - with self._cv: - self._pending -= 1 - if self._pending == 0: - self._cv.notify_all() - - def _record_error(self, exc: BaseException) -> None: - # No lock needed: ``list.append`` is atomic in CPython (GIL or - # free-threaded per-list mutex). Memory visibility to ``__exit__`` - # is established by ``_task_done``'s acquire of ``_cv``, which always - # runs after ``_record_error`` in the same task. - self._errors.append(exc) diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py new file mode 100644 index 0000000..ea735cb --- /dev/null +++ b/localpost/threadtools/__init__.py @@ -0,0 +1,10 @@ +from ._channel import Channel, ReceiveChannel, SendChannel +from ._task_group import ThreadTaskGroup, warmup + +__all__ = [ + "Channel", + "ReceiveChannel", + "SendChannel", + "ThreadTaskGroup", + "warmup", +] diff --git a/localpost/threadtools/_base.py b/localpost/threadtools/_base.py new file mode 100644 index 0000000..ad7a81d --- /dev/null +++ b/localpost/threadtools/_base.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import time + +CHECK_TIMEOUT: float = 1.0 +"""Timeout (seconds) for cancellation checks (e.g. in the server loop).""" + + +def _noop_check() -> None: + """Default cancellation probe — a no-op. + + Pass ``anyio.from_thread.check_cancelled`` (or any custom callable) to a + primitive's ``check_cancelled`` argument to opt in to cancellation. + """ + + +current_time = time.monotonic diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py new file mode 100644 index 0000000..3dc1570 --- /dev/null +++ b/localpost/threadtools/_channel.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import dataclasses as dc +import threading +from collections import deque +from collections.abc import Callable, Iterator +from typing import Protocol, Self, final, override + +from anyio import ( + ClosedResourceError, + EndOfStream, + WouldBlock, +) + +from ._base import _noop_check +from ._sync import CancellableLock, cancellable_condition + + +@final +class Channel[T]: + @staticmethod + def create( + capacity: int | None = None, + *, + check_cancelled: Callable[[], None] = _noop_check, + ) -> tuple[SendChannel[T], ReceiveChannel[T]]: + """Create a channel sender/receiver pair. + + Args: + capacity: Buffer size. None means unbounded, 0 means rendezvous + (put blocks until a receiver consumes the item), N>0 means bounded. + check_cancelled: Cancellation probe invoked from blocking lock / + condition waits. Defaults to a no-op; pass + ``anyio.from_thread.check_cancelled`` to make the channel + cancellation-aware from inside an anyio worker thread. + """ + with ChannelState(capacity, check_cancelled=check_cancelled) as state: + state.open_send_channels += 1 + tx = SendChannel(state) + state.open_receive_channels += 1 + rx = ReceiveChannel(state) + return tx, rx + + +class BaseReceiveChannel[T](Protocol): + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def __iter__(self) -> Iterator[T]: + while True: + try: + yield self.get() + except EndOfStream: + break + + def clone(self) -> ReceiveChannel[T]: ... + + # Raises: + # EndOfStream - if the sender has been closed cleanly, and no more objects are coming. This is not an error + # condition. + # ClosedResourceError - if you previously closed this ReceiveChannel object. + # BrokenResourceError - if something has gone wrong, and the channel is broken. + def get(self) -> T: ... + + def close(self): ... + + +class BaseSendChannel[T](Protocol): + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def clone(self) -> SendChannel[T]: ... + + # Raises: + # BrokenResourceError - if something has gone wrong, and the channel is broken. For example, you may get this if + # the receiver has already been closed. + # ClosedResourceError - if you previously closed this SendChannel object, or if another task closes it while + # put() is running. + def put(self, item: T, /) -> None: ... + + def close(self) -> None: ... + + +@final +@dc.dataclass(slots=True) +class ChannelState[T]: + buffer: deque[T] + capacity: int | None + open_send_channels: int + open_receive_channels: int + waiting_receivers: int + pending_handoffs: int + items_consumed: int + _lock: CancellableLock + not_empty: threading.Condition + not_full: threading.Condition + + def __init__(self, capacity: int | None = None, *, check_cancelled: Callable[[], None] = _noop_check): + if capacity is not None and capacity < 0: + raise ValueError("capacity must be >= 0 or None") + self.buffer = deque() + self.capacity = capacity + """ + None: unbounded + 0: rendezvous — put blocks until its own item is consumed by a receiver; put_nowait succeeds + only if an unclaimed waiting receiver is available. With N waiting receivers, up to N + puts can be in flight concurrently. + Positive int: bounded (put blocks until len(buffer) < capacity) + """ + self.open_send_channels = 0 + self.open_receive_channels = 0 + self.waiting_receivers = 0 + # Rendezvous bookkeeping (capacity=0 only). ``pending_handoffs`` is a budget counter for + # buffered items already paired with a waiting receiver; ``items_consumed`` is monotonic + # and lets a blocking ``put`` detect consumption of its own item even when the buffer + # holds other in-flight items. + self.pending_handoffs = 0 + self.items_consumed = 0 + self._lock = CancellableLock(threading.RLock(), check_cancelled=check_cancelled) + self.not_empty = cancellable_condition(self._lock, check_cancelled=check_cancelled) + self.not_full = cancellable_condition(self._lock, check_cancelled=check_cancelled) + + @property + def can_put(self) -> bool: + if self.open_receive_channels == 0: # TODO Make this state permanent + raise ClosedResourceError("no more receivers") + if self.capacity == 0: + return self.waiting_receivers > self.pending_handoffs or len(self.buffer) == 0 + return self.capacity is None or len(self.buffer) < self.capacity + + @property + def can_put_nowait(self) -> bool: + if self.open_receive_channels == 0: # TODO Make this state permanent + raise ClosedResourceError("no more receivers") + if self.capacity == 0: + return self.waiting_receivers > self.pending_handoffs + return self.capacity is None or len(self.buffer) < self.capacity + + def __enter__(self) -> Self: + self._lock.acquire() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + if self.open_send_channels == 0 or self.open_receive_channels == 0: + self.not_empty.notify_all() + self.not_full.notify_all() + self._lock.release() + + +@final +# TODO dataclass + repr +class SendChannel[T](BaseSendChannel[T]): + def __init__(self, state: ChannelState[T]) -> None: + self._state = state + self._closed = False + + @override + def clone(self): + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel is already closed") + state.open_send_channels += 1 + return SendChannel(state) + + def put_nowait(self, item: T, /) -> None: + with self._state as state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if not state.can_put_nowait: + raise WouldBlock + state.buffer.append(item) + if state.capacity == 0: + # ``can_put_nowait`` already gated on ``waiting_receivers > pending_handoffs``, + # so this is always a real claim. + state.pending_handoffs += 1 + state.not_empty.notify() + + @override + def put(self, item: T, /) -> None: + state = self._state + my_target = 0 + # Phase 1: wait for space in the buffer. + # Body of ``put_nowait`` is inlined here to avoid re-entering the lock + # and to skip the ``WouldBlock`` exception on the contended path. + # Cancellation is observed inside ``state.__enter__`` (cancellable lock + # acquire) and ``state.not_full.wait`` (cancellable condition). + while True: + with state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if state.can_put: + state.buffer.append(item) + if state.capacity == 0: + if state.waiting_receivers > state.pending_handoffs: + state.pending_handoffs += 1 + # Snapshot a target for Phase 2: consumption of *our* item bumps + # ``items_consumed`` to (at least) this value, regardless of any other + # in-flight items the buffer holds. + my_target = state.items_consumed + len(state.buffer) + state.not_empty.notify() + break + state.not_full.wait() + + # Phase 2 (rendezvous only): wait until *our* item is consumed + if state.capacity == 0: + while True: + with state: + if self._closed: + raise ClosedResourceError("send channel has been closed") + if state.items_consumed >= my_target: + return # Consumed + if state.open_receive_channels == 0: + return # No receivers left + state.not_full.wait() + + def close(self) -> None: + with self._state as state: + if not self._closed: + self._closed = True + state.open_send_channels -= 1 + state.not_full.notify() # Wake up threads waiting in put() immediately + + +@final +# TODO dataclass + repr +class ReceiveChannel[T](BaseReceiveChannel[T]): + def __init__(self, state: ChannelState[T]) -> None: + self._state = state + self._closed = False + + @override + def clone(self): + with self._state as state: + if self._closed: + raise ClosedResourceError("receive channel is already closed") + state.open_receive_channels += 1 + return ReceiveChannel(state) + + @override + def get(self) -> T: + # Cancellation is observed inside ``state.__enter__`` (cancellable lock + # acquire) and ``state.not_empty.wait`` (cancellable condition). + state = self._state + while not self._closed: + with state: + if state.buffer: + item = state.buffer.popleft() + if state.capacity == 0: + state.items_consumed += 1 + if state.pending_handoffs > 0: + state.pending_handoffs -= 1 + state.not_full.notify() + return item + if state.open_send_channels == 0: + raise EndOfStream("no more senders") + state.waiting_receivers += 1 + try: + state.not_empty.wait() + finally: + state.waiting_receivers -= 1 + raise ClosedResourceError("receive channel has been closed") + + @override + def close(self) -> None: + with self._state as state: + if not self._closed: + self._closed = True + state.open_receive_channels -= 1 + state.not_empty.notify() # Wake up threads waiting in get() immediately diff --git a/localpost/threadtools/_sync.py b/localpost/threadtools/_sync.py new file mode 100644 index 0000000..93c736f --- /dev/null +++ b/localpost/threadtools/_sync.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import final + +from ._base import CHECK_TIMEOUT, _noop_check, current_time + + +@final +class CancellableLock: + """Same interface as threading.Lock, but acquire() is cancellation aware. + + The class deliberately mirrors a few CPython-private attributes from + ``threading.RLock`` (``_release_save``, ``_acquire_restore``, ``_is_owned``) + so that ``threading.Condition(lock=CancellableLock(...))`` accepts it + transparently — the stdlib's ``Condition`` does its own duck-typing on + these names. This is intentional, not a leaky abstraction. + """ + + __slots__ = ( + "__exit__", + "_acquire_restore", + "_check_cancelled", + "_is_owned", + "_release_save", + "locked", + "release", + "source", + ) + + def __init__( + self, + lock: threading.Lock | threading.RLock | None = None, + *, + check_cancelled: Callable[[], None] = _noop_check, + ) -> None: + lock = lock or threading.Lock() + self.source = lock + self.release = lock.release + self.__exit__ = lock.__exit__ + self._check_cancelled = check_cancelled + if hasattr(self.source, "locked"): + self.locked = lock.locked # type: ignore + if hasattr(lock, "_release_save"): + self._release_save = lock._release_save # type: ignore + if hasattr(lock, "_acquire_restore"): + self._acquire_restore = lock._acquire_restore # type: ignore + if hasattr(lock, "_is_owned"): + self._is_owned = lock._is_owned # type: ignore + + def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: + if not blocking: + return self.source.acquire(blocking=False) + if timeout is None or timeout < 0: + # No timeout — loop until acquired, checking for cancellation + while not self.source.acquire(timeout=CHECK_TIMEOUT): + self._check_cancelled() + return True + # Finite timeout — respect the deadline + deadline = current_time() + timeout + while (remaining := deadline - current_time()) > 0: + if self.source.acquire(timeout=min(CHECK_TIMEOUT, remaining)): + return True + self._check_cancelled() + return False + + __enter__ = acquire + + +def cancellable_condition( + lock: CancellableLock | None = None, + *, + check_cancelled: Callable[[], None] = _noop_check, +) -> threading.Condition: + # ``Condition`` accepts any duck-typed lock with the right private attrs; + # ``CancellableLock`` mirrors them (see its docstring). + # Note: ``check_cancelled`` controls the condition's ``wait`` loop only. + # The lock keeps whatever probe it was constructed with — pass a lock built + # with the same callable if you want a single coherent policy. + cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore + orig_wait = cond.wait + + def cancellable_wait(timeout: float | None = None) -> bool: + if timeout is None or timeout < 0: + while True: + check_cancelled() + if orig_wait(CHECK_TIMEOUT): + return True + + end_time = current_time() + timeout + while True: + now = current_time() + if now >= end_time: + return False + check_cancelled() + remaining = min(CHECK_TIMEOUT, max(0.0, end_time - now)) + if orig_wait(remaining): + return True + + cond.wait = cancellable_wait # type: ignore + return cond + + +def cancellable_semaphore( + value: int = 1, *, check_cancelled: Callable[[], None] = _noop_check +) -> threading.BoundedSemaphore: + # ``Semaphore`` / ``BoundedSemaphore`` use a private ``_cond`` for blocking + # waits; swap it for our cancellable variant so the semaphore's blocking + # ``acquire`` becomes cancellation-aware. + source = threading.BoundedSemaphore(value) + source._cond = cancellable_condition(check_cancelled=check_cancelled) # type: ignore + return source diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py new file mode 100644 index 0000000..6ac21f2 --- /dev/null +++ b/localpost/threadtools/_task_group.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import contextvars +import dataclasses as dc +import threading +from collections import deque +from collections.abc import Callable +from concurrent.futures import Future +from typing import Any, Self, final + +IDLE_TIMEOUT: float = 60.0 +"""Seconds an idle worker waits for new work before self-exiting.""" + + +@dc.dataclass(slots=True) +class Task: + future: Future[Any] + fn: Callable[..., Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + group: ThreadTaskGroup + context: contextvars.Context + """Snapshot of the caller's ContextVars at ``start_soon`` time. The user + callable runs inside this context; mutations are confined to the task.""" + + def run(self) -> None: + try: + try: + result = self.context.run(self.fn, *self.args, **self.kwargs) + except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything + self.future.set_exception(exc) + self.group._record_error(exc) + else: + self.future.set_result(result) + finally: + self.group._task_done() + + +@final +class Worker: + """Idle-tracked worker thread with a per-worker inbox. + + Lifecycle: ``alive`` until the thread self-exits after ``IDLE_TIMEOUT`` + seconds with no work, after which the worker becomes a tombstone in the + global ``idle`` deque. The dispatcher detects tombstones via + :meth:`submit` returning ``False`` and pops the next worker / spawns a + fresh one. + """ + + __slots__ = ("_alive", "_cv", "_inbox") + + def __init__(self) -> None: + self._inbox: deque[Task] = deque() + self._cv = threading.Condition(threading.Lock()) + # Set under ``_cv`` before lock release in ``_run``; that ordering is + # what makes ``submit`` race-free vs ``Thread.is_alive()``, which can + # still report ``True`` after ``_run`` has released the lock but + # before CPython's bootstrap marks the thread stopped. + self._alive = True + threading.Thread(target=self._run, daemon=True, name="localpost-worker").start() + + def submit(self, task: Task) -> bool: + """Hand off a task. Returns ``False`` if the worker has self-exited.""" + with self._cv: + if not self._alive: + return False + self._inbox.append(task) + self._cv.notify() + return True + + def _run(self) -> None: + while True: + with self._cv: + # Re-check inbox after each wait — covers both spurious wakeups + # and the lost-notify race where a submit lands between the + # outer ``inbox.popleft`` and the wait re-acquiring the lock. + while not self._inbox: + if not self._cv.wait(timeout=IDLE_TIMEOUT): + # Timed out. One more inbox check under the lock to + # close the timeout-vs-notify race. + if not self._inbox: + self._alive = False + return + break + task = self._inbox.popleft() + task.run() + idle.append(self) + + +idle: deque[Worker] = deque() +"""Global LIFO stack of idle workers, shared across all ``ThreadTaskGroup``s.""" + + +def warmup(count: int, /) -> None: + """Pre-spawn ``count`` worker threads and park them in the global idle pool. + + Useful at process startup to amortise thread-creation cost so the first + bursts of work don't pay it. Pre-warmed workers are indistinguishable + from organically spawned ones — they self-exit on the same idle timeout + if unused. + + One-shot: ``warmup(8)`` always spawns 8 fresh workers, regardless of + how many idle workers already exist. Calling it again from a long-idle + process re-warms. + + Raises: + ValueError: ``count`` is negative. + """ + if count < 0: + raise ValueError("count must be >= 0") + for _ in range(count): + idle.append(Worker()) + + +@final +class ThreadTaskGroup: + """Trio-style task group running sync callables on a shared thread pool. + + Tasks submitted via :meth:`start_soon` run on a process-wide pool of + worker threads. Workers are spawned on demand, reused across all + ``ThreadTaskGroup`` instances, and self-exit after 60 s of idleness. + There is no concurrency cap — ``start_soon`` always succeeds (modulo + OS thread limits). + + Lifetime: sync context manager. On exit, blocks until every task + started inside the ``with`` block has finished, then re-raises any + task exceptions wrapped in an :class:`ExceptionGroup` (Trio + ``strict_exception_groups=True`` semantics — a body exception and + task exceptions are merged into one group). + + Example:: + + with ThreadTaskGroup() as tg: + fut = tg.start_soon(do_work, arg) + # ... + # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. + + ``start_soon`` is callable from any thread, including from inside a + task running on the same group (recursive spawn). It returns a + :class:`concurrent.futures.Future` that captures the task's result + or exception. Reading the future is optional — a task exception is + surfaced via the ``ExceptionGroup`` raised at ``__exit__`` even if + the future is discarded. + + ``contextvars`` are propagated: each ``start_soon`` snapshots the + caller's context with :func:`contextvars.copy_context`, and the task + runs inside that snapshot. Mutations the task makes to ContextVars + stay confined to its copy — same semantics as + :func:`asyncio.to_thread` and Trio / AnyIO task spawn. + """ + + __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending") + + def __init__(self, *, name: str | None = None) -> None: + self._name = name + self._lock = threading.Lock() + self._cv = threading.Condition(self._lock) + self._pending = 0 + # ``deque.append`` is documented thread-safe (vs ``list.append`` which + # is only atomic by CPython implementation accident). Workers append + # without holding any group-level lock; ``__exit__`` reads after + # drain, so the ``_cv`` release/acquire in ``_task_done`` provides + # the visibility barrier. + self._errors: deque[BaseException] = deque() + self._closed = False + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("ThreadTaskGroup cannot be reused") + return self + + def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> None: + # Drain. Nested ``start_soon`` from in-flight tasks is allowed — the + # while loop re-checks after each notify. + with self._cv: + while self._pending > 0: + self._cv.wait() + self._closed = True + all_errors: list[BaseException] = [] + if exc is not None: + all_errors.append(exc) + all_errors.extend(self._errors) + if all_errors: + label = f"ThreadTaskGroup {self._name!r} failed" if self._name else "ThreadTaskGroup failed" + # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every + # member is an ``Exception`` subclass, ``BaseExceptionGroup`` otherwise + # — matches Trio semantics for ``KeyboardInterrupt`` / ``SystemExit``. + raise BaseExceptionGroup(label, all_errors) + + def start_soon[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: + """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future.""" + with self._lock: + if self._closed: + raise RuntimeError("ThreadTaskGroup is closed") + self._pending += 1 + fut: Future[R] = Future() + # Snapshot the caller's context now (matches Trio / AnyIO / asyncio + # ``to_thread`` semantics); each ``start_soon`` captures independently. + task = Task(fut, fn, args, kwargs, self, contextvars.copy_context()) + while True: + try: + w = idle.pop() + except IndexError: + # No idle worker — spawn a fresh one. ``submit`` on a fresh + # worker is guaranteed to succeed (``_alive=True``). + Worker().submit(task) + return fut + if w.submit(task): + return fut + # Tombstone (self-died on idle timeout); pop the next one. + + def _task_done(self) -> None: + with self._cv: + self._pending -= 1 + if self._pending == 0: + self._cv.notify_all() + + def _record_error(self, exc: BaseException) -> None: + # No lock needed: ``list.append`` is atomic in CPython (GIL or + # free-threaded per-list mutex). Memory visibility to ``__exit__`` + # is established by ``_task_done``'s acquire of ``_cv``, which always + # runs after ``_record_error`` in the same task. + self._errors.append(exc) diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 5bc3ad9..1efc10e 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -7,26 +7,26 @@ import pytest -from localpost import threadtools from localpost.threadtools import ThreadTaskGroup, warmup +from localpost.threadtools import _task_group as _tg @pytest.fixture def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch): """Make idle-timeout tests fast: 100 ms instead of 60 s.""" - monkeypatch.setattr(threadtools, "_IDLE_TIMEOUT", 0.1) + monkeypatch.setattr(_tg, "IDLE_TIMEOUT", 0.1) return 0.1 def _drain_idle_workers() -> None: """Wait for the global idle deque to empty. - Tests that monkeypatch ``_IDLE_TIMEOUT`` to a small value rely on this + Tests that monkeypatch ``IDLE_TIMEOUT`` to a small value rely on this so they don't leak workers (or interact with each other through the - shared ``_idle`` deque). + shared ``idle`` deque). """ deadline = time.monotonic() + 2.0 - while threadtools._idle and time.monotonic() < deadline: + while _tg.idle and time.monotonic() < deadline: time.sleep(0.05) @@ -209,7 +209,7 @@ def record(): first_run = set(seen) # Second group sees at least some of the same threads (workers parked - # in the global _idle deque). + # in the global idle deque). seen.clear() with ThreadTaskGroup() as tg: for _ in range(4): @@ -224,13 +224,13 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): # Drop any leftover workers from earlier tests — they were spawned before # the monkeypatch and are stuck on their original 60 s ``inbox.get``. # Orphaning them is safe (daemon threads, won't be reused). - threadtools._idle.clear() + _tg.idle.clear() # Spawn a fresh worker and grab a reference to it. with ThreadTaskGroup() as tg: tg.start_soon(lambda: None).result(timeout=5) - assert len(threadtools._idle) >= 1 - fresh = threadtools._idle[-1] # LIFO: most recently parked + assert len(_tg.idle) >= 1 + fresh = _tg.idle[-1] # LIFO: most recently parked # Wait past the idle timeout. time.sleep(fast_idle_timeout * 10) @@ -307,11 +307,11 @@ def _record(i: int, sink: list[int], lock: threading.Lock) -> None: def test_warmup_adds_workers_to_idle_pool(): """``warmup(N)`` spawns N workers and puts them in the global idle deque.""" - threadtools._idle.clear() + _tg.idle.clear() warmup(4) - assert len(threadtools._idle) == 4 + assert len(_tg.idle) == 4 # Each entry is a distinct, alive worker. - workers = list(threadtools._idle) + workers = list(_tg.idle) assert all(w._alive for w in workers) assert len({id(w) for w in workers}) == 4 @@ -319,9 +319,9 @@ def test_warmup_adds_workers_to_idle_pool(): def test_warmup_workers_are_used_by_dispatch(): """A subsequent ``start_soon`` reuses a pre-warmed worker rather than spawning a fresh one.""" - threadtools._idle.clear() + _tg.idle.clear() warmup(2) - pre = {id(w) for w in threadtools._idle} + pre = {id(w) for w in _tg.idle} seen: set[int] = set() seen_lock = threading.Lock() @@ -334,14 +334,14 @@ def record_self() -> None: tg.start_soon(record_self).result(timeout=5) # The worker that ran is one of the pre-warmed set. - post = {id(w) for w in threadtools._idle} + post = {id(w) for w in _tg.idle} assert pre & post # at least one of the warmed workers is still parked def test_warmup_zero_is_noop(): - threadtools._idle.clear() + _tg.idle.clear() warmup(0) - assert len(threadtools._idle) == 0 + assert len(_tg.idle) == 0 def test_warmup_negative_raises(): From 15b547f3fac73cb9377b090f03eab740ed14429a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 01:00:43 +0400 Subject: [PATCH 230/286] feat(threadtools): rename ThreadTaskGroup to TaskGroup + portal-backed async submit - Rename ``ThreadTaskGroup`` to ``TaskGroup`` so it reads naturally with the package prefix (``threadtools.TaskGroup`` alongside ``Channel``, ``warmup``, etc.). Update the http worker pool, design doc, and tests. - Add an optional ``aio_portal: BlockingPortal`` to ``TaskGroup``. ``start_soon`` now accepts async callables: when the worker invokes one, the resulting coroutine is dispatched to the portal's event loop via ``portal.call``. The worker blocks on the call, preserving the uniform "one task = one worker for its lifetime" model. - ``start_soon`` gets a typed overload pair so both sync and async callables resolve to ``Future[R]``. Submitting a coroutine function without a portal raises ``RuntimeError`` at ``start_soon``; sync callables that return a coroutine raise the same error from the worker, surfaced through the future / exception group. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/threading-topologies.md | 2 +- localpost/http/_pool.py | 12 +-- localpost/threadtools/__init__.py | 4 +- localpost/threadtools/_task_group.py | 84 +++++++++++++---- tests/threadtools/thread_task_group.py | 125 +++++++++++++++++++------ 5 files changed, 174 insertions(+), 53 deletions(-) diff --git a/docs/design/threading-topologies.md b/docs/design/threading-topologies.md index ae107ed..952410f 100644 --- a/docs/design/threading-topologies.md +++ b/docs/design/threading-topologies.md @@ -58,7 +58,7 @@ What this gives you: user's composition problem (today there is no per-route pool API). - **No concurrency cap on `http_server` or the pool.** The pool - dispatches every request onto a process-wide `ThreadTaskGroup`; + dispatches every request onto a process-wide `TaskGroup`; workers are spawned on demand and reused across all `thread_pool_handler` instances in the process. There is no admission gate and no 503-on-overflow — backpressure is the diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 5ea6886..23b6370 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -6,7 +6,7 @@ syscalls like ``ctx.sendfile`` block the worker, not the selector. Workers come from a process-wide -:class:`localpost.threadtools.ThreadTaskGroup` and are reused across +:class:`localpost.threadtools.TaskGroup` and are reused across all pool wrappers / HTTP servers in the process. There is no concurrency cap — admission control is the deployment's job (front-LB / OS limits). @@ -41,12 +41,12 @@ class _Pool: """Dispatcher that runs request handlers on a shared - :class:`ThreadTaskGroup`. + :class:`TaskGroup`. """ __slots__ = ("_shutdown_event", "_tg") - def __init__(self, tg: threadtools.ThreadTaskGroup, shutdown_event: threading.Event) -> None: + def __init__(self, tg: threadtools.TaskGroup, shutdown_event: threading.Event) -> None: self._tg = tg self._shutdown_event = shutdown_event @@ -126,7 +126,7 @@ def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: RequestHandler) @asynccontextmanager async def _pool_context() -> AsyncGenerator[_Pool]: - """Open a worker pool backed by a :class:`ThreadTaskGroup`. + """Open a worker pool backed by a :class:`TaskGroup`. The task group is process-wide in spirit (workers are shared across all pools), but each ``_pool_context`` owns its own group so its @@ -137,7 +137,7 @@ async def _pool_context() -> AsyncGenerator[_Pool]: surrounding event loop stays responsive. """ shutdown_event = threading.Event() - tg = threadtools.ThreadTaskGroup(name="http-pool") + tg = threadtools.TaskGroup(name="http-pool") tg.__enter__() try: yield _Pool(tg, shutdown_event) @@ -169,7 +169,7 @@ async def thread_pool_handler(inner: RequestHandler, /) -> AsyncGenerator[Reques ``inner`` runs on a worker on a blocking-with-timeout socket — body reads (``ctx.receive(...)`` / :func:`localpost.http.read_body`) and other blocking syscalls don't stall the selector. Workers come from - a process-wide :class:`localpost.threadtools.ThreadTaskGroup` and + a process-wide :class:`localpost.threadtools.TaskGroup` and are reused across all pool wrappers; there is no concurrency cap. Per-request cancellation surfaces through diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index ea735cb..c5c8a82 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,10 +1,10 @@ from ._channel import Channel, ReceiveChannel, SendChannel -from ._task_group import ThreadTaskGroup, warmup +from ._task_group import TaskGroup, warmup __all__ = [ "Channel", "ReceiveChannel", "SendChannel", - "ThreadTaskGroup", + "TaskGroup", "warmup", ] diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index 6ac21f2..f235b0c 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -2,11 +2,15 @@ import contextvars import dataclasses as dc +import inspect import threading from collections import deque -from collections.abc import Callable +from collections.abc import Awaitable, Callable from concurrent.futures import Future -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final, overload + +if TYPE_CHECKING: + from anyio.from_thread import BlockingPortal IDLE_TIMEOUT: float = 60.0 """Seconds an idle worker waits for new work before self-exiting.""" @@ -18,7 +22,7 @@ class Task: fn: Callable[..., Any] args: tuple[Any, ...] kwargs: dict[str, Any] - group: ThreadTaskGroup + group: TaskGroup context: contextvars.Context """Snapshot of the caller's ContextVars at ``start_soon`` time. The user callable runs inside this context; mutations are confined to the task.""" @@ -27,6 +31,19 @@ def run(self) -> None: try: try: result = self.context.run(self.fn, *self.args, **self.kwargs) + if inspect.iscoroutine(result): + # The user submitted an async callable; dispatch the + # coroutine to the event loop via the portal. The worker + # thread blocks on ``portal.call`` until the coroutine + # completes — same lifetime model as the sync path. + portal = self.group._aio_portal + if portal is None: + result.close() + raise RuntimeError( # noqa: TRY301 + "TaskGroup received a coroutine but was not given an aio_portal" + ) + coro = result + result = portal.call(lambda: coro) except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything self.future.set_exception(exc) self.group._record_error(exc) @@ -88,7 +105,7 @@ def _run(self) -> None: idle: deque[Worker] = deque() -"""Global LIFO stack of idle workers, shared across all ``ThreadTaskGroup``s.""" +"""Global LIFO stack of idle workers, shared across all ``TaskGroup``s.""" def warmup(count: int, /) -> None: @@ -113,14 +130,14 @@ def warmup(count: int, /) -> None: @final -class ThreadTaskGroup: +class TaskGroup: """Trio-style task group running sync callables on a shared thread pool. Tasks submitted via :meth:`start_soon` run on a process-wide pool of worker threads. Workers are spawned on demand, reused across all - ``ThreadTaskGroup`` instances, and self-exit after 60 s of idleness. - There is no concurrency cap — ``start_soon`` always succeeds (modulo - OS thread limits). + ``TaskGroup`` instances, and self-exit after 60 s of idleness. There + is no concurrency cap — ``start_soon`` always succeeds (modulo OS + thread limits). Lifetime: sync context manager. On exit, blocks until every task started inside the ``with`` block has finished, then re-raises any @@ -130,7 +147,7 @@ class ThreadTaskGroup: Example:: - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: fut = tg.start_soon(do_work, arg) # ... # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. @@ -147,12 +164,24 @@ class ThreadTaskGroup: runs inside that snapshot. Mutations the task makes to ContextVars stay confined to its copy — same semantics as :func:`asyncio.to_thread` and Trio / AnyIO task spawn. + + If ``aio_portal`` is provided, ``start_soon`` also accepts async + callables: the coroutine is dispatched to the portal's event loop + via :meth:`anyio.from_thread.BlockingPortal.call`, run from inside + the worker thread (the worker blocks until the coroutine returns). + Submitting an async callable without a portal raises ``RuntimeError``. """ - __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending") + __slots__ = ("_aio_portal", "_closed", "_cv", "_errors", "_lock", "_name", "_pending") - def __init__(self, *, name: str | None = None) -> None: + def __init__( + self, + *, + name: str | None = None, + aio_portal: BlockingPortal | None = None, + ) -> None: self._name = name + self._aio_portal = aio_portal self._lock = threading.Lock() self._cv = threading.Condition(self._lock) self._pending = 0 @@ -166,7 +195,7 @@ def __init__(self, *, name: str | None = None) -> None: def __enter__(self) -> Self: if self._closed: - raise RuntimeError("ThreadTaskGroup cannot be reused") + raise RuntimeError("TaskGroup cannot be reused") return self def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> None: @@ -181,19 +210,38 @@ def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> N all_errors.append(exc) all_errors.extend(self._errors) if all_errors: - label = f"ThreadTaskGroup {self._name!r} failed" if self._name else "ThreadTaskGroup failed" + label = f"TaskGroup {self._name!r} failed" if self._name else "TaskGroup failed" # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every # member is an ``Exception`` subclass, ``BaseExceptionGroup`` otherwise # — matches Trio semantics for ``KeyboardInterrupt`` / ``SystemExit``. raise BaseExceptionGroup(label, all_errors) - def start_soon[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: - """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future.""" + @overload + def start_soon[**P, R]( + self, fn: Callable[P, Awaitable[R]], /, *args: P.args, **kwargs: P.kwargs + ) -> Future[R]: ... + @overload + def start_soon[**P, R]( + self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs + ) -> Future[R]: ... + def start_soon( + self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any + ) -> Future[Any]: + """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future. + + ``fn`` may be sync or async. Async callables require ``aio_portal``; + the worker invokes ``portal.call`` to await the coroutine on the + event loop. + """ + if inspect.iscoroutinefunction(fn) and self._aio_portal is None: + raise RuntimeError( + "TaskGroup.start_soon got an async callable but the group has no aio_portal" + ) with self._lock: if self._closed: - raise RuntimeError("ThreadTaskGroup is closed") + raise RuntimeError("TaskGroup is closed") self._pending += 1 - fut: Future[R] = Future() + fut: Future[Any] = Future() # Snapshot the caller's context now (matches Trio / AnyIO / asyncio # ``to_thread`` semantics); each ``start_soon`` captures independently. task = Task(fut, fn, args, kwargs, self, contextvars.copy_context()) @@ -221,3 +269,5 @@ def _record_error(self, exc: BaseException) -> None: # is established by ``_task_done``'s acquire of ``_cv``, which always # runs after ``_record_error`` in the same task. self._errors.append(exc) + + diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 1efc10e..1739515 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -1,4 +1,4 @@ -"""Tests for ``localpost.threadtools.ThreadTaskGroup``.""" +"""Tests for ``localpost.threadtools.TaskGroup``.""" import contextvars import threading @@ -7,7 +7,7 @@ import pytest -from localpost.threadtools import ThreadTaskGroup, warmup +from localpost.threadtools import TaskGroup, warmup from localpost.threadtools import _task_group as _tg @@ -36,14 +36,14 @@ def _drain_idle_workers() -> None: def test_start_soon_returns_future_with_result(): - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: fut = tg.start_soon(lambda x: x * 2, 21) assert isinstance(fut, Future) assert fut.result(timeout=5) == 42 def test_start_soon_with_kwargs(): - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: fut = tg.start_soon(lambda *, a, b: a + b, a=1, b=2) assert fut.result(timeout=5) == 3 @@ -55,7 +55,7 @@ def work(i: int) -> int: time.sleep(0.01) return i * i - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: futs = [tg.start_soon(work, i) for i in range(n)] assert sorted(f.result() for f in futs) == [i * i for i in range(n)] @@ -74,7 +74,7 @@ def bad(): raise Boom("nope") with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: fut = tg.start_soon(bad) # Future records the exception too with pytest.raises(Boom): @@ -88,7 +88,7 @@ class BodyBoom(Exception): pass with pytest.raises(ExceptionGroup) as ei: - with ThreadTaskGroup(): + with TaskGroup(): raise BodyBoom("body") assert len(ei.value.exceptions) == 1 assert isinstance(ei.value.exceptions[0], BodyBoom) @@ -105,7 +105,7 @@ def bad(): raise Task("task") with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: tg.start_soon(bad) time.sleep(0.05) # let the task land raise Body("body") @@ -117,8 +117,8 @@ def test_named_group_uses_name_in_exception_message(): def bad(): raise RuntimeError("x") - with pytest.raises(ExceptionGroup, match="ThreadTaskGroup 'pool-x' failed"): - with ThreadTaskGroup(name="pool-x") as tg: + with pytest.raises(ExceptionGroup, match="TaskGroup 'pool-x' failed"): + with TaskGroup(name="pool-x") as tg: tg.start_soon(bad) @@ -137,7 +137,7 @@ def slow(): release.wait(timeout=5) finished.set() - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: tg.start_soon(slow) assert started.wait(timeout=2) assert not finished.is_set() @@ -156,7 +156,7 @@ def leaf(): with counter_lock: counter += 1 - def branch(tg: ThreadTaskGroup, depth: int): + def branch(tg: TaskGroup, depth: int): if depth == 0: leaf() return @@ -167,7 +167,7 @@ def branch(tg: ThreadTaskGroup, depth: int): f2.result(timeout=5) leaf() - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: tg.start_soon(branch, tg, 3) # depth=3 → 1 + 2 + 4 + 8 = 15 leaves @@ -175,7 +175,7 @@ def branch(tg: ThreadTaskGroup, depth: int): def test_start_soon_after_close_raises(): - tg = ThreadTaskGroup() + tg = TaskGroup() with tg: tg.start_soon(lambda: None).result(timeout=5) with pytest.raises(RuntimeError, match="closed"): @@ -183,7 +183,7 @@ def test_start_soon_after_close_raises(): def test_group_cannot_be_reused(): - tg = ThreadTaskGroup() + tg = TaskGroup() with tg: pass with pytest.raises(RuntimeError, match="cannot be reused"): @@ -203,7 +203,7 @@ def test_workers_are_shared_across_groups(fast_idle_timeout): def record(): seen.add(threading.get_ident()) - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: for _ in range(4): tg.start_soon(record).result(timeout=5) first_run = set(seen) @@ -211,7 +211,7 @@ def record(): # Second group sees at least some of the same threads (workers parked # in the global idle deque). seen.clear() - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: for _ in range(4): tg.start_soon(record).result(timeout=5) second_run = set(seen) @@ -227,7 +227,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): _tg.idle.clear() # Spawn a fresh worker and grab a reference to it. - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: tg.start_soon(lambda: None).result(timeout=5) assert len(_tg.idle) >= 1 fresh = _tg.idle[-1] # LIFO: most recently parked @@ -237,7 +237,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): assert fresh._alive is False # Submitting again skips the dead tombstone and spawns fresh. - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: tg.start_soon(lambda: None).result(timeout=5) _drain_idle_workers() @@ -265,7 +265,7 @@ def tick(): counter += 1 for _ in range(5): - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: futs = [tg.start_soon(tick) for _ in range(n)] for f in futs: f.result(timeout=5) @@ -281,11 +281,11 @@ def test_start_soon_from_arbitrary_thread(): results: list[int] = [] results_lock = threading.Lock() - def producer(tg: ThreadTaskGroup): + def producer(tg: TaskGroup): for i in range(10): tg.start_soon(_record, i, results, results_lock) - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: threads = [threading.Thread(target=producer, args=(tg,)) for _ in range(5)] for t in threads: t.start() @@ -330,7 +330,7 @@ def record_self() -> None: with seen_lock: seen.add(threading.get_ident()) - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: tg.start_soon(record_self).result(timeout=5) # The worker that ran is one of the pre-warmed set. @@ -359,7 +359,7 @@ def test_task_sees_caller_context(): var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var") var.set("caller-value") - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: fut = tg.start_soon(var.get) assert fut.result(timeout=5) == "caller-value" @@ -373,7 +373,7 @@ def mutate(): var.set("task-mutated") return var.get() - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: fut = tg.start_soon(mutate) assert fut.result(timeout=5) == "task-mutated" @@ -385,7 +385,7 @@ def test_each_start_soon_captures_independently(): the ContextVar between them — capture is per-call, not per-group.""" var: contextvars.ContextVar[int] = contextvars.ContextVar("test_var", default=0) - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: var.set(1) f1 = tg.start_soon(var.get) var.set(2) @@ -408,10 +408,81 @@ def observe() -> int: barrier.wait(timeout=5) return var.get() - with ThreadTaskGroup() as tg: + with TaskGroup() as tg: futs: list[Future[int]] = [] for i in range(10): var.set(i) futs.append(tg.start_soon(observe)) assert sorted(f.result(timeout=5) for f in futs) == list(range(10)) + + +# --------------------------------------------------------------------------- +# Async callables via aio_portal +# --------------------------------------------------------------------------- + + +async def test_async_callable_runs_via_portal(): + """An async callable submitted to ``start_soon`` is awaited on the + portal's event loop; the future resolves with the awaited result.""" + from anyio.from_thread import start_blocking_portal + + async def add(a: int, b: int) -> int: + return a + b + + with start_blocking_portal() as portal: + with TaskGroup(aio_portal=portal) as tg: + fut = tg.start_soon(add, 2, 3) + + assert fut.result(timeout=5) == 5 + + +async def test_async_callable_exception_flows_through_future(): + """Exceptions raised inside an async callable surface on the future + (and into the TaskGroup's exception group).""" + from anyio.from_thread import start_blocking_portal + + async def boom(): + raise ValueError("boom") + + with start_blocking_portal() as portal: + tg = TaskGroup(aio_portal=portal) + with pytest.raises(ExceptionGroup) as ei: + with tg: + fut = tg.start_soon(boom) + + with pytest.raises(ValueError, match="boom"): + fut.result(timeout=5) + assert any(isinstance(e, ValueError) for e in ei.value.exceptions) + + +def test_async_callable_without_portal_raises(): + """Submitting a coroutine function to a TaskGroup without an + ``aio_portal`` raises immediately at ``start_soon``.""" + + async def whatever(): + return None + + with TaskGroup() as tg: + with pytest.raises(RuntimeError, match="aio_portal"): + tg.start_soon(whatever) + + +def test_sync_callable_returning_coroutine_without_portal_raises(): + """Defence in depth: a sync callable that returns a coroutine still + needs a portal — detected at run time, surfaced through the future.""" + + async def inner(): + return 1 + + def returns_coro(): + return inner() + + with pytest.raises(ExceptionGroup) as ei: + with TaskGroup() as tg: + tg.start_soon(returns_coro) + + assert any( + isinstance(e, RuntimeError) and "aio_portal" in str(e) + for e in ei.value.exceptions + ) From 654644b48bf75363df03064f791a9b87080a4142 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 01:22:09 +0400 Subject: [PATCH 231/286] feat(threadtools): split TaskGroup spawn into start_soon (None) + create_task (Future) start_soon is now fire-and-forget; create_task returns the observation Future. Aligns with asyncio.TaskGroup and the AnyIO direction in agronholm/anyio#1098. start_soon is a one-line wrapper over create_task, so the two share one implementation. Also swap the eager async-callable check from inspect.iscoroutinefunction to localpost._utils.is_async_callable, which unwraps functools.partial and detects callable instances with async __call__. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools/_task_group.py | 71 ++++++++++++++-------- tests/threadtools/thread_task_group.py | 82 +++++++++++++++++--------- 2 files changed, 101 insertions(+), 52 deletions(-) diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index f235b0c..45e27dc 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -9,6 +9,8 @@ from concurrent.futures import Future from typing import TYPE_CHECKING, Any, Self, final, overload +from .._utils import is_async_callable + if TYPE_CHECKING: from anyio.from_thread import BlockingPortal @@ -148,24 +150,25 @@ class TaskGroup: Example:: with TaskGroup() as tg: - fut = tg.start_soon(do_work, arg) - # ... + tg.start_soon(do_work, arg) # fire-and-forget + fut = tg.create_task(other_work) # observe via Future # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. - ``start_soon`` is callable from any thread, including from inside a - task running on the same group (recursive spawn). It returns a - :class:`concurrent.futures.Future` that captures the task's result - or exception. Reading the future is optional — a task exception is - surfaced via the ``ExceptionGroup`` raised at ``__exit__`` even if - the future is discarded. - - ``contextvars`` are propagated: each ``start_soon`` snapshots the - caller's context with :func:`contextvars.copy_context`, and the task - runs inside that snapshot. Mutations the task makes to ContextVars - stay confined to its copy — same semantics as - :func:`asyncio.to_thread` and Trio / AnyIO task spawn. - - If ``aio_portal`` is provided, ``start_soon`` also accepts async + Both :meth:`start_soon` and :meth:`create_task` are callable from any + thread, including from inside a task running on the same group + (recursive spawn). ``start_soon`` is fire-and-forget; ``create_task`` + returns a :class:`concurrent.futures.Future` that captures the task's + result or exception. Either way, task exceptions are surfaced via the + ``ExceptionGroup`` raised at ``__exit__`` — for ``create_task``, + reading the future's exception does not suppress that group raise. + + ``contextvars`` are propagated: each spawn snapshots the caller's + context with :func:`contextvars.copy_context`, and the task runs + inside that snapshot. Mutations the task makes to ContextVars stay + confined to its copy — same semantics as :func:`asyncio.to_thread` + and Trio / AnyIO task spawn. + + If ``aio_portal`` is provided, both spawn methods also accept async callables: the coroutine is dispatched to the portal's event loop via :meth:`anyio.from_thread.BlockingPortal.call`, run from inside the worker thread (the worker blocks until the coroutine returns). @@ -217,14 +220,31 @@ def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> N raise BaseExceptionGroup(label, all_errors) @overload - def start_soon[**P, R]( + def start_soon[**P]( + self, fn: Callable[P, Awaitable[Any]], /, *args: P.args, **kwargs: P.kwargs + ) -> None: ... + @overload + def start_soon[**P]( + self, fn: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs + ) -> None: ... + def start_soon(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None: + """Submit ``fn(*args, **kwargs)`` to a worker thread. Fire-and-forget. + + Errors still surface via the ``ExceptionGroup`` raised at + ``__exit__``. Use :meth:`create_task` if you need to observe the + task's result or exception via a :class:`concurrent.futures.Future`. + """ + self.create_task(fn, *args, **kwargs) + + @overload + def create_task[**P, R]( self, fn: Callable[P, Awaitable[R]], /, *args: P.args, **kwargs: P.kwargs ) -> Future[R]: ... @overload - def start_soon[**P, R]( + def create_task[**P, R]( self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs ) -> Future[R]: ... - def start_soon( + def create_task( self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any ) -> Future[Any]: """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future. @@ -232,18 +252,21 @@ def start_soon( ``fn`` may be sync or async. Async callables require ``aio_portal``; the worker invokes ``portal.call`` to await the coroutine on the event loop. + + The returned future is observation-only. ``Future.cancel()`` only + succeeds while the task is still queued; it cannot interrupt a + running task. Reading ``.exception()`` does not suppress the + ``ExceptionGroup`` raised at ``__exit__``. """ - if inspect.iscoroutinefunction(fn) and self._aio_portal is None: - raise RuntimeError( - "TaskGroup.start_soon got an async callable but the group has no aio_portal" - ) + if is_async_callable(fn) and self._aio_portal is None: + raise RuntimeError("TaskGroup got an async callable but the group has no aio_portal") with self._lock: if self._closed: raise RuntimeError("TaskGroup is closed") self._pending += 1 fut: Future[Any] = Future() # Snapshot the caller's context now (matches Trio / AnyIO / asyncio - # ``to_thread`` semantics); each ``start_soon`` captures independently. + # ``to_thread`` semantics); each spawn captures independently. task = Task(fut, fn, args, kwargs, self, contextvars.copy_context()) while True: try: diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 1739515..2716615 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -6,6 +6,7 @@ from concurrent.futures import Future import pytest +from anyio.from_thread import start_blocking_portal from localpost.threadtools import TaskGroup, warmup from localpost.threadtools import _task_group as _tg @@ -35,16 +36,23 @@ def _drain_idle_workers() -> None: # --------------------------------------------------------------------------- -def test_start_soon_returns_future_with_result(): +def test_start_soon_returns_none(): + """``start_soon`` is fire-and-forget — explicit ``None`` return.""" with TaskGroup() as tg: - fut = tg.start_soon(lambda x: x * 2, 21) + result = tg.start_soon(lambda: 42) + assert result is None + + +def test_create_task_returns_future_with_result(): + with TaskGroup() as tg: + fut = tg.create_task(lambda x: x * 2, 21) assert isinstance(fut, Future) assert fut.result(timeout=5) == 42 -def test_start_soon_with_kwargs(): +def test_create_task_with_kwargs(): with TaskGroup() as tg: - fut = tg.start_soon(lambda *, a, b: a + b, a=1, b=2) + fut = tg.create_task(lambda *, a, b: a + b, a=1, b=2) assert fut.result(timeout=5) == 3 @@ -56,7 +64,7 @@ def work(i: int) -> int: return i * i with TaskGroup() as tg: - futs = [tg.start_soon(work, i) for i in range(n)] + futs = [tg.create_task(work, i) for i in range(n)] assert sorted(f.result() for f in futs) == [i * i for i in range(n)] @@ -66,7 +74,7 @@ def work(i: int) -> int: # --------------------------------------------------------------------------- -def test_task_exception_captured_in_future_and_raised_at_exit(): +def test_create_task_exception_captured_in_future_and_raised_at_exit(): class Boom(Exception): pass @@ -75,14 +83,32 @@ def bad(): with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 with TaskGroup() as tg: - fut = tg.start_soon(bad) - # Future records the exception too + fut = tg.create_task(bad) + # Future records the exception too — and consuming it does + # *not* suppress the group's ExceptionGroup at __exit__. with pytest.raises(Boom): fut.result(timeout=5) assert len(ei.value.exceptions) == 1 assert isinstance(ei.value.exceptions[0], Boom) +def test_start_soon_exception_surfaces_in_group(): + """Even fire-and-forget tasks have their errors escalate to the group.""" + + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + with pytest.raises(ExceptionGroup) as ei: + with TaskGroup() as tg: + tg.start_soon(bad) + + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], Boom) + + def test_body_exception_alone_is_wrapped(): class BodyBoom(Exception): pass @@ -161,8 +187,8 @@ def branch(tg: TaskGroup, depth: int): leaf() return # Each branch spawns 2 children of depth-1, waits for them. - f1 = tg.start_soon(branch, tg, depth - 1) - f2 = tg.start_soon(branch, tg, depth - 1) + f1 = tg.create_task(branch, tg, depth - 1) + f2 = tg.create_task(branch, tg, depth - 1) f1.result(timeout=5) f2.result(timeout=5) leaf() @@ -177,7 +203,7 @@ def branch(tg: TaskGroup, depth: int): def test_start_soon_after_close_raises(): tg = TaskGroup() with tg: - tg.start_soon(lambda: None).result(timeout=5) + tg.create_task(lambda: None).result(timeout=5) with pytest.raises(RuntimeError, match="closed"): tg.start_soon(lambda: None) @@ -205,7 +231,7 @@ def record(): with TaskGroup() as tg: for _ in range(4): - tg.start_soon(record).result(timeout=5) + tg.create_task(record).result(timeout=5) first_run = set(seen) # Second group sees at least some of the same threads (workers parked @@ -213,7 +239,7 @@ def record(): seen.clear() with TaskGroup() as tg: for _ in range(4): - tg.start_soon(record).result(timeout=5) + tg.create_task(record).result(timeout=5) second_run = set(seen) assert first_run & second_run, "no worker reuse across groups" @@ -228,7 +254,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): # Spawn a fresh worker and grab a reference to it. with TaskGroup() as tg: - tg.start_soon(lambda: None).result(timeout=5) + tg.create_task(lambda: None).result(timeout=5) assert len(_tg.idle) >= 1 fresh = _tg.idle[-1] # LIFO: most recently parked @@ -238,7 +264,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): # Submitting again skips the dead tombstone and spawns fresh. with TaskGroup() as tg: - tg.start_soon(lambda: None).result(timeout=5) + tg.create_task(lambda: None).result(timeout=5) _drain_idle_workers() @@ -266,7 +292,7 @@ def tick(): for _ in range(5): with TaskGroup() as tg: - futs = [tg.start_soon(tick) for _ in range(n)] + futs = [tg.create_task(tick) for _ in range(n)] for f in futs: f.result(timeout=5) # Sleep to let some workers idle-time-out, leaving tombstones. @@ -331,7 +357,7 @@ def record_self() -> None: seen.add(threading.get_ident()) with TaskGroup() as tg: - tg.start_soon(record_self).result(timeout=5) + tg.create_task(record_self).result(timeout=5) # The worker that ran is one of the pre-warmed set. post = {id(w) for w in _tg.idle} @@ -360,7 +386,7 @@ def test_task_sees_caller_context(): var.set("caller-value") with TaskGroup() as tg: - fut = tg.start_soon(var.get) + fut = tg.create_task(var.get) assert fut.result(timeout=5) == "caller-value" @@ -374,7 +400,7 @@ def mutate(): return var.get() with TaskGroup() as tg: - fut = tg.start_soon(mutate) + fut = tg.create_task(mutate) assert fut.result(timeout=5) == "task-mutated" assert var.get() == "original" # caller's view unchanged @@ -387,9 +413,9 @@ def test_each_start_soon_captures_independently(): with TaskGroup() as tg: var.set(1) - f1 = tg.start_soon(var.get) + f1 = tg.create_task(var.get) var.set(2) - f2 = tg.start_soon(var.get) + f2 = tg.create_task(var.get) assert f1.result(timeout=5) == 1 assert f2.result(timeout=5) == 2 @@ -412,7 +438,7 @@ def observe() -> int: futs: list[Future[int]] = [] for i in range(10): var.set(i) - futs.append(tg.start_soon(observe)) + futs.append(tg.create_task(observe)) assert sorted(f.result(timeout=5) for f in futs) == list(range(10)) @@ -423,16 +449,15 @@ def observe() -> int: async def test_async_callable_runs_via_portal(): - """An async callable submitted to ``start_soon`` is awaited on the + """An async callable submitted via ``create_task`` is awaited on the portal's event loop; the future resolves with the awaited result.""" - from anyio.from_thread import start_blocking_portal async def add(a: int, b: int) -> int: return a + b with start_blocking_portal() as portal: with TaskGroup(aio_portal=portal) as tg: - fut = tg.start_soon(add, 2, 3) + fut = tg.create_task(add, 2, 3) assert fut.result(timeout=5) == 5 @@ -440,7 +465,6 @@ async def add(a: int, b: int) -> int: async def test_async_callable_exception_flows_through_future(): """Exceptions raised inside an async callable surface on the future (and into the TaskGroup's exception group).""" - from anyio.from_thread import start_blocking_portal async def boom(): raise ValueError("boom") @@ -449,7 +473,7 @@ async def boom(): tg = TaskGroup(aio_portal=portal) with pytest.raises(ExceptionGroup) as ei: with tg: - fut = tg.start_soon(boom) + fut = tg.create_task(boom) with pytest.raises(ValueError, match="boom"): fut.result(timeout=5) @@ -458,7 +482,7 @@ async def boom(): def test_async_callable_without_portal_raises(): """Submitting a coroutine function to a TaskGroup without an - ``aio_portal`` raises immediately at ``start_soon``.""" + ``aio_portal`` raises immediately — for both spawn methods.""" async def whatever(): return None @@ -466,6 +490,8 @@ async def whatever(): with TaskGroup() as tg: with pytest.raises(RuntimeError, match="aio_portal"): tg.start_soon(whatever) + with pytest.raises(RuntimeError, match="aio_portal"): + tg.create_task(whatever) def test_sync_callable_returning_coroutine_without_portal_raises(): From b4404dee5059e2b6dea0b4234d1592760aa5252a Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 01:37:05 +0400 Subject: [PATCH 232/286] fix(threadtools): dedup body and task exceptions by identity in TaskGroup.__exit__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a caller observes a failed create_task future via Future.result() and lets it propagate into the with-block body, the same exception instance reaches __exit__ twice — once as the body exception, once via the group's _errors deque (recorded in Task.run). Previously the ExceptionGroup surfaced the failure twice; now they collapse by identity so the group reports it once. Distinct exceptions (different instances, even of the same type) still both appear. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools/_task_group.py | 6 +++- tests/threadtools/thread_task_group.py | 40 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index 45e27dc..97e6922 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -211,7 +211,11 @@ def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> N all_errors: list[BaseException] = [] if exc is not None: all_errors.append(exc) - all_errors.extend(self._errors) + # Dedup by identity: a task that raised was already recorded in + # ``self._errors``. If the caller observed it via ``Future.result()`` + # and let it propagate into the body, ``exc`` is the same instance — + # surface it once, not twice. + all_errors.extend(e for e in self._errors if e is not exc) if all_errors: label = f"TaskGroup {self._name!r} failed" if self._name else "TaskGroup failed" # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 2716615..7536a54 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -109,6 +109,46 @@ def bad(): assert isinstance(ei.value.exceptions[0], Boom) +def test_create_task_result_reraise_is_deduped_in_group(): + """When ``Future.result()`` re-raises a task exception into the body, + ``__exit__`` collapses the body copy with the group-recorded copy + (same instance) — surfacing the failure exactly once.""" + + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + with pytest.raises(ExceptionGroup) as ei: + with TaskGroup() as tg: + tg.create_task(bad).result(timeout=5) + + assert len(ei.value.exceptions) == 1 + assert isinstance(ei.value.exceptions[0], Boom) + + +def test_distinct_body_and_task_exceptions_are_both_surfaced(): + """Dedup is by identity — two different exception instances must both + appear in the ExceptionGroup, even if they're the same type.""" + + class Boom(Exception): + pass + + def bad(): + raise Boom("from-task") + + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 + with TaskGroup() as tg: + tg.start_soon(bad) + time.sleep(0.05) # let the task land in _errors + raise Boom("from-body") + + assert len(ei.value.exceptions) == 2 + messages = sorted(str(e) for e in ei.value.exceptions) + assert messages == ["from-body", "from-task"] + + def test_body_exception_alone_is_wrapped(): class BodyBoom(Exception): pass From f8c66c5d98bddac2cc77f73a22b83b0230b876f1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 01:46:31 +0400 Subject: [PATCH 233/286] refactor(threadtools): seed all_errors from _errors, prepend body exc only if new Previously the body exception always landed at position 0 of the ExceptionGroup. When the body re-raised a task exception that wasn't the first recorded error, dedup kept it in front, distorting the order tasks actually failed in. Now we seed from _errors (preserving record order) and only prepend the body exception if it isn't already in there by identity. Adds a test pinning the new ordering contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools/_task_group.py | 16 ++++++------- tests/threadtools/thread_task_group.py | 33 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index 97e6922..f27c3e4 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -208,14 +208,14 @@ def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> N while self._pending > 0: self._cv.wait() self._closed = True - all_errors: list[BaseException] = [] - if exc is not None: - all_errors.append(exc) - # Dedup by identity: a task that raised was already recorded in - # ``self._errors``. If the caller observed it via ``Future.result()`` - # and let it propagate into the body, ``exc`` is the same instance — - # surface it once, not twice. - all_errors.extend(e for e in self._errors if e is not exc) + # Seed from ``_errors`` first so recorded tasks keep their original + # order. Prepend the body exception only if it isn't already in + # there — a task exception observed via ``Future.result()`` and let + # to propagate is the same instance as the one in ``_errors``; + # surfacing it once preserves both dedup and record order. + all_errors: list[BaseException] = list(self._errors) + if exc is not None and all(e is not exc for e in all_errors): + all_errors.insert(0, exc) if all_errors: label = f"TaskGroup {self._name!r} failed" if self._name else "TaskGroup failed" # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 7536a54..0a6958d 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -149,6 +149,39 @@ def bad(): assert messages == ["from-body", "from-task"] +def test_dedup_preserves_recorded_order_when_body_reraises_task_error(): + """If the body re-raises a task exception that wasn't first in + ``_errors``, dedup must keep it at its original recorded position + rather than promoting it to the front.""" + + class A(Exception): + pass + + class B(Exception): + pass + + def task_a(): + raise A("a") + + def task_b(): + raise B("b") + + with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 + with TaskGroup() as tg: + tg.start_soon(task_a) + time.sleep(0.05) # ensure A lands in _errors first + fut_b = tg.create_task(task_b) + # Pull B's exception (same instance now sitting in _errors) + # and re-raise it into the body. Without dedup-with-order, + # B would be moved to position 0. + b_exc = fut_b.exception(timeout=5) + assert b_exc is not None + raise b_exc + + types = [type(e) for e in ei.value.exceptions] + assert types == [A, B] # record order, not body-first + + def test_body_exception_alone_is_wrapped(): class BodyBoom(Exception): pass From 026a2f687deaeb730492ccf2076c462958150dde Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:34:23 +0400 Subject: [PATCH 234/286] build: widen uv-build pin to <0.13 The 0.10.0 upper bound excluded the current uv 0.11.x and emitted a warning on every `uv build`. Widen the range to keep building cleanly under recent uv releases. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6679aae..4a1d09c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["uv_build>=0.9.10,<0.10.0"] +requires = ["uv_build>=0.9.10,<0.13"] build-backend = "uv_build" # See also https://daniel.feldroy.com/posts/2023-08-pypi-project-urls-cheatsheet From d6058db8316c27ecdc78def8b4050f0b7326a7a1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:34:28 +0400 Subject: [PATCH 235/286] ci(release): switch pypi-publish workflow from pdm to uv The project's build backend is uv_build but the publish workflow still ran pdm install/build/publish. Replace with astral-sh/setup-uv@v5 + uv build + uv publish --trusted-publishing always so the release pipeline matches the build system. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pypi-publish.yaml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index 68b57c9..7adf1f0 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -5,7 +5,6 @@ on: types: [ published ] jobs: - # https://pdm-project.org/latest/usage/publish/#publish-with-trusted-publishers publish: runs-on: ubuntu-latest permissions: @@ -14,13 +13,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Just "fetch-tags: true" does not work, probably because of - # fetch-depth: 0 - - uses: pdm-project/setup-pdm@v4 + - uses: astral-sh/setup-uv@v5 with: + enable-cache: true python-version-file: ".python-version" - cache: true - - run: pdm install --no-lock --no-editable - - run: pdm build - - run: pdm publish --no-build + - run: uv build + - run: uv publish --trusted-publishing always From 5fcee1da172b9a1a9ed67ea610b88729e9c68407 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:34:35 +0400 Subject: [PATCH 236/286] docs(changelog): consolidate unreleased drafts into 0.6.0 Collapse the [Unreleased] block plus the never-released [0.5.0] and [0.6.0] drafts into a single [0.6.0] entry. Drop the consumers/SQS/Kafka items from the 0.5 draft since localpost.experimental is gone in this release. Reorganise into BREAKING / Added / Changed / Removed / Fixed / Performance, dedupe the two "Changed" blocks, and lead with a paragraph framing 0.6 as effectively a rewrite from 0.4. Add the localpost.openapi entry that was missing from the drafts. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 352 +++++++++++++++++++++++---------------------------- 1 file changed, 157 insertions(+), 195 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dedc701..734cbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,214 +5,176 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.6.0] - 2026-05-08 -### Fixed +**Effectively a rewrite from 0.4.0.** The project's focus — long-running +async Python processes built on AnyIO — is unchanged, but most internals +and a number of public APIs have changed. The core pillars (`hosting`, +`scheduler`, `http`, `di`) are the stable public surface, verified with +`ty` and `basedpyright --verifytypes`. A new `localpost.openapi` module +ships alongside them — a type-driven HTTP framework with OpenAPI 3.2 +generation built in. Several exploratory modules from the 0.4 line +(`flow`, `consumers`, `experimental`) are gone; they may return as a +separate package once the design settles. -### Changed +0.5.0 was drafted in `CHANGELOG.md` but never released. Its still-relevant +items are folded into this entry; the consumer/SQS/Kafka rework is dropped +along with the rest of `experimental`. -- **HTTP backend selection moved to `ServerConfig.backend`** (BREAKING). - A single entry point — `start_http_server(config, handler)` — and a - single hosted-service wrapper — `http_server(config, handler)` — - now pick between h11 and httptools based on the new - `ServerConfig.backend: Literal["h11", "httptools"] = "h11"` field - (default unchanged: pure-Python h11). Renames / removals: - - `start_httptools_server` removed → use - `start_http_server(ServerConfig(backend="httptools"), handler)`. - - `httptools_server` hosted-service wrapper removed → use - `http_server(ServerConfig(backend="httptools"), handler)`. - - `HttpApp.service(cfg, *, backend=…)` kwarg removed — set - `backend` on the `ServerConfig` instead. -- Concrete connection classes lose their backend suffix: each backend - module exposes its own `HTTPConn` (was `HTTPConnH11` / - `HTTPConnHttptools`). The Protocol `HTTPReqCtx` and the ABC - `BaseHTTPConn` are unchanged. Internal-only — no external callers - referenced the old names. +Python 3.12+ is now required (was 3.10+). ### Added -- **Per-request ``settimeout`` calls dropped from the borrow boundary.** - The conn stays non-blocking for its lifetime; the worker's send path - uses a non-blocking ``send`` with a blocking-with-timeout fallback on - ``BlockingIOError``. New ``_send_all`` helper in - ``localpost.http._base``. Saves two fcntl per request — see - ``benchmarks/http/PERF_FINDINGS.md`` Phase 10 (+21-32% RPS on the - bench's hot path). -- **`HttpApp` framework** (`localpost.http.app`). Decorator-driven HTTP - app on top of the lean Router. Decorators (`.get`, `.post`, ...), - parameter injection (`HTTPReqCtx` + path args matched by name), - response conversion (str / bytes / dict / list / `NativeResponse` / - `(NativeResponse, bytes)` / `None`), worker-pool dispatch, and - app-level + per-route middleware. New `app.service(config)` factory - for hosting integration. See `localpost/http/README.md` for usage. -- **HTTP middleware support.** New `localpost.http.Middleware` type - (`Callable[[RequestHandler], RequestHandler]`) and a `compose(*mws)` - helper. Plain Python decorator pattern — wrap pre-body, wrap the - returned `BodyHandler` for post-body work. Used by `HttpApp` for - app-level / per-route composition. -- **`HTTPReqCtx.attrs`** — `dict[str, Any]` mutable per-request state - on the Protocol. Used by `Router` to attach `RouteMatch`, available - for middlewares to thread auth / tracing / rate-limit state. -- **`RouteMatch` dataclass** + **`route_match(ctx)` accessor** — the - matched route info Router writes into `ctx.attrs["route_match"]`. -- **`streaming_pool_handler`** — async CM that runs a handler in a - worker on a borrowed conn (body **not** pre-buffered). Pair with - `HttpApp`'s `buffer_body=False` for streaming uploads. -- **Two-phase HTTP request handler contract.** `RequestHandler` is now - `Callable[[HTTPReqCtx], BodyHandler | None]`. The pre-body handler runs - on the selector thread when headers are parsed and may either complete - inline (returning `None`, e.g. for 404 / 405 / auth fail) or return a - `BodyHandler` continuation. The selector buffers the full request body - into `ctx.body` before invoking the continuation. Old-style - `(ctx) -> None` handlers are forward-compatible. See - `benchmarks/http/PERF_FINDINGS.md` Phase 8 for the design rationale and - bench numbers (+21% RPS on standard CPython 3.13 httptools). -- `localpost.http.BodyHandler` — type alias for the post-body continuation. -- **Free-threaded CPython (3.14t) support** — verified end-to-end with the - full http test suite passing and the bench delivering a ~3x RPS jump at - `selectors=1` (httptools plaintext: 12,563 → 36,208 RPS) just from - switching interpreters. The existing single-selector + worker-pool - architecture is already multi-threaded, so removing the GIL lets the - selector and workers actually overlap. Tested with `httptools >= 0.8` - (declares `Py_mod_gil = Py_MOD_GIL_NOT_USED`); the released 0.7.1 wheel - auto-re-enables the GIL on import. See - `benchmarks/http/PERF_FINDINGS.md` Phase 7b. -- **Multi-selector single-process** via the new `selectors: int = 1` knob - on `localpost.http.http_server` and `httptools_server`. With - `selectors > 1`, each selector thread binds its own listening socket on - the same address via `SO_REUSEPORT`; the kernel distributes incoming - connections across them. Handlers (and any wrapped - `thread_pool_handler`) are shared. **Note: on macOS this knob is a - no-op.** Verified empirically (`localpost_httptools_diag.py`): with - `selectors=4`, 100% of accepts go to one selector thread. macOS's - `SO_REUSEPORT` permits the bind but does not load-balance accepts — - unlike Linux 3.9+. An accept-dispatch design (one acceptor thread + - N selectors fed via the existing op queue) is the platform-portable - fix and is the next planned step. The knob still works on Linux, - pending a Linux bench cell to confirm scaling. -- `localpost.http.thread_pool_handler` — async context manager that wraps - a `RequestHandler` so the post-body `BodyHandler` continuation it - returns is offloaded to a worker thread. Pre-body decisions (routing, - auth, 404 / 405) stay on the selector. Compose explicitly with - `http_server` when you want body handlers on a worker pool. -- `just deadcode` — vulture-based dead-code finder, configured in - `pyproject.toml` (`[tool.vulture]`). -- **Optional `httptools` HTTP server backend** under the `[http-fast]` - extra. New entry points `localpost.http.start_httptools_server` and - `localpost.http.httptools_server` (hosted-service form) drive a - C-based llhttp parser as a peer of the existing h11 server. Same - selector / accept loop / connection bookkeeping (lifted into a shared - `BaseServer`); each backend uses its parser's natural idioms — no - internal `next_event/send_response` Protocol forced over both. - h11 stays the default. Initial scope: `Content-Length` responses - only (chunked transfer-encoding on the httptools side is a follow-up; - matches what `Router` and `wrap_wsgi` produce today). -- Neutral wire types `Request`, `NativeResponse`, `InformationalResponse` - (re-exported from `localpost.http`) — backend-agnostic shapes both - servers populate. User code no longer imports `h11` or `httptools` - directly. - -### Changed - -- **`RequestHandler` return type changed** from `None` to - `BodyHandler | None` (see Added). Old-style `(ctx) -> None` handlers - still work — returning `None` is the inline-completion path. - Handlers that previously read the body via `ctx.receive(size)` need - to migrate to the continuation pattern: return a `BodyHandler` and - read the body from `ctx.body`. The in-tree adapters - (`Router.as_handler`, `wrap_wsgi`, `flask_handler`, - `sentry_router_handler`, `sentry_flask_handler`) are updated. +- **`localpost.http`** — small h11-based HTTP/1.1 server with a single + selector thread and pluggable parser backend (`h11` by default, + `httptools` via the `[http-fast]` extra). Driven by + `start_http_server(config, handler)`. Includes: + - `HttpApp` — decorator-driven framework on top of the lean `Router` + (`@app.get`, `@app.post`, …): parameter injection, response + conversion (str / bytes / dict / list / `Response` / `None`), + worker-pool dispatch, app-level + per-route middleware. + - `Router` — minimal RFC 6570 Level-1 URI template dispatcher; attaches + `RouteMatch` to `ctx.attrs["route_match"]`, exposed via + `route_match(ctx)`. + - WSGI bridge (`wrap_wsgi`, `to_wsgi`), ASGI bridge (`to_asgi`), RSGI + bridge (`to_rsgi` + `HostRSGIApp` for Granian deployments). + - `read_body` / `aread_body` body helpers, `static_handler`, + `compress_handler` (gzip stdlib; brotli via the `[http-compress]` + extra). + - `thread_pool_handler` / `streaming_pool_handler` — opt-in worker-pool + offload of handlers and streaming uploads. + - `HTTPReqCtx.attrs` — mutable per-request state for cross-cutting + concerns (auth, tracing, rate-limit, body cache). + - **Free-threaded CPython 3.14t support** — verified end-to-end. ~3x + RPS jump at `selectors=1` (httptools plaintext: 12,563 → 36,208 RPS) + just from removing the GIL. The pure-Python `[http]` (h11) backend + is no-GIL-clean today; `[http-fast]` (httptools) needs 0.8+ to avoid + auto-re-enabling the GIL on import. + - **Multi-selector single-process** via `ServerConfig.selectors > 1` + on Linux (`SO_REUSEPORT`). macOS does not load-balance accepts and + is a no-op there pending an accept-dispatch design. + - Neutral wire types (`Request`, `Response`, `InformationalResponse`, + `BodyTooLarge`) — the public API does not leak `h11` or `httptools`. +- **Async HTTP context surface** — `AsyncHTTPReqCtx` Protocol + + `AsyncRequestHandler` type. `to_asgi` / `to_rsgi` adapters expose the + same handler shape over async transports, so the same `HttpApp` can run + under Granian or Uvicorn or the in-tree sync server. +- **`localpost.threadtools`** — sync primitives for thread-bridging code: + `TaskGroup` (a portal-backed task group with `start_soon(...)` for + fire-and-forget and `create_task(...) -> Future` for awaitable spawn), + `Channel` (with separate `SendChannel` / `ReceiveChannel` halves), and + `cancellable_semaphore` / `CancellableLock` with per-primitive + `check_cancelled`. +- **`localpost.di`** — `.NET`-style scoped IoC container + (`ServiceRegistry`, `ServiceProvider`, `AppContext`) with a Flask + integration that scopes services per request. +- **Hosting middleware** — `shutdown_on_signal()` and `start_timeout(...)`, + composable around any `ServiceF`. New `+` and `>>` operators for + combining and wrapping services. +- **`HostRSGIApp`** — host an RSGI app under `localpost.hosting` so it + participates in the same lifecycle / signals as everything else. +- **`localpost.debug`** — context manager to attach AnyIO-aware debug + hooks during development. +- **`hosting.services`** adapters — `uvicorn`, `hypercorn`, `grpc`, and a + generic `_asgi`. Each runs the underlying server as a hosted service + with proper start / stop semantics. +- **Scheduler trigger composition** — operator-based combinators + (`every("1m") // delay((0, 10))`), `take_first(n)`, `cron(...)` (via + the `[cron]` extra). Sync handlers are auto-offloaded to threads via + `anyio.to_thread`. Trigger middleware is now async-generator based + (`trigger_factory_middleware`). +- **`localpost.openapi`** (`[openapi]` extra) — type-driven HTTP framework + with OpenAPI 3.2 generation, on top of `localpost.http`. FastAPI-style + decorator API where the spec and runtime handling are derived from the + *same* type annotations, including union return types for response + shapes (`Book | NotFound[str]`). msgspec for encoding / decoding / + schema generation by default; pydantic and `attrs` recognised + automatically when present (`[openapi-attrs]` adds `attrs` + `cattrs`). + Sync (`HttpApp`) and async (`HttpAsyncApp`) flavours; OpenAPI-aware + middleware can contribute security schemes and extra responses. +- **Documentation site** — Zensical-based site built from per-module + READMEs (`mkdocs.yml`); seed ADRs and design notes under `docs/`. + +### Changed (BREAKING) + +- **Python 3.12+** required; 3.10 / 3.11 classifiers dropped. +- **Hosting fully rewritten.** `Host` and `AppHost` are gone. The new + surface is the `@service` decorator → `ServiceF`, top-level + `serve` / `run` / `run_app` entry points, structured `ServiceState` + (`Starting → Running → ShuttingDown → Stopped`), and `+` / `>>` + operators for service composition. See `localpost/hosting/README.md`. +- **`Router` is a lean dispatcher**, not a self-contained framework. + The old `RequestCtx` / `Response` / `(RequestCtx) -> Response` shape is + gone from `localpost.http.router`; `Router.as_handler()` returns a + plain `RequestHandler` that attaches a `RouteMatch` and delegates. + Pythonic helpers (decorators, response conversion, param injection) + live on the new `HttpApp`. Pair with `wrap_wsgi` if you need WSGI + output. +- **Scheduler internals reworked.** `ScheduledTask` / + `ScheduledTaskTemplate` / `Task` / `Scheduler`, declarative triggers + (`every`, `after`, `after_all`, `cron`); the sync/async-handler duality + is preserved. +- **`threadtools` namespace** — `ThreadTaskGroup` is now `TaskGroup`; the + module is now a package (`localpost/threadtools/`). +- **HTTP server** does not leak parser types into the public API. + `HTTPReqCtx.request` is `localpost.http.Request` (was `h11.Request`); + `HTTPReqCtx.start_response` and `complete` accept + `localpost.http.Response` / `InformationalResponse`. Field shapes match + h11's, so the migration is mechanical (`from localpost.http import + Response` and replace). +- **HTTP backend selection** lives on `ServerConfig.backend: Literal[ + "h11", "httptools"] = "h11"`. There is one entry point — + `start_http_server` — and one hosted-service wrapper — `http_server`. +- **`http_server` no longer owns a worker pool.** The `max_concurrency` + kwarg is gone from `http_server`, `flask_server`, and `wsgi_server`; + wrap your handler with `thread_pool_handler` to opt back into a pool + (typical for blocking WSGI / Flask handlers). - **HTTP/1.1 pipelining is no longer supported.** Pipelined clients are - served sequentially — correct, but no parallelism on the same - connection. The httptools backend's `_ready` deque was removed for the - simplification. -- **`Router` is now a lean dispatcher**, not a self-contained framework. - Removed `RequestCtx`, `Response`, `RequestHandler` (the - `(RequestCtx) -> Response` shape) from `localpost.http.router`. - `Router.as_handler()` returns a plain `localpost.http.RequestHandler` - that attaches a `RouteMatch` to `ctx.attrs["route_match"]` and - delegates to the registered http-level handler. **`Router.wsgi` is - removed** — pair with `localpost.http.wrap_wsgi` if you need WSGI - output. Pythonic helpers (decorators, response conversion, param - injection) move to the new `HttpApp`. See `PERF_FINDINGS.md` Phase 9 - — restructure delivers another +35% RPS on the bench's hot path. -- **`localpost.http` no longer leaks h11 types into the public API.** - `HTTPReqCtx.request` is now `localpost.http.Request` (was - `h11.Request`); `HTTPReqCtx.start_response` and `complete` accept - `localpost.http.NativeResponse` / `InformationalResponse` (was - `h11.Response` / `h11.InformationalResponse`). Field shapes are - identical (lowercased header-name bytes, byte values), so the - migration is mechanical: replace `import h11` / - `h11.Response(status_code=…, headers=…)` with - `from localpost.http import NativeResponse` / - `NativeResponse(status_code=…, headers=…)`. The `http` module is - marked stable, but absorbing this cost once enables the - alternative-backend support above. -- `localpost.http._service.http_server` no longer accepts `max_concurrency` - and no longer owns a worker pool. Wrap your handler with - `thread_pool_handler` to opt back into worker dispatch. -- `localpost.http.flask.flask_server` and `localpost.http.wsgi_server` - drop their `max_concurrency` kwarg for the same reason — wrap with - `thread_pool_handler` if you need a pool (typical for blocking WSGI - / Flask apps). -- Modernised typing throughout `localpost/_utils.py`, - `localpost/scheduler/`, and `localpost/hosting/_host.py` to PEP 695 - (`class Foo[T]`, `type Foo = ...`, inline function type parameters). - No public-API change — the existing module-level `TypeVar` declarations - in `localpost/scheduler/_scheduler.py` are kept until ty learns to - reconcile PEP 695 class type parameters with same-named TypeVars - inside nested generic functions. -- Dropped the `Programming Language :: Python :: 3.11` classifier - (the project's `requires-python = ">=3.12"` since 0.6). -- Cleaner ruff/ty footprint across the package and shared infra - (`localpost/__init__.py`, `_utils.py`, `threadtools.py`): 0 errors - from either tool. + served sequentially. +- **Internal typing modernised** to PEP 695 across `_utils`, `scheduler`, + and `hosting`. No public-API change. ### Removed -- **`localpost.experimental` is gone.** Both `experimental.consumers` - (channel / stream / queue / Pub/Sub) and `experimental.openapi` are - removed to keep the focus on the stable surface (`hosting`, `scheduler`, - `http`, `di`). The corresponding extras (`[sqs]`, `[kafka]`, `[nats]`, - `[http-openapi]`) and `examples/consumers/`, `examples/openapi/`, - `tests/experimental/` are removed too. May come back as a separate - package once the design settles. -- Internal helpers that were unused everywhere: `localpost._utils.NO_OP_TS`, - `AsyncContextManagerAdapter`, `Switch`, the `send_or_drop_from_thread` / - `send_or_drop` methods on the now-trivial `MemorySendStream` (so - ``MemoryStream.create()`` simply returns the bare anyio - ``MemoryObjectSendStream`` / ``MemoryObjectReceiveStream`` pair), and - `localpost.hosting._host._serve_and_observe`. - -## [0.6.0] - 2026-02-22 - -Complete rewrite of the hosting system, to simplify it and make it more robust. +- **`localpost.flow`** — too complicated; the data-flow surface lives on + through the scheduler's trigger composition. +- **`localpost.experimental`** — both `experimental.consumers` (channel / + stream / queue / Pub/Sub) and `experimental.openapi` are removed. + Corresponding extras (`[sqs]`, `[kafka]`, `[nats]`, `[http-openapi]`) + and `examples/consumers/`, `examples/openapi/`, `tests/experimental/` + are gone too. May return as a separate package once the design settles. +- **`scheduler.serve()` / `scheduler.aserve()`** — use `hosting.run` / + `run_app` instead. +- **`localpost.flow_ops`** (had been merged into `flow` for 0.4) — gone + along with `flow`. +- Internal helpers that were unused everywhere: `_utils.NO_OP_TS`, + `AsyncContextManagerAdapter`, `Switch`, `MemorySendStream`'s + `send_or_drop_from_thread` / `send_or_drop` (so `MemoryStream.create()` + now returns the bare AnyIO stream pair), and + `hosting._serve_and_observe`. ### Fixed -### Added - -- `localpost.http` — selectors-based non-async HTTP server - -### Changed - -### Removed - -- `localpost.flow` — too complicated - -## [0.5.0] - 2025-07-18 - -### Added - -- `localpost.consumers.stream` for in-memory queues -- `localpost.hosting.services.hypercorn` for Hypercorn HTTP server -- `localpost.debug` context manager, to simplify debugging -- More tests - -### Changed - -- `localpost.consumers.kafka` reworked -- `localpost.consumers.sqs` reworked (now with both `boto3` and `aioboto3` support) +- **Scheduler keeps its loop alive across handler exceptions** — a single + failing run no longer terminates the schedule. +- **`Task.subscribe` after the task has finished** raises a clear error + instead of silently hanging; graceful mid-iteration shutdown is + covered. +- **`TaskGroup.__exit__`** deduplicates exceptions that propagate via + both the body and a child task by identity, so each appears at most + once in the resulting `ExceptionGroup`. +- **HTTP send path** — non-blocking `send` with a blocking-with-timeout + fallback on `BlockingIOError`; per-request `settimeout` calls dropped + from the borrow boundary (saves two `fcntl` per request, +21–32% RPS + on the bench's hot path). +- **`UvicornService`** no longer crashes the whole app if the embedded + server fails to start (carried over from the unreleased 0.5 draft). + +### Performance + +- HTTP `Router` restructured into a lean dispatcher (+35% RPS on the + bench's hot path). +- See `benchmarks/macro/http/PERF_FINDINGS.md` for per-phase notes and numbers. ## [0.4.0] - 2025-06-23 From 1282f80d02a1fdbc9333fb3b7b4d0c33e87f09b7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:35:05 +0400 Subject: [PATCH 237/286] refactor(benchmarks)!: split into micro/ and macro/ layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote micro/ and macro/ as the two top-level groupings under benchmarks/. Move the macro suites and their shared infra: benchmarks/{http,openapi}/ -> benchmarks/macro/{http,openapi}/ benchmarks/_core/ -> benchmarks/macro/_core/ benchmarks/_setup.py -> benchmarks/macro/_setup.py Update all internal imports, docstrings, and path references in the moved code; rewrite benchmarks/README.md as an index over micro/ + macro/; thread the new module paths through justfile bench-* recipes and through PERF_FINDINGS links in docs/design/server-backends.md and localpost/http/_service.py. Fix tests/benchmarks/* in the same change: the old tests imported _parse_oha / parse_filters / select_stacks from benchmarks.http.{runner,stacks}, but those helpers had already moved into benchmarks._core (now benchmarks.macro._core) with new signatures (parse_oha takes expected_status; parse_filters takes valid_keys; select_stacks takes the suite's stacks + groups). Update imports + call sites; access dim values via stack.dims[...] instead of flat attributes; rename the asserted status_2xx key to status_expected. This was both the unit-test collection blocker (cannot import _parse_oha) and the trigger for the layout split — the helpers were already shared between two macro suites; the directory just hadn't caught up. Co-Authored-By: Claude Opus 4.7 (1M context) --- benchmarks/README.md | 37 +++++++------- benchmarks/macro/__init__.py | 7 +++ benchmarks/{ => macro}/_core/__init__.py | 2 +- benchmarks/{ => macro}/_core/cli.py | 12 ++--- benchmarks/{ => macro}/_core/filters.py | 2 +- benchmarks/{ => macro}/_core/pythons.py | 0 benchmarks/{ => macro}/_core/render.py | 2 +- benchmarks/{ => macro}/_core/runner.py | 4 +- benchmarks/{ => macro}/_core/types.py | 0 benchmarks/{ => macro}/_setup.py | 6 +-- benchmarks/{ => macro}/http/PERF_FINDINGS.md | 4 +- benchmarks/{ => macro}/http/README.md | 8 +-- benchmarks/{ => macro}/http/__init__.py | 0 benchmarks/{ => macro}/http/apps/__init__.py | 0 benchmarks/{ => macro}/http/apps/_cli.py | 0 .../{ => macro}/http/apps/_flask_app.py | 2 +- .../{ => macro}/http/apps/_starlette_app.py | 2 +- .../{ => macro}/http/apps/flask_cheroot.py | 4 +- .../{ => macro}/http/apps/flask_granian.py | 4 +- .../{ => macro}/http/apps/flask_gunicorn.py | 4 +- .../{ => macro}/http/apps/localpost_flask.py | 4 +- .../{ => macro}/http/apps/localpost_h11.py | 4 +- .../http/apps/localpost_h11_acceptor_s4.py | 4 +- .../{ => macro}/http/apps/localpost_h11_s4.py | 4 +- .../http/apps/localpost_httptools.py | 4 +- .../apps/localpost_httptools_acceptor_s4.py | 4 +- .../http/apps/localpost_httptools_diag.py | 4 +- .../http/apps/localpost_httptools_inline.py | 4 +- .../localpost_httptools_inline_acceptor_s4.py | 4 +- .../apps/localpost_httptools_inline_s4.py | 2 +- .../http/apps/localpost_httptools_s4.py | 4 +- .../{ => macro}/http/apps/localpost_wsgi.py | 4 +- .../http/apps/starlette_granian.py | 4 +- .../http/apps/starlette_uvicorn.py | 4 +- benchmarks/{ => macro}/http/runner.py | 12 ++--- benchmarks/{ => macro}/http/scenarios.py | 4 +- benchmarks/{ => macro}/http/stacks.py | 6 +-- benchmarks/{ => macro}/openapi/README.md | 4 +- benchmarks/{ => macro}/openapi/__init__.py | 2 +- .../{ => macro}/openapi/apps/__init__.py | 0 benchmarks/{ => macro}/openapi/apps/_cli.py | 0 .../{ => macro}/openapi/apps/fastapi.py | 2 +- .../{ => macro}/openapi/apps/flask_openapi.py | 2 +- .../openapi/apps/localpost_openapi.py | 2 +- .../openapi/apps/localpost_openapi_async.py | 2 +- .../openapi/apps/localpost_openapi_granian.py | 2 +- benchmarks/{ => macro}/openapi/runner.py | 12 ++--- benchmarks/{ => macro}/openapi/scenarios.py | 6 +-- benchmarks/{ => macro}/openapi/stacks.py | 2 +- docs/design/server-backends.md | 2 +- justfile | 8 +-- localpost/http/_service.py | 2 +- tests/benchmarks/http_runner.py | 14 +++--- tests/benchmarks/http_stacks.py | 49 +++++++++++-------- 54 files changed, 151 insertions(+), 136 deletions(-) create mode 100644 benchmarks/macro/__init__.py rename benchmarks/{ => macro}/_core/__init__.py (67%) rename benchmarks/{ => macro}/_core/cli.py (96%) rename benchmarks/{ => macro}/_core/filters.py (98%) rename benchmarks/{ => macro}/_core/pythons.py (100%) rename benchmarks/{ => macro}/_core/render.py (99%) rename benchmarks/{ => macro}/_core/runner.py (97%) rename benchmarks/{ => macro}/_core/types.py (100%) rename benchmarks/{ => macro}/_setup.py (89%) rename benchmarks/{ => macro}/http/PERF_FINDINGS.md (99%) rename benchmarks/{ => macro}/http/README.md (93%) rename benchmarks/{ => macro}/http/__init__.py (100%) rename benchmarks/{ => macro}/http/apps/__init__.py (100%) rename benchmarks/{ => macro}/http/apps/_cli.py (100%) rename benchmarks/{ => macro}/http/apps/_flask_app.py (89%) rename benchmarks/{ => macro}/http/apps/_starlette_app.py (91%) rename benchmarks/{ => macro}/http/apps/flask_cheroot.py (78%) rename benchmarks/{ => macro}/http/apps/flask_granian.py (84%) rename benchmarks/{ => macro}/http/apps/flask_gunicorn.py (89%) rename benchmarks/{ => macro}/http/apps/localpost_flask.py (86%) rename benchmarks/{ => macro}/http/apps/localpost_h11.py (91%) rename benchmarks/{ => macro}/http/apps/localpost_h11_acceptor_s4.py (73%) rename benchmarks/{ => macro}/http/apps/localpost_h11_s4.py (65%) rename benchmarks/{ => macro}/http/apps/localpost_httptools.py (93%) rename benchmarks/{ => macro}/http/apps/localpost_httptools_acceptor_s4.py (72%) rename benchmarks/{ => macro}/http/apps/localpost_httptools_diag.py (95%) rename benchmarks/{ => macro}/http/apps/localpost_httptools_inline.py (92%) rename benchmarks/{ => macro}/http/apps/localpost_httptools_inline_acceptor_s4.py (74%) rename benchmarks/{ => macro}/http/apps/localpost_httptools_inline_s4.py (74%) rename benchmarks/{ => macro}/http/apps/localpost_httptools_s4.py (64%) rename benchmarks/{ => macro}/http/apps/localpost_wsgi.py (85%) rename benchmarks/{ => macro}/http/apps/starlette_granian.py (80%) rename benchmarks/{ => macro}/http/apps/starlette_uvicorn.py (74%) rename benchmarks/{ => macro}/http/runner.py (74%) rename benchmarks/{ => macro}/http/scenarios.py (96%) rename benchmarks/{ => macro}/http/stacks.py (94%) rename benchmarks/{ => macro}/openapi/README.md (97%) rename benchmarks/{ => macro}/openapi/__init__.py (77%) rename benchmarks/{ => macro}/openapi/apps/__init__.py (100%) rename benchmarks/{ => macro}/openapi/apps/_cli.py (100%) rename benchmarks/{ => macro}/openapi/apps/fastapi.py (97%) rename benchmarks/{ => macro}/openapi/apps/flask_openapi.py (98%) rename benchmarks/{ => macro}/openapi/apps/localpost_openapi.py (97%) rename benchmarks/{ => macro}/openapi/apps/localpost_openapi_async.py (98%) rename benchmarks/{ => macro}/openapi/apps/localpost_openapi_granian.py (98%) rename benchmarks/{ => macro}/openapi/runner.py (64%) rename benchmarks/{ => macro}/openapi/scenarios.py (91%) rename benchmarks/{ => macro}/openapi/stacks.py (97%) diff --git a/benchmarks/README.md b/benchmarks/README.md index 252c745..29908b1 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,24 +1,25 @@ # LocalPost benchmarks -Three independent suites: - -- **[`http/`](http/README.md)** — macro HTTP load benchmarks. Compare - LocalPost's HTTP server against peer servers (Gunicorn, Cheroot, - Granian, Uvicorn) on a fixed Flask / Starlette workload using - [`oha`](https://github.com/hatoo/oha) as the load generator. Measures - **HTTP server** overhead. -- **[`openapi/`](openapi/README.md)** — macro framework benchmarks. - Compare `localpost.openapi` against peer typed/OpenAPI frameworks - (FastAPI, flask-openapi v5) on a shared workload — typed handlers, - schema validation, response serialization. Measures **framework** - overhead. +Two top-level groups: + - **[`micro/`](micro/)** — `pytest-benchmark` micro-benchmarks for `URITemplate` and `Router`. Catches perf regressions in the deterministic core. +- **[`macro/`](macro/)** — load benchmarks driven by + [`oha`](https://github.com/hatoo/oha) against a spawned subprocess. + Two suites: + - **[`macro/http/`](macro/http/README.md)** — compares LocalPost's + HTTP server against peer servers (Gunicorn, Cheroot, Granian, + Uvicorn) on a fixed Flask / Starlette workload. Measures **HTTP + server** overhead. + - **[`macro/openapi/`](macro/openapi/README.md)** — compares + `localpost.openapi` against peer typed/OpenAPI frameworks (FastAPI, + flask-openapi v5) on a shared workload — typed handlers, schema + validation, response serialization. Measures **framework** overhead. The two macro suites share the runner, types, filter language, and -report writers via [`_core/`](_core/). Each defines its own scenarios, -stacks, and `apps/`. +report writers via [`macro/_core/`](macro/_core/). Each defines its own +scenarios, stacks, and `apps/`. All three are kept out of the default test run (`just tests`). @@ -40,13 +41,13 @@ just bench-openapi --duration 5 --stacks fastapi,localpost_openapi just bench-micro ``` -Per-suite docs: [`http/README.md`](http/README.md), -[`openapi/README.md`](openapi/README.md). +Per-suite docs: [`macro/http/README.md`](macro/http/README.md), +[`macro/openapi/README.md`](macro/openapi/README.md). ## Shared CLI surface Both macro runners share these flags via -[`benchmarks/_core/cli.py`](_core/cli.py): +[`benchmarks/macro/_core/cli.py`](macro/_core/cli.py): - `--duration N` — seconds per cell (default 20). - `--stacks a,b` — verbatim stack name list (escape hatch). @@ -57,7 +58,7 @@ Both macro runners share these flags via - `--scenarios a,b` — restrict to specific scenarios. - `--pythons name=path,...` — override the bench Python matrix (default: every entry in - [`benchmarks._core.pythons.PYTHONS`](_core/pythons.py)). + [`benchmarks.macro._core.pythons.PYTHONS`](macro/_core/pythons.py)). ## Micro suite diff --git a/benchmarks/macro/__init__.py b/benchmarks/macro/__init__.py new file mode 100644 index 0000000..b3731b3 --- /dev/null +++ b/benchmarks/macro/__init__.py @@ -0,0 +1,7 @@ +"""Macro benchmark suites. + +Each suite (``http``, ``openapi``) drives a real HTTP load (oha) against a +spawned subprocess running one of its ``apps/`` modules. Shared +infrastructure — runner, filter language, types, CLI — lives in ``_core``. +``_setup.py`` syncs the shared bench venvs. +""" diff --git a/benchmarks/_core/__init__.py b/benchmarks/macro/_core/__init__.py similarity index 67% rename from benchmarks/_core/__init__.py rename to benchmarks/macro/_core/__init__.py index f4d44ce..859e9a6 100644 --- a/benchmarks/_core/__init__.py +++ b/benchmarks/macro/_core/__init__.py @@ -1,6 +1,6 @@ """Shared infrastructure for the macro benchmark suites. -Each suite (``benchmarks/http/``, ``benchmarks/openapi/``) defines its own +Each suite (``benchmarks/macro/http/``, ``benchmarks/macro/openapi/``) defines its own scenarios, stacks, and ``apps/`` modules but reuses this package for the runner, types, CLI, and report rendering. """ diff --git a/benchmarks/_core/cli.py b/benchmarks/macro/_core/cli.py similarity index 96% rename from benchmarks/_core/cli.py rename to benchmarks/macro/_core/cli.py index c9c5614..6754817 100644 --- a/benchmarks/_core/cli.py +++ b/benchmarks/macro/_core/cli.py @@ -28,11 +28,11 @@ from datetime import UTC, datetime from pathlib import Path -from benchmarks._core.filters import select_stacks -from benchmarks._core.pythons import PYTHONS -from benchmarks._core.render import render_html, render_markdown -from benchmarks._core.runner import pick_port, probe_python, run_cell -from benchmarks._core.types import Cell, RunReport, Scenario, Stack +from benchmarks.macro._core.filters import select_stacks +from benchmarks.macro._core.pythons import PYTHONS +from benchmarks.macro._core.render import render_html, render_markdown +from benchmarks.macro._core.runner import pick_port, probe_python, run_cell +from benchmarks.macro._core.types import Cell, RunReport, Scenario, Stack @dataclass(frozen=True, slots=True) @@ -135,7 +135,7 @@ def run( "--pythons", default="", help="comma-separated name=path pairs to override the bench matrix, e.g. " - "'3.13=.venv-bench/3.13/bin/python' (default: every entry in benchmarks._core.pythons.PYTHONS)", + "'3.13=.venv-bench/3.13/bin/python' (default: every entry in benchmarks.macro._core.pythons.PYTHONS)", ) p.add_argument("--port-base", type=int, default=18800) args = p.parse_args(argv) diff --git a/benchmarks/_core/filters.py b/benchmarks/macro/_core/filters.py similarity index 98% rename from benchmarks/_core/filters.py rename to benchmarks/macro/_core/filters.py index 9f2569e..c5cb273 100644 --- a/benchmarks/_core/filters.py +++ b/benchmarks/macro/_core/filters.py @@ -19,7 +19,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass -from benchmarks._core.types import Stack +from benchmarks.macro._core.types import Stack @dataclass(frozen=True, slots=True) diff --git a/benchmarks/_core/pythons.py b/benchmarks/macro/_core/pythons.py similarity index 100% rename from benchmarks/_core/pythons.py rename to benchmarks/macro/_core/pythons.py diff --git a/benchmarks/_core/render.py b/benchmarks/macro/_core/render.py similarity index 99% rename from benchmarks/_core/render.py rename to benchmarks/macro/_core/render.py index 88eb573..ad4e948 100644 --- a/benchmarks/_core/render.py +++ b/benchmarks/macro/_core/render.py @@ -17,7 +17,7 @@ import json from typing import Any -from benchmarks._core.types import Cell, RunReport +from benchmarks.macro._core.types import Cell, RunReport GRIDJS_VERSION = "6.2.0" _CDN_BASE = f"https://cdn.jsdelivr.net/npm/gridjs@{GRIDJS_VERSION}/dist" diff --git a/benchmarks/_core/runner.py b/benchmarks/macro/_core/runner.py similarity index 97% rename from benchmarks/_core/runner.py rename to benchmarks/macro/_core/runner.py index ffab1b1..423d04f 100644 --- a/benchmarks/_core/runner.py +++ b/benchmarks/macro/_core/runner.py @@ -1,7 +1,7 @@ """Cell-level orchestration: spawn → readiness probe → oha → parse → kill. Suite-agnostic. The CLI passes in the suite's apps package prefix (e.g. -``benchmarks.http.apps``) and the runner spawns ``python -m .`` +``benchmarks.macro.http.apps``) and the runner spawns ``python -m .`` for each cell. """ @@ -15,7 +15,7 @@ import time from pathlib import Path -from benchmarks._core.types import Cell, Scenario, Stack +from benchmarks.macro._core.types import Cell, Scenario, Stack def pick_port(base: int, offset: int) -> int: diff --git a/benchmarks/_core/types.py b/benchmarks/macro/_core/types.py similarity index 100% rename from benchmarks/_core/types.py rename to benchmarks/macro/_core/types.py diff --git a/benchmarks/_setup.py b/benchmarks/macro/_setup.py similarity index 89% rename from benchmarks/_setup.py rename to benchmarks/macro/_setup.py index 4651597..13a850f 100644 --- a/benchmarks/_setup.py +++ b/benchmarks/macro/_setup.py @@ -1,10 +1,10 @@ """Sync every venv in the bench matrix. -Iterates ``benchmarks._core.pythons.PYTHONS`` and runs ``uv sync`` for each, +Iterates ``benchmarks.macro._core.pythons.PYTHONS`` and runs ``uv sync`` for each, redirected to the entry's venv via ``UV_PROJECT_ENVIRONMENT``. Continues on failure; exits non-zero if any sync failed. -The two macro bench suites (``benchmarks/http/``, ``benchmarks/openapi/``) +The two macro bench suites (``benchmarks/macro/http/``, ``benchmarks/macro/openapi/``) share the same venvs, so this installs the union of their groups and extras in one pass — ``uv sync`` would otherwise wipe groups not named on the current invocation. @@ -23,7 +23,7 @@ import subprocess import sys -from benchmarks._core.pythons import PYTHONS +from benchmarks.macro._core.pythons import PYTHONS # Union across all bench suites. See per-suite ``apps/`` for the actual # imports. diff --git a/benchmarks/http/PERF_FINDINGS.md b/benchmarks/macro/http/PERF_FINDINGS.md similarity index 99% rename from benchmarks/http/PERF_FINDINGS.md rename to benchmarks/macro/http/PERF_FINDINGS.md index f95468b..b1fdacc 100644 --- a/benchmarks/http/PERF_FINDINGS.md +++ b/benchmarks/macro/http/PERF_FINDINGS.md @@ -191,7 +191,7 @@ benchmarks; only worth doing if a measurable gap to Cheroot remains. ## Validation plan -- A/B Phase 1 against current path in `benchmarks/http/runner.py`. Expect +- A/B Phase 1 against current path in `benchmarks/macro/http/runner.py`. Expect p50 to collapse from ~12 ms toward ~2-3 ms; RPS to rise correspondingly. - New tests for Phase 1b: - Client closes mid-request → handler's `check_cancelled()` raises @@ -702,7 +702,7 @@ the same number. ### Why multi-selector is flat: macOS `SO_REUSEPORT` does not distribute Confirmed empirically with a diagnostic build -(`benchmarks/http/apps/localpost_httptools_diag.py`) that counts requests +(`benchmarks/macro/http/apps/localpost_httptools_diag.py`) that counts requests per selector thread. At `selectors=4`, `oha -c 512 -z 8s`: ``` diff --git a/benchmarks/http/README.md b/benchmarks/macro/http/README.md similarity index 93% rename from benchmarks/http/README.md rename to benchmarks/macro/http/README.md index d4f11a0..45dd4de 100644 --- a/benchmarks/http/README.md +++ b/benchmarks/macro/http/README.md @@ -6,7 +6,7 @@ Starlette workload using [`oha`](https://github.com/hatoo/oha) as the load generator. This is the **server** bench. The sibling -[`benchmarks/openapi/`](../openapi/README.md) measures **framework** +[`benchmarks/macro/openapi/`](../openapi/README.md) measures **framework** overhead (typed handlers, schema validation) — different question, different suite. @@ -28,7 +28,7 @@ just bench-http --filter app=flask # all Flask servers just bench-http --filter 'backend=lp-*' --filter selectors=1 ``` -Output lands in `benchmarks/http/results///`: +Output lands in `benchmarks/macro/http/results///`: - `results.json` — raw cells (re-parseable). - `RESULTS.md` — markdown summary, one table per scenario. @@ -65,9 +65,9 @@ the multiplicative effect of more processes. ## How a run works For each (python, stack, scenario) cell, the shared runner -([`benchmarks/_core/cli.py`](../_core/cli.py)) does: +([`benchmarks/macro/_core/cli.py`](../_core/cli.py)) does: -1. `subprocess.Popen` the stack as `python -m benchmarks.http.apps.`. +1. `subprocess.Popen` the stack as `python -m benchmarks.macro.http.apps.`. 2. Poll `127.0.0.1:` with TCP connect until ready (≤ 10 s). 3. Fire `oha --no-tui -j -z s -c ...`. 4. Parse JSON → RPS, p50/p90/p99, status histogram. diff --git a/benchmarks/http/__init__.py b/benchmarks/macro/http/__init__.py similarity index 100% rename from benchmarks/http/__init__.py rename to benchmarks/macro/http/__init__.py diff --git a/benchmarks/http/apps/__init__.py b/benchmarks/macro/http/apps/__init__.py similarity index 100% rename from benchmarks/http/apps/__init__.py rename to benchmarks/macro/http/apps/__init__.py diff --git a/benchmarks/http/apps/_cli.py b/benchmarks/macro/http/apps/_cli.py similarity index 100% rename from benchmarks/http/apps/_cli.py rename to benchmarks/macro/http/apps/_cli.py diff --git a/benchmarks/http/apps/_flask_app.py b/benchmarks/macro/http/apps/_flask_app.py similarity index 89% rename from benchmarks/http/apps/_flask_app.py rename to benchmarks/macro/http/apps/_flask_app.py index 5ff935c..aed8265 100644 --- a/benchmarks/http/apps/_flask_app.py +++ b/benchmarks/macro/http/apps/_flask_app.py @@ -7,7 +7,7 @@ from flask import Flask, Response, jsonify from flask import request as flask_request -from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update +from benchmarks.macro.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update def build_app() -> Flask: diff --git a/benchmarks/http/apps/_starlette_app.py b/benchmarks/macro/http/apps/_starlette_app.py similarity index 91% rename from benchmarks/http/apps/_starlette_app.py rename to benchmarks/macro/http/apps/_starlette_app.py index 0db95dc..0e7a1c0 100644 --- a/benchmarks/http/apps/_starlette_app.py +++ b/benchmarks/macro/http/apps/_starlette_app.py @@ -9,7 +9,7 @@ from starlette.responses import JSONResponse, Response from starlette.routing import Route -from benchmarks.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update +from benchmarks.macro.http.scenarios import HELLO_PREFIX, PING_BODY, PROFILE_WORK_DELAYS_S, build_profile_update async def _ping(_: Request) -> Response: diff --git a/benchmarks/http/apps/flask_cheroot.py b/benchmarks/macro/http/apps/flask_cheroot.py similarity index 78% rename from benchmarks/http/apps/flask_cheroot.py rename to benchmarks/macro/http/apps/flask_cheroot.py index 4aadf3a..d634859 100644 --- a/benchmarks/http/apps/flask_cheroot.py +++ b/benchmarks/macro/http/apps/flask_cheroot.py @@ -6,8 +6,8 @@ from cheroot.wsgi import Server as WSGIServer -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._flask_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app def main() -> int: diff --git a/benchmarks/http/apps/flask_granian.py b/benchmarks/macro/http/apps/flask_granian.py similarity index 84% rename from benchmarks/http/apps/flask_granian.py rename to benchmarks/macro/http/apps/flask_granian.py index 5334591..408371d 100644 --- a/benchmarks/http/apps/flask_granian.py +++ b/benchmarks/macro/http/apps/flask_granian.py @@ -7,8 +7,8 @@ from granian import Granian from granian.constants import Interfaces -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._flask_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app # Granian re-imports this module in each worker; the worker uses ``app`` directly. app = build_app() diff --git a/benchmarks/http/apps/flask_gunicorn.py b/benchmarks/macro/http/apps/flask_gunicorn.py similarity index 89% rename from benchmarks/http/apps/flask_gunicorn.py rename to benchmarks/macro/http/apps/flask_gunicorn.py index 999b17c..4327d06 100644 --- a/benchmarks/http/apps/flask_gunicorn.py +++ b/benchmarks/macro/http/apps/flask_gunicorn.py @@ -10,8 +10,8 @@ from gunicorn.app.base import BaseApplication -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._flask_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app class _GunicornApp(BaseApplication): diff --git a/benchmarks/http/apps/localpost_flask.py b/benchmarks/macro/http/apps/localpost_flask.py similarity index 86% rename from benchmarks/http/apps/localpost_flask.py rename to benchmarks/macro/http/apps/localpost_flask.py index 41aafe0..101c485 100644 --- a/benchmarks/http/apps/localpost_flask.py +++ b/benchmarks/macro/http/apps/localpost_flask.py @@ -4,8 +4,8 @@ import sys -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._flask_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app from localpost import threadtools from localpost.hosting import run_app, service from localpost.http import ServerConfig, http_server, thread_pool_handler diff --git a/benchmarks/http/apps/localpost_h11.py b/benchmarks/macro/http/apps/localpost_h11.py similarity index 91% rename from benchmarks/http/apps/localpost_h11.py rename to benchmarks/macro/http/apps/localpost_h11.py index 24cfceb..d503474 100644 --- a/benchmarks/http/apps/localpost_h11.py +++ b/benchmarks/macro/http/apps/localpost_h11.py @@ -5,8 +5,8 @@ import sys import time -from benchmarks.http.apps._cli import parse_args -from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost import threadtools from localpost.hosting import run_app from localpost.http import HTTPReqCtx, Response, ServerConfig diff --git a/benchmarks/http/apps/localpost_h11_acceptor_s4.py b/benchmarks/macro/http/apps/localpost_h11_acceptor_s4.py similarity index 73% rename from benchmarks/http/apps/localpost_h11_acceptor_s4.py rename to benchmarks/macro/http/apps/localpost_h11_acceptor_s4.py index 609e63e..119bf1b 100644 --- a/benchmarks/http/apps/localpost_h11_acceptor_s4.py +++ b/benchmarks/macro/http/apps/localpost_h11_acceptor_s4.py @@ -1,6 +1,6 @@ """LocalPost h11 backend, acceptor topology with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_h11` that injects +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_h11` that injects ``--selectors 4 --acceptor`` so the runner can pick it up as a separate stack. One acceptor thread + four worker selectors via the cross-thread op queue. @@ -10,7 +10,7 @@ import sys -from benchmarks.http.apps.localpost_h11 import main +from benchmarks.macro.http.apps.localpost_h11 import main if __name__ == "__main__": sys.argv += ["--selectors", "4", "--acceptor"] diff --git a/benchmarks/http/apps/localpost_h11_s4.py b/benchmarks/macro/http/apps/localpost_h11_s4.py similarity index 65% rename from benchmarks/http/apps/localpost_h11_s4.py rename to benchmarks/macro/http/apps/localpost_h11_s4.py index 88621fe..395002b 100644 --- a/benchmarks/http/apps/localpost_h11_s4.py +++ b/benchmarks/macro/http/apps/localpost_h11_s4.py @@ -1,6 +1,6 @@ """LocalPost h11 backend with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_h11` that injects +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_h11` that injects ``--selectors 4`` so the runner can pick it up as a separate stack. """ @@ -8,7 +8,7 @@ import sys -from benchmarks.http.apps.localpost_h11 import main +from benchmarks.macro.http.apps.localpost_h11 import main if __name__ == "__main__": sys.argv += ["--selectors", "4"] diff --git a/benchmarks/http/apps/localpost_httptools.py b/benchmarks/macro/http/apps/localpost_httptools.py similarity index 93% rename from benchmarks/http/apps/localpost_httptools.py rename to benchmarks/macro/http/apps/localpost_httptools.py index b9c159b..1a1b711 100644 --- a/benchmarks/http/apps/localpost_httptools.py +++ b/benchmarks/macro/http/apps/localpost_httptools.py @@ -8,8 +8,8 @@ import sys import time -from benchmarks.http.apps._cli import parse_args -from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost import threadtools from localpost.hosting import run_app from localpost.http import HTTPReqCtx, Response, ServerConfig diff --git a/benchmarks/http/apps/localpost_httptools_acceptor_s4.py b/benchmarks/macro/http/apps/localpost_httptools_acceptor_s4.py similarity index 72% rename from benchmarks/http/apps/localpost_httptools_acceptor_s4.py rename to benchmarks/macro/http/apps/localpost_httptools_acceptor_s4.py index ed2006f..ec3537c 100644 --- a/benchmarks/http/apps/localpost_httptools_acceptor_s4.py +++ b/benchmarks/macro/http/apps/localpost_httptools_acceptor_s4.py @@ -1,6 +1,6 @@ """LocalPost httptools backend, acceptor topology with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_httptools` that injects +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_httptools` that injects ``--selectors 4 --acceptor`` so the runner can pick it up as a separate stack. One acceptor thread + four worker selectors via the cross-thread op queue. @@ -10,7 +10,7 @@ import sys -from benchmarks.http.apps.localpost_httptools import main +from benchmarks.macro.http.apps.localpost_httptools import main if __name__ == "__main__": sys.argv += ["--selectors", "4", "--acceptor"] diff --git a/benchmarks/http/apps/localpost_httptools_diag.py b/benchmarks/macro/http/apps/localpost_httptools_diag.py similarity index 95% rename from benchmarks/http/apps/localpost_httptools_diag.py rename to benchmarks/macro/http/apps/localpost_httptools_diag.py index 37265cb..49ec419 100644 --- a/benchmarks/http/apps/localpost_httptools_diag.py +++ b/benchmarks/macro/http/apps/localpost_httptools_diag.py @@ -25,8 +25,8 @@ import time from collections import Counter -from benchmarks.http.apps._cli import parse_args -from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app, service from localpost.http import ( BodyHandler, diff --git a/benchmarks/http/apps/localpost_httptools_inline.py b/benchmarks/macro/http/apps/localpost_httptools_inline.py similarity index 92% rename from benchmarks/http/apps/localpost_httptools_inline.py rename to benchmarks/macro/http/apps/localpost_httptools_inline.py index 9fd8007..ba800bc 100644 --- a/benchmarks/http/apps/localpost_httptools_inline.py +++ b/benchmarks/macro/http/apps/localpost_httptools_inline.py @@ -9,8 +9,8 @@ import sys import time -from benchmarks.http.apps._cli import parse_args -from benchmarks.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body +from benchmarks.macro.http.apps._cli import parse_args +from benchmarks.macro.http.scenarios import PING_BODY, PROFILE_WORK_DELAYS_S, hello_body, profile_update_body from localpost.hosting import run_app from localpost.http import HTTPReqCtx, Response, ServerConfig from localpost.http.app import HttpApp diff --git a/benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py b/benchmarks/macro/http/apps/localpost_httptools_inline_acceptor_s4.py similarity index 74% rename from benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py rename to benchmarks/macro/http/apps/localpost_httptools_inline_acceptor_s4.py index 40b2cc4..81c76d6 100644 --- a/benchmarks/http/apps/localpost_httptools_inline_acceptor_s4.py +++ b/benchmarks/macro/http/apps/localpost_httptools_inline_acceptor_s4.py @@ -1,6 +1,6 @@ """LocalPost httptools backend, inline (no pool), acceptor topology with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_httptools_inline` that +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_httptools_inline` that injects ``--selectors 4 --acceptor`` so the runner can pick it up as a separate stack. Characterises the acceptor topology in isolation — handlers run on the worker selector thread without a pool hop. @@ -10,7 +10,7 @@ import sys -from benchmarks.http.apps.localpost_httptools_inline import main +from benchmarks.macro.http.apps.localpost_httptools_inline import main if __name__ == "__main__": sys.argv += ["--selectors", "4", "--acceptor"] diff --git a/benchmarks/http/apps/localpost_httptools_inline_s4.py b/benchmarks/macro/http/apps/localpost_httptools_inline_s4.py similarity index 74% rename from benchmarks/http/apps/localpost_httptools_inline_s4.py rename to benchmarks/macro/http/apps/localpost_httptools_inline_s4.py index 409df8d..6ff942e 100644 --- a/benchmarks/http/apps/localpost_httptools_inline_s4.py +++ b/benchmarks/macro/http/apps/localpost_httptools_inline_s4.py @@ -4,7 +4,7 @@ import sys -from benchmarks.http.apps.localpost_httptools_inline import main +from benchmarks.macro.http.apps.localpost_httptools_inline import main if __name__ == "__main__": sys.argv += ["--selectors", "4"] diff --git a/benchmarks/http/apps/localpost_httptools_s4.py b/benchmarks/macro/http/apps/localpost_httptools_s4.py similarity index 64% rename from benchmarks/http/apps/localpost_httptools_s4.py rename to benchmarks/macro/http/apps/localpost_httptools_s4.py index 2d09813..d13e579 100644 --- a/benchmarks/http/apps/localpost_httptools_s4.py +++ b/benchmarks/macro/http/apps/localpost_httptools_s4.py @@ -1,6 +1,6 @@ """LocalPost httptools backend with ``selectors=4``. -Wrapper around :mod:`benchmarks.http.apps.localpost_httptools` that injects +Wrapper around :mod:`benchmarks.macro.http.apps.localpost_httptools` that injects ``--selectors 4`` so the runner can pick it up as a separate stack. """ @@ -8,7 +8,7 @@ import sys -from benchmarks.http.apps.localpost_httptools import main +from benchmarks.macro.http.apps.localpost_httptools import main if __name__ == "__main__": sys.argv += ["--selectors", "4"] diff --git a/benchmarks/http/apps/localpost_wsgi.py b/benchmarks/macro/http/apps/localpost_wsgi.py similarity index 85% rename from benchmarks/http/apps/localpost_wsgi.py rename to benchmarks/macro/http/apps/localpost_wsgi.py index b69425a..3f16017 100644 --- a/benchmarks/http/apps/localpost_wsgi.py +++ b/benchmarks/macro/http/apps/localpost_wsgi.py @@ -4,8 +4,8 @@ import sys -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._flask_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._flask_app import build_app from localpost import threadtools from localpost.hosting import run_app, service from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi diff --git a/benchmarks/http/apps/starlette_granian.py b/benchmarks/macro/http/apps/starlette_granian.py similarity index 80% rename from benchmarks/http/apps/starlette_granian.py rename to benchmarks/macro/http/apps/starlette_granian.py index a1c3c0a..28371bd 100644 --- a/benchmarks/http/apps/starlette_granian.py +++ b/benchmarks/macro/http/apps/starlette_granian.py @@ -7,8 +7,8 @@ from granian import Granian from granian.constants import Interfaces -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._starlette_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._starlette_app import build_app app = build_app() diff --git a/benchmarks/http/apps/starlette_uvicorn.py b/benchmarks/macro/http/apps/starlette_uvicorn.py similarity index 74% rename from benchmarks/http/apps/starlette_uvicorn.py rename to benchmarks/macro/http/apps/starlette_uvicorn.py index bde48c8..f1e65d4 100644 --- a/benchmarks/http/apps/starlette_uvicorn.py +++ b/benchmarks/macro/http/apps/starlette_uvicorn.py @@ -6,8 +6,8 @@ import uvicorn -from benchmarks.http.apps._cli import parse_port -from benchmarks.http.apps._starlette_app import build_app +from benchmarks.macro.http.apps._cli import parse_port +from benchmarks.macro.http.apps._starlette_app import build_app def main() -> int: diff --git a/benchmarks/http/runner.py b/benchmarks/macro/http/runner.py similarity index 74% rename from benchmarks/http/runner.py rename to benchmarks/macro/http/runner.py index 6a7f8d4..2718666 100644 --- a/benchmarks/http/runner.py +++ b/benchmarks/macro/http/runner.py @@ -1,7 +1,7 @@ """Macro HTTP benchmark runner — thin entry point. For each (python, stack, scenario) cell: - 1. Boot the stack as a subprocess (`` -m benchmarks.http.apps.``). + 1. Boot the stack as a subprocess (`` -m benchmarks.macro.http.apps.``). 2. Poll ``/ping`` until ready (or fail fast). 3. Run ``oha --json -z s -c ...``. 4. Parse JSON; record RPS + p50/p90/p99 + status histogram. @@ -13,7 +13,7 @@ one table per scenario. results///RESULTS.html — interactive view. -See :mod:`benchmarks._core.cli` for the CLI flag surface. +See :mod:`benchmarks.macro._core.cli` for the CLI flag surface. """ from __future__ import annotations @@ -21,9 +21,9 @@ import sys from pathlib import Path -from benchmarks._core.cli import entrypoint -from benchmarks.http.scenarios import SCENARIOS -from benchmarks.http.stacks import DIM_KEYS, GROUPS, STACKS +from benchmarks.macro._core.cli import entrypoint +from benchmarks.macro.http.scenarios import SCENARIOS +from benchmarks.macro.http.stacks import DIM_KEYS, GROUPS, STACKS REPO_ROOT = Path(__file__).resolve().parents[2] RESULTS_DIR = Path(__file__).parent / "results" @@ -34,7 +34,7 @@ def main() -> int: scenarios=SCENARIOS, stacks=STACKS, groups=GROUPS, - apps_pkg="benchmarks.http.apps", + apps_pkg="benchmarks.macro.http.apps", results_dir=RESULTS_DIR, title="HTTP benchmark", dim_keys=DIM_KEYS, diff --git a/benchmarks/http/scenarios.py b/benchmarks/macro/http/scenarios.py similarity index 96% rename from benchmarks/http/scenarios.py rename to benchmarks/macro/http/scenarios.py index b695b35..bade748 100644 --- a/benchmarks/http/scenarios.py +++ b/benchmarks/macro/http/scenarios.py @@ -8,7 +8,7 @@ * ``profile_update`` — ``POST /users/{user_id}/profile`` with JSON body → normalized user profile JSON. -Every app module under ``benchmarks/http/apps/`` implements these four. The +Every app module under ``benchmarks/macro/http/apps/`` implements these four. The runner uses ``SCENARIOS`` to know what to fire at each stack and how to verify the response shape. """ @@ -18,7 +18,7 @@ import json from typing import Any, Final -from benchmarks._core.types import Scenario +from benchmarks.macro._core.types import Scenario __all__ = [ "HELLO_PREFIX", diff --git a/benchmarks/http/stacks.py b/benchmarks/macro/http/stacks.py similarity index 94% rename from benchmarks/http/stacks.py rename to benchmarks/macro/http/stacks.py index b992c37..2aaebc4 100644 --- a/benchmarks/http/stacks.py +++ b/benchmarks/macro/http/stacks.py @@ -9,10 +9,10 @@ * annotate each cell in ``results.json`` so the HTML reporter can pivot. The ``name`` field doubles as the module name under -``benchmarks/http/apps/``, so spawning a stack stays a one-liner — no +``benchmarks/macro/http/apps/``, so spawning a stack stays a one-liner — no app-module changes needed. -See :mod:`benchmarks._core.filters` for the filter language. +See :mod:`benchmarks.macro._core.filters` for the filter language. """ from __future__ import annotations @@ -20,7 +20,7 @@ from collections.abc import Callable from typing import Final -from benchmarks._core.types import Stack +from benchmarks.macro._core.types import Stack # Ordered dim keys — drives HTML column ordering and filter dropdown order. DIM_KEYS: Final[tuple[str, ...]] = ("app", "backend", "selectors", "pool", "acceptor") diff --git a/benchmarks/openapi/README.md b/benchmarks/macro/openapi/README.md similarity index 97% rename from benchmarks/openapi/README.md rename to benchmarks/macro/openapi/README.md index 1340a6b..c420180 100644 --- a/benchmarks/openapi/README.md +++ b/benchmarks/macro/openapi/README.md @@ -4,7 +4,7 @@ Compares `localpost.openapi` against peer typed/OpenAPI Python web frameworks under one fixed workload, single-process, single-host. This is the **framework** bench. The sibling -[`benchmarks/http/`](../http/README.md) measures **server** overhead +[`benchmarks/macro/http/`](../http/README.md) measures **server** overhead with a thin Flask/Starlette app on top — different question, different suite. @@ -31,7 +31,7 @@ just bench-openapi --filter framework=localpost just bench-openapi --filter 'framework=fastapi,localpost' --filter schema=pydantic ``` -Output lands in `benchmarks/openapi/results///`: +Output lands in `benchmarks/macro/openapi/results///`: - `results.json` — raw cells (re-parseable). - `RESULTS.md` — markdown summary, one table per scenario. diff --git a/benchmarks/openapi/__init__.py b/benchmarks/macro/openapi/__init__.py similarity index 77% rename from benchmarks/openapi/__init__.py rename to benchmarks/macro/openapi/__init__.py index 3b66b87..4380ca6 100644 --- a/benchmarks/openapi/__init__.py +++ b/benchmarks/macro/openapi/__init__.py @@ -1,7 +1,7 @@ """OpenAPI framework macro benchmark suite. Compares ``localpost.openapi`` against peer typed/OpenAPI Python web -frameworks (flask-openapi3, FastAPI). Sibling of ``benchmarks/http/``, +frameworks (flask-openapi3, FastAPI). Sibling of ``benchmarks/macro/http/``, which measures HTTP server overhead. This suite measures *framework* overhead — typed signatures, schema validation, response serialization. """ diff --git a/benchmarks/openapi/apps/__init__.py b/benchmarks/macro/openapi/apps/__init__.py similarity index 100% rename from benchmarks/openapi/apps/__init__.py rename to benchmarks/macro/openapi/apps/__init__.py diff --git a/benchmarks/openapi/apps/_cli.py b/benchmarks/macro/openapi/apps/_cli.py similarity index 100% rename from benchmarks/openapi/apps/_cli.py rename to benchmarks/macro/openapi/apps/_cli.py diff --git a/benchmarks/openapi/apps/fastapi.py b/benchmarks/macro/openapi/apps/fastapi.py similarity index 97% rename from benchmarks/openapi/apps/fastapi.py rename to benchmarks/macro/openapi/apps/fastapi.py index 27d2426..7c4a82b 100644 --- a/benchmarks/openapi/apps/fastapi.py +++ b/benchmarks/macro/openapi/apps/fastapi.py @@ -16,7 +16,7 @@ from fastapi.responses import PlainTextResponse from pydantic import BaseModel -from benchmarks.openapi.apps._cli import parse_port +from benchmarks.macro.openapi.apps._cli import parse_port class Item(BaseModel): diff --git a/benchmarks/openapi/apps/flask_openapi.py b/benchmarks/macro/openapi/apps/flask_openapi.py similarity index 98% rename from benchmarks/openapi/apps/flask_openapi.py rename to benchmarks/macro/openapi/apps/flask_openapi.py index 545cde5..81bd9d5 100644 --- a/benchmarks/openapi/apps/flask_openapi.py +++ b/benchmarks/macro/openapi/apps/flask_openapi.py @@ -16,7 +16,7 @@ from gunicorn.app.base import BaseApplication from pydantic import BaseModel -from benchmarks.openapi.apps._cli import parse_port +from benchmarks.macro.openapi.apps._cli import parse_port class ItemPath(BaseModel): diff --git a/benchmarks/openapi/apps/localpost_openapi.py b/benchmarks/macro/openapi/apps/localpost_openapi.py similarity index 97% rename from benchmarks/openapi/apps/localpost_openapi.py rename to benchmarks/macro/openapi/apps/localpost_openapi.py index 4dfd1b0..8d7d68f 100644 --- a/benchmarks/openapi/apps/localpost_openapi.py +++ b/benchmarks/macro/openapi/apps/localpost_openapi.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from typing import Any -from benchmarks.openapi.apps._cli import parse_port +from benchmarks.macro.openapi.apps._cli import parse_port from localpost import hosting, threadtools from localpost.http import HTTPReqCtx, ServerConfig from localpost.openapi import ( diff --git a/benchmarks/openapi/apps/localpost_openapi_async.py b/benchmarks/macro/openapi/apps/localpost_openapi_async.py similarity index 98% rename from benchmarks/openapi/apps/localpost_openapi_async.py rename to benchmarks/macro/openapi/apps/localpost_openapi_async.py index 6e5c01e..b93de07 100644 --- a/benchmarks/openapi/apps/localpost_openapi_async.py +++ b/benchmarks/macro/openapi/apps/localpost_openapi_async.py @@ -20,7 +20,7 @@ import uvicorn -from benchmarks.openapi.apps._cli import parse_port +from benchmarks.macro.openapi.apps._cli import parse_port from localpost.openapi import ( AsyncApiOperation, AsyncHTTPReqCtx, diff --git a/benchmarks/openapi/apps/localpost_openapi_granian.py b/benchmarks/macro/openapi/apps/localpost_openapi_granian.py similarity index 98% rename from benchmarks/openapi/apps/localpost_openapi_granian.py rename to benchmarks/macro/openapi/apps/localpost_openapi_granian.py index 1e6d04b..60d1120 100644 --- a/benchmarks/openapi/apps/localpost_openapi_granian.py +++ b/benchmarks/macro/openapi/apps/localpost_openapi_granian.py @@ -21,7 +21,7 @@ from granian.constants import Interfaces from granian.server import Server -from benchmarks.openapi.apps._cli import parse_port +from benchmarks.macro.openapi.apps._cli import parse_port from localpost.openapi import ( AsyncApiOperation, AsyncHTTPReqCtx, diff --git a/benchmarks/openapi/runner.py b/benchmarks/macro/openapi/runner.py similarity index 64% rename from benchmarks/openapi/runner.py rename to benchmarks/macro/openapi/runner.py index d3b388f..0ce09ef 100644 --- a/benchmarks/openapi/runner.py +++ b/benchmarks/macro/openapi/runner.py @@ -1,10 +1,10 @@ """Macro OpenAPI-framework benchmark runner — thin entry point. -Drives the same oha pipeline as ``benchmarks/http/runner.py`` but against +Drives the same oha pipeline as ``benchmarks/macro/http/runner.py`` but against typed/OpenAPI frameworks (LocalPost, flask-openapi3, FastAPI) instead of bare HTTP servers. -See :mod:`benchmarks._core.cli` for the CLI flag surface. +See :mod:`benchmarks.macro._core.cli` for the CLI flag surface. """ from __future__ import annotations @@ -12,9 +12,9 @@ import sys from pathlib import Path -from benchmarks._core.cli import entrypoint -from benchmarks.openapi.scenarios import SCENARIOS -from benchmarks.openapi.stacks import DIM_KEYS, GROUPS, STACKS +from benchmarks.macro._core.cli import entrypoint +from benchmarks.macro.openapi.scenarios import SCENARIOS +from benchmarks.macro.openapi.stacks import DIM_KEYS, GROUPS, STACKS REPO_ROOT = Path(__file__).resolve().parents[2] RESULTS_DIR = Path(__file__).parent / "results" @@ -25,7 +25,7 @@ def main() -> int: scenarios=SCENARIOS, stacks=STACKS, groups=GROUPS, - apps_pkg="benchmarks.openapi.apps", + apps_pkg="benchmarks.macro.openapi.apps", results_dir=RESULTS_DIR, title="OpenAPI framework benchmark", dim_keys=DIM_KEYS, diff --git a/benchmarks/openapi/scenarios.py b/benchmarks/macro/openapi/scenarios.py similarity index 91% rename from benchmarks/openapi/scenarios.py rename to benchmarks/macro/openapi/scenarios.py index 1cafbed..dcbc2ba 100644 --- a/benchmarks/openapi/scenarios.py +++ b/benchmarks/macro/openapi/scenarios.py @@ -6,7 +6,7 @@ business logic. The body for ``body_roundtrip`` mirrors -``benchmarks/http/scenarios.py::_PROFILE_UPDATE_BODY`` so the http and +``benchmarks/macro/http/scenarios.py::_PROFILE_UPDATE_BODY`` so the http and openapi suites can be cross-referenced. """ @@ -14,7 +14,7 @@ from typing import Final -from benchmarks._core.types import Scenario +from benchmarks.macro._core.types import Scenario __all__ = [ "INVALID_PROFILE_BODY", @@ -27,7 +27,7 @@ PING_BODY: Final = b"pong" -# Same payload as ``benchmarks/http/scenarios.py``: each app must accept +# Same payload as ``benchmarks/macro/http/scenarios.py``: each app must accept # untrimmed strings, mixed-case email, duplicated/whitespaced tags, and # return a normalized profile (trimmed, lower-cased, deduped, sorted, # version+1). diff --git a/benchmarks/openapi/stacks.py b/benchmarks/macro/openapi/stacks.py similarity index 97% rename from benchmarks/openapi/stacks.py rename to benchmarks/macro/openapi/stacks.py index 413faf2..4fe7cf5 100644 --- a/benchmarks/openapi/stacks.py +++ b/benchmarks/macro/openapi/stacks.py @@ -23,7 +23,7 @@ from collections.abc import Callable from typing import Final -from benchmarks._core.types import Stack +from benchmarks.macro._core.types import Stack DIM_KEYS: Final[tuple[str, ...]] = ("framework", "server", "schema") diff --git a/docs/design/server-backends.md b/docs/design/server-backends.md index 905ac55..5a2cfaa 100644 --- a/docs/design/server-backends.md +++ b/docs/design/server-backends.md @@ -48,4 +48,4 @@ The full rationale, including alternatives we rejected, is in if/when WebSockets come back on the roadmap. For perf context, see -[`benchmarks/http/PERF_FINDINGS.md`](https://github.com/alexeyshockov/localpost.py/blob/main/benchmarks/http/PERF_FINDINGS.md). +[`benchmarks/macro/http/PERF_FINDINGS.md`](https://github.com/alexeyshockov/localpost.py/blob/main/benchmarks/macro/http/PERF_FINDINGS.md). diff --git a/justfile b/justfile index edb8824..7716d0b 100755 --- a/justfile +++ b/justfile @@ -49,17 +49,17 @@ integration-tests: [doc("Set up all venvs in the bench matrix (.venv-bench/)")] bench-deps: - uv run python -m benchmarks._setup + uv run python -m benchmarks.macro._setup [doc("Refresh lock and re-sync all bench-matrix venvs")] bench-deps-upgrade: uv lock --upgrade - uv run python -m benchmarks._setup + uv run python -m benchmarks.macro._setup [doc("Run macro HTTP benchmarks (oha-driven, requires `brew install oha`)")] bench-http *args: uv run --group bench --group dev-http \ - python -m benchmarks.http.runner {{ args }} + python -m benchmarks.macro.http.runner {{ args }} [doc("Quick PR-time HTTP bench: representative subset of stacks")] bench-http-quick *args: @@ -76,7 +76,7 @@ bench-http-localpost *args: [doc("Run macro OpenAPI-framework benchmarks (LocalPost vs FastAPI vs flask-openapi3)")] bench-openapi *args: uv run --group bench --group bench-openapi \ - python -m benchmarks.openapi.runner {{ args }} + python -m benchmarks.macro.openapi.runner {{ args }} [doc("Run micro-benchmarks (router, URI template) via pytest-benchmark")] bench-micro *args: diff --git a/localpost/http/_service.py b/localpost/http/_service.py index bab7cc5..8647cc0 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -116,7 +116,7 @@ def http_server( flat (GIL holds during parser callbacks + dispatch + channel handoff; macOS ``SO_REUSEPORT`` doesn't load-balance like Linux). Useful on Linux (kernel-level distribution) and on - free-threaded builds; see ``benchmarks/http/PERF_FINDINGS.md`` + free-threaded builds; see ``benchmarks/macro/http/PERF_FINDINGS.md`` Phase 7. With ``acceptor=True``, ``selectors`` is the number of diff --git a/tests/benchmarks/http_runner.py b/tests/benchmarks/http_runner.py index c90085b..8bde599 100644 --- a/tests/benchmarks/http_runner.py +++ b/tests/benchmarks/http_runner.py @@ -1,8 +1,8 @@ -"""Tests for the HTTP benchmark runner internals.""" +"""Tests for the shared macro benchmark runner internals (oha parser).""" from __future__ import annotations -from benchmarks.http.runner import _parse_oha +from benchmarks.macro._core.runner import parse_oha def test_parse_oha_normal(): @@ -11,12 +11,12 @@ def test_parse_oha_normal(): "latencyPercentiles": {"p50": 0.001, "p90": 0.002, "p99": 0.005}, "statusCodeDistribution": {"200": 100, "500": 2}, } - parsed = _parse_oha(raw) + parsed = parse_oha(raw, expected_status=200) assert parsed["rps"] == 1234.5 assert parsed["p50_ms"] == 1.0 assert parsed["p99_ms"] == 5.0 assert parsed["total_requests"] == 102 - assert parsed["status_2xx"] == 100 + assert parsed["status_expected"] == 100 assert parsed["status_other"] == 2 @@ -28,7 +28,7 @@ def test_parse_oha_null_percentiles(): "latencyPercentiles": {"p50": None, "p90": None, "p99": None}, "statusCodeDistribution": {}, } - parsed = _parse_oha(raw) + parsed = parse_oha(raw, expected_status=200) assert parsed == { "rps": 0.0, "p50_ms": 0.0, @@ -36,13 +36,13 @@ def test_parse_oha_null_percentiles(): "p99_ms": 0.0, "total_requests": 0, "success_rate": 0.0, - "status_2xx": 0, + "status_expected": 0, "status_other": 0, } def test_parse_oha_missing_keys(): - parsed = _parse_oha({}) + parsed = parse_oha({}, expected_status=200) assert parsed["rps"] == 0.0 assert parsed["p50_ms"] == 0.0 assert parsed["total_requests"] == 0 diff --git a/tests/benchmarks/http_stacks.py b/tests/benchmarks/http_stacks.py index 6649a67..71c607b 100644 --- a/tests/benchmarks/http_stacks.py +++ b/tests/benchmarks/http_stacks.py @@ -1,10 +1,17 @@ -"""Tests for the HTTP benchmark stack registry + filter parser.""" +"""Tests for the HTTP stack registry + the shared filter language.""" from __future__ import annotations import pytest -from benchmarks.http.stacks import GROUPS, STACKS, parse_filters, select_stacks +from benchmarks.macro._core.filters import collect_dim_keys, parse_filters, select_stacks +from benchmarks.macro.http.stacks import GROUPS, STACKS + +_VALID_KEYS = frozenset({"name", "tags"} | collect_dim_keys(STACKS)) + + +def _select(*, names=None, group=None, filters=()): + return select_stacks(STACKS, GROUPS, names=names, group=group, filters=filters) def test_stacks_registry_unique_names(): @@ -13,11 +20,11 @@ def test_stacks_registry_unique_names(): def test_select_all_by_default(): - assert select_stacks() == STACKS + assert _select() == STACKS def test_select_by_app(): - selected = select_stacks(filters=["app=flask"]) + selected = _select(filters=["app=flask"]) assert {s.name for s in selected} == { "localpost_flask", "flask_cheroot", @@ -27,7 +34,7 @@ def test_select_by_app(): def test_select_by_backend_glob_and_selectors(): - selected = select_stacks(filters=["backend=lp-*", "selectors=1"]) + selected = _select(filters=["backend=lp-*", "selectors=1"]) names = {s.name for s in selected} assert names == { "localpost_h11", @@ -39,60 +46,60 @@ def test_select_by_backend_glob_and_selectors(): def test_select_negation(): - selected = select_stacks(filters=["app!=starlette"]) - assert all(s.app != "starlette" for s in selected) + selected = _select(filters=["app!=starlette"]) + assert all(s.dims["app"] != "starlette" for s in selected) assert len(selected) == len(STACKS) - 2 def test_select_multi_value_or(): - selected = select_stacks(filters=["app=wsgi,starlette"]) - assert {s.app for s in selected} == {"wsgi", "starlette"} + selected = _select(filters=["app=wsgi,starlette"]) + assert {s.dims["app"] for s in selected} == {"wsgi", "starlette"} def test_select_pool_off(): - selected = select_stacks(filters=["pool=false"]) - assert all(not s.pool for s in selected) + selected = _select(filters=["pool=false"]) + assert all(s.dims["pool"] == "false" for s in selected) assert len(selected) == 3 # the three inline httptools variants def test_group_localpost(): - selected = select_stacks(group="localpost") - assert all(s.backend.startswith("lp-") for s in selected) + selected = _select(group="localpost") + assert all(s.dims["backend"].startswith("lp-") for s in selected) def test_group_quick_is_small(): - selected = select_stacks(group="quick") + selected = _select(group="quick") assert 2 <= len(selected) <= 6 def test_group_plus_filter_ands(): - selected = select_stacks(group="localpost", filters=["selectors=1"]) - assert all(s.backend.startswith("lp-") and s.selectors == 1 for s in selected) + selected = _select(group="localpost", filters=["selectors=1"]) + assert all(s.dims["backend"].startswith("lp-") and s.dims["selectors"] == "1" for s in selected) def test_explicit_names_bypass_filters(): - selected = select_stacks(names=["flask_gunicorn"], filters=["app=starlette"]) + selected = _select(names=["flask_gunicorn"], filters=["app=starlette"]) assert [s.name for s in selected] == ["flask_gunicorn"] def test_unknown_filter_key_raises(): with pytest.raises(ValueError, match="unknown filter key"): - parse_filters(["foo=bar"]) + parse_filters(["foo=bar"], _VALID_KEYS) def test_unknown_name_raises(): with pytest.raises(ValueError, match="unknown stack name"): - select_stacks(names=["does_not_exist"]) + _select(names=["does_not_exist"]) def test_unknown_group_raises(): with pytest.raises(ValueError, match="unknown group"): - select_stacks(group="not-a-group") + _select(group="not-a-group") def test_filter_without_equals_raises(): with pytest.raises(ValueError, match="must be 'key="): - parse_filters(["just-a-word"]) + parse_filters(["just-a-word"], _VALID_KEYS) def test_groups_have_predicates(): From 3c05480cc94d7565b8f2e64b650b70bb88917155 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:35:16 +0400 Subject: [PATCH 238/286] chore(release): add just release / release-post recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `just release X.Y.Z` bumps pyproject.toml, commits, pushes main, and opens a draft GitHub release pre-filled with the matching CHANGELOG section as its body. The draft is the manual-review checkpoint — clicking Publish in the GitHub UI fires the release:published event that triggers .github/workflows/pypi-publish.yaml. `just release-post NEXT` opens the next dev cycle by bumping pyproject.toml to NEXT.dev0 and committing/pushing. Both recipes guard against a dirty working tree and a non-main checkout; release also aborts if the matching CHANGELOG section is missing, so release notes are required before tagging. Co-Authored-By: Claude Opus 4.7 (1M context) --- justfile | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/justfile b/justfile index 7716d0b..713ea17 100755 --- a/justfile +++ b/justfile @@ -100,3 +100,52 @@ docs-build: [doc("Find unused code with vulture (config in pyproject.toml). Pass extra args to override.")] deadcode *args: vulture {{ args }} + +# --------------------------------------------------------------------------- +# Release automation +# +# Workflow: +# just release 0.6.0 # bump to 0.6.0, commit, push, open draft GH release +# -> triggers .github/workflows/pypi-publish.yaml +# just release-post 0.7.0 # bump to 0.7.0.dev0, commit, push +# --------------------------------------------------------------------------- + +[doc("Cut a release: bump version, commit, push, open draft GH release with CHANGELOG notes")] +release version: + #!/usr/bin/env bash + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + echo "working tree dirty; commit or stash first" >&2 + exit 1 + fi + if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then + echo "not on main; checkout main first" >&2 + exit 1 + fi + notes=$(awk -v v="{{ version }}" '$0 ~ "^## \\[" v "\\]" {flag=1; next} /^## \[/ {flag=0} flag' CHANGELOG.md) + if [ -z "$notes" ]; then + echo "no [{{ version }}] section in CHANGELOG.md" >&2 + exit 1 + fi + uv version {{ version }} + git commit -am "release: {{ version }}" + git push origin main + gh release create "v{{ version }}" --target main --draft --title "v{{ version }}" --notes "$notes" + echo + echo "Draft release created. Review on GitHub and click Publish to trigger the PyPI workflow." + +[doc("Open the next dev cycle: bump pyproject to .dev0, commit, push")] +release-post next: + #!/usr/bin/env bash + set -euo pipefail + if [ -n "$(git status --porcelain)" ]; then + echo "working tree dirty; commit or stash first" >&2 + exit 1 + fi + if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then + echo "not on main; checkout main first" >&2 + exit 1 + fi + uv version {{ next }}.dev0 + git commit -am "chore: bump version to {{ next }}.dev0" + git push origin main From ad7d1a82dc692df417eb3ced25eda76a123ea9d2 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:35:22 +0400 Subject: [PATCH 239/286] docs: add CONTRIBUTING.md, trim README contribute section Land a full CONTRIBUTING.md at the repo root (where GitHub auto-surfaces it on the Contribute tab and on issue/PR forms) covering: issue reporting, dev setup, project structure, code style, Conventional Commits, the PR checklist, tests, docs, and the maintainer release process (just release / release-post + the pypi-publish workflow). Trim README's Contributing section to a one-line pointer plus the quickstart commands. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 218 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 7 +- 2 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..309592d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,218 @@ +# Contributing to localpost + +Thanks for your interest in contributing! This document covers everything you need to file a good +issue, send a pull request, or cut a release. + +## Reporting issues + +- **Bugs** — open a [GitHub issue](https://github.com/alexeyshockov/localpost.py/issues/new) with a + minimal reproducer, the Python and `localpost` version, and the AnyIO backend (`asyncio` or + `trio`). +- **Feature requests / design discussion** — open an issue first so we can agree on scope before + you write code. +- **Questions** — open a [GitHub + Discussion](https://github.com/alexeyshockov/localpost.py/discussions) or an issue tagged + `question`. + +## Development setup + +Requirements: Python 3.12+ and [`uv`](https://docs.astral.sh/uv/) (which manages Python itself, +the virtualenv, and dependencies). [`just`](https://github.com/casey/just) is used to wrap the +common workflows. + +```bash +git clone https://github.com/alexeyshockov/localpost.py.git +cd localpost.py +just deps # uv sync --all-groups --all-extras +just unit-tests # confirm the toolchain is healthy +``` + +The full list of recipes is in `justfile` (run `just --list`). The most common ones: + +| Command | What it does | +| --- | --- | +| `just deps` | Sync the venv with every group + extra. | +| `just deps-upgrade` | `uv lock --upgrade` and re-sync. | +| `just format` | `ruff check --fix` + `ruff format` on `localpost/`. | +| `just types` | `ty check localpost`. | +| `just type-coverage` | `basedpyright --verifytypes` on the public API. | +| `just unit-tests` | `pytest -m "not integration"` with coverage. | +| `just integration-tests` | `pytest -m "integration" -n auto` (testcontainers). | +| `just tests` | Both suites. | +| `just check FILE` | Ruff + ty on a single file — handy in tight edit loops. | +| `just docs-serve` | Live-reloading docs site (Zensical). | + +After editing any file under `localpost/`, run `just check ` to catch lint and type +regressions early. + +## Project structure + +``` +localpost/ +├── hosting/ # Service lifecycle + orchestration (start/stop/signals) +├── scheduler/ # In-process composable task scheduler +├── http/ # Lightweight sync HTTP/1.1 server (h11 / httptools) +├── di/ # .NET-style scoped IoC container +├── openapi/ # Type-driven HTTP framework with OpenAPI 3.2 generation +└── threadtools/ # Sync primitives (TaskGroup, Channel, ...) +``` + +Files prefixed with `_` are internal; the public API is re-exported from each module's +`__init__.py`. Don't import from a `_`-module outside the module it lives in. + +Each module has a README (`localpost//README.md`) — read those first when working in a +specific area. Cross-cutting design notes live in `docs/`. + +## Code style and conventions + +- **AnyIO everywhere** — structured concurrency; no raw `asyncio`. Tested on both asyncio and Trio + backends (`anyio_mode = "auto"` in `pyproject.toml`). +- **Public API is fully typed** and verified with `ty check` and `basedpyright --verifytypes`. +- **Internal code** uses types where they aid readability — not strictly required. +- **Errors as values** — `Result[T]` (Ok / failure) flows through scheduler tasks and arg + resolvers. +- **Sync + async duality** — the scheduler accepts both; sync callables are offloaded to threads + via `anyio.to_thread`. +- **Ruff** with `line-length = 120` and `pyupgrade.keep-runtime-typing = true`. Wrap comments and + docstrings to 120 chars too. +- **Docstrings**: Google convention. + +Run `just format` before committing. + +## Commits + +We use [Conventional Commits](https://www.conventionalcommits.org/) with a module scope. Looking +at recent history is the fastest way to internalise the pattern: + +``` +feat(scheduler): add cron trigger +fix(threadtools): dedup body and task exceptions in TaskGroup.__exit__ +refactor(http): hoist async ctx Protocol + ASGI bridge to localpost.http +docs(scheduler): clarify Task.subscribe buffer semantics +test(http): add wsgi roundtrip cases +chore(deps): bump anyio to 4.12 +``` + +Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`. Use `!` after the +scope (e.g. `feat(http)!: ...`) for breaking changes — and add a CHANGELOG entry under +**Changed (BREAKING)**. + +Other rules: + +- **Never skip hooks on commit** (`--no-verify`). If a hook fails, fix the underlying issue. +- **Prefer a new commit over `--amend`** unless you're correcting the very last commit you just + made and haven't pushed. + +## Pull requests + +Before opening: + +- [ ] `just format` is clean +- [ ] `just types` passes +- [ ] `just type-coverage` passes (public API stays fully typed) +- [ ] `just unit-tests` passes +- [ ] You added or updated tests for the change +- [ ] You added a CHANGELOG entry under `## [Unreleased]` (Added / Changed / Fixed / Removed, + with breaking changes called out) +- [ ] Module README and any relevant `docs/` notes are updated + +The `main` branch is the only stable branch and will never be force-pushed. Any other branch, +including release branches, may be rebased or force-pushed at any time. + +## Tests + +- **Unit tests** (`just unit-tests`) — fast, hermetic, default suite. Marker: `not integration`. +- **Integration tests** (`just integration-tests`) — `@pytest.mark.integration`, run in parallel + via `pytest-xdist` (`-n auto`), use testcontainers. + +Run a single test with: + +```bash +pytest tests/path/to/test.py::test_function -v +``` + +Async tests pick up `anyio_mode = "auto"` from `pyproject.toml` — no fixtures needed. + +## Documentation + +- Module READMEs (`localpost//README.md`) are the canonical end-user docs and are surfaced + on the docs site verbatim. +- Cross-cutting design notes live under `docs/design/`. +- Architectural decisions live under `docs/adr/`. +- The site is built with [Zensical](https://zensical.com/); preview locally with `just docs-serve`. + +## Release process (maintainers) + +The release pipeline is automated via two `just` recipes plus a GitHub Actions workflow. + +### One-time setup + +- `gh` CLI authenticated against this repo (`gh auth status`). +- Push access to `main`. +- The PyPI project is configured for [trusted + publishing](https://docs.pypi.org/trusted-publishers/) from + `.github/workflows/pypi-publish.yaml` (no API tokens stored anywhere). + +### Cutting a release + +1. **Land the release notes.** On a feature branch, replace `## [Unreleased]` in `CHANGELOG.md` + with a `## [X.Y.Z] - YYYY-MM-DD` heading and finalise the entries. Open a PR, get it merged + to `main`. + +2. **Run the release recipe** from a clean `main`: + + ```bash + just release 0.6.0 + ``` + + This: + - bumps `pyproject.toml` (`uv version 0.6.0`), + - commits `release: 0.6.0`, + - pushes `main`, + - creates a **draft** GitHub release `v0.6.0` pre-filled with the `## [0.6.0]` CHANGELOG + section as its body. + + Guards: aborts if the working tree is dirty, you're not on `main`, or the matching CHANGELOG + section is missing. + +3. **Review and publish the release** on GitHub. Edit notes if needed and click **Publish + release**. This: + - creates the `v0.6.0` git tag at the release commit, + - fires the `release: published` event, which triggers + [`pypi-publish.yaml`](.github/workflows/pypi-publish.yaml), + - the workflow runs `uv build` then `uv publish` with PyPI trusted publishing. + +4. **Open the next dev cycle:** + + ```bash + just release-post 0.7.0 + ``` + + This bumps `pyproject.toml` to `0.7.0.dev0` and commits/pushes `chore: bump version to + 0.7.0.dev0`. Use the next planned target — `0.6.1` for a patch line, `0.7.0` for the next + minor, etc. + +### Versioning convention + +[PEP 440](https://peps.python.org/pep-0440/) with [SemVer](https://semver.org/) on top: + +- Released versions: `X.Y.Z`. +- In-development on `main`: `X.Y.Z.dev0` where `X.Y.Z` is the **target** of the next release. + PEP 440 sorts `0.6.0.dev0 < 0.6.0 < 0.7.0.dev0`, so the suffix marks "heading toward this + version, not there yet." +- Breaking changes bump the minor pre-1.0 (per SemVer's pre-release allowance) and the major + post-1.0. +- Patch releases (`X.Y.Z+1`) are reserved for bug fixes that don't change the public API. + +### Hotfix releases + +Same flow with the patch component bumped — e.g. `just release 0.6.1` from `main` once the fix is +merged, then `just release-post 0.6.2` (or `0.7.0` if the next planned release is a minor). + +### Yanking a release + +If a release is broken, yank it on PyPI (don't delete — yanking preserves the version number while +discouraging installs). PyPI does not currently expose yank from the command line; do it through +the project's [release management +page](https://pypi.org/manage/project/localpost/releases/) and include a short reason +("broken: see #NNN"). Then cut a follow-up release with the fix as soon as possible. diff --git a/README.md b/README.md index cbd99a2..21a9432 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,10 @@ MIT — see [LICENSE](LICENSE). ## Contributing -The `main` branch is the only stable branch and will never be force-pushed. Any -other branch (including release branches) may be rebased or force-pushed at any -time. +See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, code style, commit conventions, the +pull-request checklist, and (for maintainers) the release process. -Dev setup: +Quick start: ```bash just deps # uv sync --all-groups --all-extras From 137523a87f9a3cde45a64ada25ce15a6d00d5339 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:53:49 +0400 Subject: [PATCH 240/286] ci: rewrite CI on uv with Python matrix, lint+types gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the pdm-based sonar/test job with a uv-driven pipeline: * `lint-types` job — `ruff check` + `ruff format --check` + `ty check` + `basedpyright --verifytypes`. Public-API typing was previously enforced only locally via `just types` / `just type-coverage`; CI now guards it on every PR. * `tests` matrix — Python 3.12 / 3.13 / 3.14, unit tests only (`-m "not integration"`). Integration tests no longer run on every PR; factor those out into a separate workflow when the suite grows. * `sonarcloud` — depends on tests; consumes coverage-xml artifact uploaded by the 3.13 cell instead of re-running pytest. * `tests-free-threaded` — kept as-is (already on uv, well structured). Workflow-level hygiene: `permissions: contents: read` (least privilege), `concurrency` group cancelling in-flight PR runs, drop the stale `master` branch ref. Backend dimension intentionally left out of the matrix — handled by the project's own backend-aware tests rather than duplicating the matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 70 +++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cab25e5..5f81255 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,27 +2,75 @@ name: CI on: push: - branches: [ master, main ] + branches: [main] pull_request: - branches: [ master, main ] + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + lint-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version-file: ".python-version" + - run: uv sync --all-groups --all-extras + - name: ruff check + run: uv run ruff check localpost + - name: ruff format --check + run: uv run ruff format --check localpost + - name: ty + run: uv run ty check localpost + - name: type coverage (basedpyright --verifytypes) + run: uv run basedpyright --pythonpath $(which python) --verifytypes localpost localpost/* + + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Sonar needs full history + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - run: uv python install ${{ matrix.python }} + - run: uv sync --python ${{ matrix.python }} --all-groups --all-extras + - name: pytest (unit only) + timeout-minutes: 5 + run: > + uv run --python ${{ matrix.python }} + pytest -m "not integration" -v + --cov-report=term --cov-report=xml --cov-branch --cov + - name: Upload coverage (3.13) + if: matrix.python == '3.13' + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + retention-days: 1 + sonarcloud: + needs: tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - # Disable shallow clone (required for SonarQube to work properly) fetch-depth: 0 - - uses: pdm-project/setup-pdm@v4 + - uses: actions/download-artifact@v4 with: - python-version-file: ".python-version" - cache: true - - name: pdm install - run: pdm install --frozen-lockfile --no-editable - - name: pytest - timeout-minutes: 5 - run: pdm run pytest --cov-report=term --cov-report=xml --cov-branch --cov -v + name: coverage-xml - uses: SonarSource/sonarqube-scan-action@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 41d52e68c9a86f54ca3ba4228a193d783a071cd8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 14:53:49 +0400 Subject: [PATCH 241/286] ci(release): generate PEP 740 attestations via pypa/gh-action-pypi-publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv publish (0.11) uploads attestations when they're already in dist/ but does not generate them. To get PEP 740 PyPI Publish Attestations on releases today, swap the publish step for the official PyPA action which generates Sigstore-signed attestations and uploads them alongside the wheel/sdist. Trusted publishing is preserved (id-token + attestations write permissions). uv build still produces the dist/ — only the upload step changes. Track astral-sh/uv#15618; once uv learns to generate attestations we can collapse this back to a single `uv publish` step. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pypi-publish.yaml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index 7adf1f0..a9a9ccb 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -4,12 +4,17 @@ on: release: types: [ published ] +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest permissions: - # Required for PyPI trusted publishing + contents: read + # Required for PyPI trusted publishing + Sigstore-signed PEP 740 attestations id-token: write + attestations: write steps: - uses: actions/checkout@v4 with: @@ -19,4 +24,11 @@ jobs: enable-cache: true python-version-file: ".python-version" - run: uv build - - run: uv publish --trusted-publishing always + # uv publish (as of 0.11) only uploads pre-existing attestations; it does + # not generate them. Use the official PyPA action, which generates PEP 740 + # PyPI Publish Attestations via Sigstore and uploads them alongside the + # distributions. Track astral-sh/uv#15618 — once landed we can swap back + # to a single `uv publish` step. + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true From ea48d7717a50b0fab1d2351e8e71b1b2b4d2c7c0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 19:52:49 +0400 Subject: [PATCH 242/286] ci(coverage): tighten coverage config + 85% floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [tool.coverage.run] * source = ["localpost"] — measure the package explicitly so the number doesn't drift with whatever happens to be imported under tests. * branch = true — moved from --cov-branch on the CLI; one place to set. * omit — skip _debug.py and http/__main__.py (dev-only entry points). [tool.coverage.report] * fail_under = 85 — locks the current 86.90% with a small buffer; raise as the suite grows. * exclude_also patterns for TYPE_CHECKING blocks, @overload / @abstractmethod stubs, NotImplementedError raisers, ellipsis bodies, __main__ guards, and an explicit `pragma: no cover` opt-out. Stops these counting as uncovered. [tool.coverage.paths] * Map matrix-cell paths back to a canonical layout so combined coverage from multiple Python versions / runners merges cleanly when we wire that up. sonar-project.properties * sonar.python.version: 3.12 -> "3.12, 3.13, 3.14" — Sonar parses 3.13/3.14 syntax (PEP 695, etc.) correctly instead of flagging it. Drop --cov-branch from justfile recipes and the new CI tests job since branch=true lives in the config now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 2 +- justfile | 4 ++-- pyproject.toml | 39 ++++++++++++++++++++++++++++++++++++++- sonar-project.properties | 2 +- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5f81255..80db8ca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,7 +52,7 @@ jobs: run: > uv run --python ${{ matrix.python }} pytest -m "not integration" -v - --cov-report=term --cov-report=xml --cov-branch --cov + --cov-report=term --cov-report=xml --cov - name: Upload coverage (3.13) if: matrix.python == '3.13' uses: actions/upload-artifact@v4 diff --git a/justfile b/justfile index 713ea17..baffb25 100755 --- a/justfile +++ b/justfile @@ -39,10 +39,10 @@ check file: -ty check {{ file }} tests: - pytest --cov-report=term --cov-report=xml --cov-branch --cov -v + pytest --cov-report=term --cov-report=xml --cov -v unit-tests: - pytest -m "not integration" --cov-report=term --cov-branch --cov -v + pytest -m "not integration" --cov-report=term --cov -v integration-tests: pytest -m "integration" -n auto -v diff --git a/pyproject.toml b/pyproject.toml index 4a1d09c..a69ea81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,7 +164,44 @@ docs = [ ] [tool.coverage.run] -omit = ["tests/*"] +source = ["localpost"] +branch = true +# Internal-only entry points and dev/debug shims that don't merit coverage tracking. +omit = [ + "localpost/_debug.py", + "localpost/http/__main__.py", +] + +[tool.coverage.report] +# Anything below this fails the report. Set a few points under the current +# total so day-to-day churn doesn't break CI; raise it as the suite grows. +fail_under = 85 +show_missing = true +skip_covered = false +precision = 2 +exclude_also = [ + # Type-checking-only branches don't run at test time. + "if TYPE_CHECKING:", + "if typing\\.TYPE_CHECKING:", + # Stub / Protocol / abstract method bodies. + "@overload", + "@(typing\\.)?overload", + "@(abc\\.)?abstractmethod", + "raise NotImplementedError", + "^\\s*\\.\\.\\.\\s*$", + # Defensive guards that aren't reachable from tests. + "if __name__ == .__main__.:", + # Explicit opt-out marker. + "pragma: no cover", +] + +[tool.coverage.paths] +# Map matrix-cell paths back to a single canonical layout so combined coverage +# from multiple Python versions / runners merges cleanly. +source = [ + "localpost/", + "*/localpost/", +] [tool.vulture] # Dead-code finder. Run via ``just deadcode``. diff --git a/sonar-project.properties b/sonar-project.properties index 1c8a1e9..a35466c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.projectKey=alexeyshockov_localpost.py sonar.organization=alexeyshockov # Optional -sonar.python.version=3.12 +sonar.python.version=3.12, 3.13, 3.14 sonar.sources=localpost sonar.tests=tests sonar.python.coverage.reportPaths=coverage.xml From 8d4e3ff690fa1242ae609d5bc6a171516e7cec67 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 00:21:41 +0400 Subject: [PATCH 243/286] feat(hosting): add Click command service adapter Wraps a click.Command (or Group) as a hosted service so a CLI can be composed with sibling services under a single host. Runs with standalone_mode=False and maps Click outcomes (ClickException / UsageError, Abort, ctx.exit, raw SystemExit) onto the lifetime's exit_code; sync callback executes in a worker thread, so current_service() is reachable for cooperative shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/host/click_cli.py | 24 ++++++++ localpost/hosting/services/click.py | 56 +++++++++++++++++ pyproject.toml | 4 ++ tests/hosting/services/__init__.py | 0 tests/hosting/services/click.py | 96 +++++++++++++++++++++++++++++ uv.lock | 8 ++- 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100755 examples/host/click_cli.py create mode 100644 localpost/hosting/services/click.py create mode 100644 tests/hosting/services/__init__.py create mode 100644 tests/hosting/services/click.py diff --git a/examples/host/click_cli.py b/examples/host/click_cli.py new file mode 100755 index 0000000..cc17133 --- /dev/null +++ b/examples/host/click_cli.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +"""Run a Click command as a hosted service. + +For a one-shot CLI you can just call ``hello()`` directly. Wrapping it as a +hosted service earns its keep when the command is composed with sibling +services (a background worker, a metrics endpoint, ...) under one host. +""" + +import sys + +import click + +from localpost.hosting import run_app +from localpost.hosting.services.click import click_cmd + + +@click.command() +@click.option("--name", default="world") +def hello(name: str) -> None: + click.echo(f"Hello, {name}!") + + +if __name__ == "__main__": + sys.exit(run_app(click_cmd(hello))) diff --git a/localpost/hosting/services/click.py b/localpost/hosting/services/click.py new file mode 100644 index 0000000..72c0486 --- /dev/null +++ b/localpost/hosting/services/click.py @@ -0,0 +1,56 @@ +"""Run a Click command (or group) as a hosted service. + +The command runs once with ``standalone_mode=False`` so its outcome maps onto +the hosting layer's exit code instead of calling ``sys.exit`` directly. Click's +own exception types are translated; anything else propagates and surfaces as a +non-zero exit code. + +The Click callback executes synchronously in a worker thread (the ``@service`` +decorator handles the sync-to-async offload). Inside the callback, +``localpost.hosting.current_service()`` returns this service's lifetime view — +use ``shutting_down`` for a non-blocking poll, or ``wait_shutting_down()`` to +block from sync code. ``await ...wait()`` will not work here because the +callback is sync. + +When the command returns, only this service stops; sibling services composed +via ``run_app`` keep running until they finish or shut down on their own. +""" + +from collections.abc import Sequence + +import click + +from localpost.hosting._host import ServiceLifetime, service + + +@service +def click_cmd( + cmd: click.Command, + *, + args: Sequence[str] | None = None, + prog_name: str | None = None, +): + def run(lt: ServiceLifetime) -> None: + lt.set_started() + try: + # In non-standalone mode, ``ctx.exit(code)`` causes ``main()`` to + # *return* the integer exit code instead of raising — mirror Click's + # own ``sys.exit(rv if isinstance(rv, int) else 0)``. + rv = cmd.main( + args=list(args) if args is not None else None, + prog_name=prog_name, + standalone_mode=False, + ) + except click.ClickException as e: + e.show() + lt.exit_code = e.exit_code + except click.exceptions.Abort: + lt.exit_code = 1 + except SystemExit as e: + code = e.code + lt.exit_code = code if isinstance(code, int) else (1 if code else 0) + else: + if isinstance(rv, int): + lt.exit_code = rv + + return run diff --git a/pyproject.toml b/pyproject.toml index a69ea81..c98ce47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,9 @@ rsgi = [ # via ``localpost.http.to_rsgi`` or ``localpost.hosting.HostRSGIApp``. "granian ~=2.7", ] +click = [ + "click ~=8.0", +] openapi = [ # "localpost[http]", "msgspec ~=0.19", @@ -97,6 +100,7 @@ dev-hosting-services = [ "uvicorn ~=0.30", "hypercorn ~=0.17", "grpcio ~=1.68", + "click ~=8.0", ] dev-http = [ "flask ~=3.1", diff --git a/tests/hosting/services/__init__.py b/tests/hosting/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hosting/services/click.py b/tests/hosting/services/click.py new file mode 100644 index 0000000..e42c062 --- /dev/null +++ b/tests/hosting/services/click.py @@ -0,0 +1,96 @@ +import click +import pytest + +from localpost.hosting import current_service, serve +from localpost.hosting.services.click import click_cmd + +pytestmark = pytest.mark.anyio + + +async def test_success(): + ran: list[bool] = [] + + @click.command() + def ok(): + ran.append(True) + + async with serve(click_cmd(ok, args=[])) as lt: + await lt.stopped + + assert ran == [True] + assert lt.exit_code == 0 + + +async def test_click_exception_maps_to_exit_code(): + @click.command() + def boom(): + raise click.ClickException("boom") + + async with serve(click_cmd(boom, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 1 + + +async def test_usage_error_maps_to_exit_code_2(): + @click.command() + def bad(): + raise click.UsageError("bad") + + async with serve(click_cmd(bad, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 2 + + +async def test_abort_maps_to_exit_code_1(): + @click.command() + def aborted(): + raise click.exceptions.Abort() + + async with serve(click_cmd(aborted, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 1 + + +async def test_ctx_exit_propagates_exit_code(): + # In non-standalone mode ``ctx.exit(N)`` makes ``cmd.main()`` *return* N. + @click.command() + @click.pass_context + def exit42(ctx: click.Context): + ctx.exit(42) + + async with serve(click_cmd(exit42, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 42 + + +async def test_raw_sys_exit_propagates(): + # ``sys.exit`` from inside the command body bypasses Click and surfaces as + # ``SystemExit`` — the wrapper still maps it onto the lifetime's exit code. + import sys + + @click.command() + def hard_exit(): + sys.exit(7) + + async with serve(click_cmd(hard_exit, args=[])) as lt: + await lt.stopped + + assert lt.exit_code == 7 + + +async def test_current_service_visible_inside_callback(): + captured: list[object] = [] + + @click.command() + def grab(): + captured.append(current_service()) + + async with serve(click_cmd(grab, args=[])) as lt: + await lt.stopped + + assert len(captured) == 1 + assert lt.exit_code == 0 diff --git a/uv.lock b/uv.lock index 27525bd..ab594d5 100644 --- a/uv.lock +++ b/uv.lock @@ -768,6 +768,9 @@ dependencies = [ ] [package.optional-dependencies] +click = [ + { name = "click" }, +] cron = [ { name = "croniter" }, ] @@ -817,6 +820,7 @@ dev = [ { name = "vulture" }, ] dev-hosting-services = [ + { name = "click" }, { name = "grpcio" }, { name = "hypercorn" }, { name = "uvicorn" }, @@ -865,6 +869,7 @@ requires-dist = [ { name = "attrs", marker = "extra == 'openapi-attrs'", specifier = ">=23" }, { name = "brotli", marker = "extra == 'http-compress'", specifier = "~=1.1" }, { name = "cattrs", marker = "extra == 'openapi-attrs'", specifier = ">=24" }, + { name = "click", marker = "extra == 'click'", specifier = "~=8.0" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=2.0,<4.0" }, { name = "granian", marker = "extra == 'rsgi'", specifier = "~=2.7" }, { name = "h11", marker = "extra == 'http'", specifier = "~=0.16" }, @@ -873,7 +878,7 @@ requires-dist = [ { name = "msgspec", marker = "extra == 'openapi'", specifier = "~=0.19" }, { name = "pytimeparse2", marker = "extra == 'scheduler'", specifier = "~=1.6" }, ] -provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "rsgi", "openapi", "openapi-attrs"] +provides-extras = ["cron", "scheduler", "http", "http-fast", "http-compress", "rsgi", "click", "openapi", "openapi-attrs"] [package.metadata.requires-dev] bench = [ @@ -897,6 +902,7 @@ dev = [ { name = "vulture", specifier = "~=2.14" }, ] dev-hosting-services = [ + { name = "click", specifier = "~=8.0" }, { name = "grpcio", specifier = "~=1.68" }, { name = "hypercorn", specifier = "~=0.17" }, { name = "uvicorn", specifier = "~=0.30" }, From 6746edecd80987bfd9e9d04a02dd7e19fa32d65c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 21:13:43 +0000 Subject: [PATCH 244/286] chore: cleanup channels --- localpost/threadtools/_channel.py | 130 ++++++++++++++---------------- 1 file changed, 61 insertions(+), 69 deletions(-) diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py index 3dc1570..7fbffad 100644 --- a/localpost/threadtools/_channel.py +++ b/localpost/threadtools/_channel.py @@ -4,7 +4,7 @@ import threading from collections import deque from collections.abc import Callable, Iterator -from typing import Protocol, Self, final, override +from typing import Self, final from anyio import ( ClosedResourceError, @@ -42,63 +42,6 @@ def create( return tx, rx -class BaseReceiveChannel[T](Protocol): - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def __iter__(self) -> Iterator[T]: - while True: - try: - yield self.get() - except EndOfStream: - break - - def clone(self) -> ReceiveChannel[T]: ... - - # Raises: - # EndOfStream - if the sender has been closed cleanly, and no more objects are coming. This is not an error - # condition. - # ClosedResourceError - if you previously closed this ReceiveChannel object. - # BrokenResourceError - if something has gone wrong, and the channel is broken. - def get(self) -> T: ... - - def close(self): ... - - -class BaseSendChannel[T](Protocol): - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def clone(self) -> SendChannel[T]: ... - - # Raises: - # BrokenResourceError - if something has gone wrong, and the channel is broken. For example, you may get this if - # the receiver has already been closed. - # ClosedResourceError - if you previously closed this SendChannel object, or if another task closes it while - # put() is running. - def put(self, item: T, /) -> None: ... - - def close(self) -> None: ... - - @final @dc.dataclass(slots=True) class ChannelState[T]: @@ -166,14 +109,28 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: @final -# TODO dataclass + repr -class SendChannel[T](BaseSendChannel[T]): +class SendChannel[T]: def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False - @override - def clone(self): + def __repr__(self) -> str: + state = self._state + return f"SendChannel(closed={self._closed}, capacity={state.capacity}, qsize={len(state.buffer)})" + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def clone(self) -> SendChannel[T]: with self._state as state: if self._closed: raise ClosedResourceError("send channel is already closed") @@ -193,8 +150,16 @@ def put_nowait(self, item: T, /) -> None: state.pending_handoffs += 1 state.not_empty.notify() - @override def put(self, item: T, /) -> None: + """Put ``item`` into the channel, blocking until there is space (or, for a rendezvous + channel, until a receiver consumes it). + + Raises: + BrokenResourceError: Something has gone wrong and the channel is broken — for + example, the receiver has already been closed. + ClosedResourceError: This send channel was previously closed, or another task + closed it while ``put`` was running. + """ state = self._state my_target = 0 # Phase 1: wait for space in the buffer. @@ -240,22 +205,50 @@ def close(self) -> None: @final -# TODO dataclass + repr -class ReceiveChannel[T](BaseReceiveChannel[T]): +class ReceiveChannel[T]: def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False - @override - def clone(self): + def __repr__(self) -> str: + state = self._state + return f"ReceiveChannel(closed={self._closed}, capacity={state.capacity}, qsize={len(state.buffer)})" + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def __iter__(self) -> Iterator[T]: + while True: + try: + yield self.get() + except EndOfStream: + break + + def clone(self) -> ReceiveChannel[T]: with self._state as state: if self._closed: raise ClosedResourceError("receive channel is already closed") state.open_receive_channels += 1 return ReceiveChannel(state) - @override def get(self) -> T: + """Get the next item from the channel, blocking until one is available. + + Raises: + EndOfStream: The sender has been closed cleanly and no more items are + coming. This is not an error condition. + ClosedResourceError: This receive channel was previously closed. + BrokenResourceError: Something has gone wrong and the channel is broken. + """ # Cancellation is observed inside ``state.__enter__`` (cancellable lock # acquire) and ``state.not_empty.wait`` (cancellable condition). state = self._state @@ -278,7 +271,6 @@ def get(self) -> T: state.waiting_receivers -= 1 raise ClosedResourceError("receive channel has been closed") - @override def close(self) -> None: with self._state as state: if not self._closed: From 93df87a105e00dfa2fffa519d1978149960fde0c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 21:13:58 +0000 Subject: [PATCH 245/286] chore(docs): localpost.dev --- docs/CNAME | 1 + mkdocs.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..ffe5910 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +localpost.dev diff --git a/mkdocs.yml b/mkdocs.yml index fbd34d0..3fcd3a7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,7 +2,7 @@ site_name: LocalPost site_description: >- A small async Python framework for long-running processes: service hosting, in-process task scheduling, and a lightweight HTTP server. -site_url: https://alexeyshockov.github.io/localpost.py/ +site_url: https://localpost.dev/ repo_url: https://github.com/alexeyshockov/localpost.py repo_name: alexeyshockov/localpost.py edit_uri: edit/main/docs/ From 42fb8610fa0c502950c34d8f9af089666e844dfd Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Fri, 8 May 2026 21:37:32 +0000 Subject: [PATCH 246/286] WIP on task group rework --- localpost/hosting/_host.py | 24 ++- localpost/http/_pool.py | 32 ++-- localpost/threadtools/__init__.py | 6 +- localpost/threadtools/_pool.py | 165 +++++++++++++++++++++ localpost/threadtools/_task_group.py | 195 +++++++++++++------------ pyproject.toml | 1 + tests/threadtools/conftest.py | 50 +++++++ tests/threadtools/thread_pool.py | 118 +++++++++++++++ tests/threadtools/thread_task_group.py | 158 ++++++++++---------- 9 files changed, 554 insertions(+), 195 deletions(-) create mode 100644 localpost/threadtools/_pool.py create mode 100644 tests/threadtools/conftest.py create mode 100644 tests/threadtools/thread_pool.py diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index d3b9aa4..0de1863 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -10,7 +10,6 @@ AbstractContextManager, AsyncExitStack, asynccontextmanager, - nullcontext, ) from dataclasses import dataclass, field from dataclasses import dataclass as define @@ -32,6 +31,7 @@ unwrap_exc, wait_all, ) +from localpost.threadtools import thread_pool logger = logging.getLogger("localpost.hosting") @@ -436,7 +436,10 @@ async def wait_stopped(): await child_lt.stopped wait_tg.cancel_scope.cancel() - async with BlockingPortal() as portal: + # Open an ambient ``thread_pool`` alongside the portal so service + # bodies and anything they spawn can use ``threadtools.TaskGroup`` + # without a per-callsite wrapper. + async with BlockingPortal() as portal, thread_pool(): child_lt = ServiceLifetime(portal) app_token = _app_lt.set(child_lt) try: @@ -487,15 +490,24 @@ async def wait_stopped(): async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: - async with nullcontext(parent.portal) if parent else BlockingPortal() as portal: + if parent is not None: + # Nested run inherits the parent's portal and ambient thread pool + # (the latter via contextvar — no extra setup needed here). + lt = ServiceLifetime(parent.portal) + await _run(svc_f, lt) + return lt.exit_code + # Root run opens a portal and an ambient thread pool so any service + # body — and anything it transitively spawns — runs under an active + # ``thread_pool()`` context. Without this, ``threadtools.TaskGroup()`` + # would raise. + async with BlockingPortal() as portal, thread_pool(): lt = ServiceLifetime(portal) - app_token = _app_lt.set(lt) if parent is None else None + app_token = _app_lt.set(lt) try: await _run(svc_f, lt) return lt.exit_code finally: - if app_token is not None: - _app_lt.reset(app_token) + _app_lt.reset(app_token) def _run_many(*svcs: ServiceF) -> ServiceF: diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 23b6370..7559715 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -17,7 +17,7 @@ import logging import threading from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager, suppress +from contextlib import AsyncExitStack, asynccontextmanager, suppress from typing import cast from anyio import to_thread @@ -132,18 +132,32 @@ async def _pool_context() -> AsyncGenerator[_Pool]: all pools), but each ``_pool_context`` owns its own group so its teardown drains exactly the requests it dispatched. + Reuses the ambient :class:`localpost.threadtools.ThreadPool` when + one is set (the normal case under :func:`localpost.hosting.run_app` + / :func:`localpost.hosting.serve`); otherwise opens a private one + for the duration of this context so :func:`thread_pool_handler` can + be used standalone. + On exit, signals in-flight handlers via the cancel event and waits for the group to drain. Drain is offloaded to a thread so the surrounding event loop stays responsive. """ - shutdown_event = threading.Event() - tg = threadtools.TaskGroup(name="http-pool") - tg.__enter__() - try: - yield _Pool(tg, shutdown_event) - finally: - shutdown_event.set() - await to_thread.run_sync(tg.__exit__, None, None, None) + # Imported lazily to avoid importing threadtools internals at + # module load time. + from localpost.threadtools import thread_pool + from localpost.threadtools._task_group import _current_pool + + async with AsyncExitStack() as stack: + if _current_pool.get(None) is None: + await stack.enter_async_context(thread_pool()) + shutdown_event = threading.Event() + tg = threadtools.TaskGroup(name="http-pool") + tg.__enter__() + try: + yield _Pool(tg, shutdown_event) + finally: + shutdown_event.set() + await to_thread.run_sync(tg.__exit__, None, None, None) def _emit_body_too_large(ctx: _NativeReqCtx) -> None: diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index c5c8a82..ce77d84 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,10 +1,12 @@ from ._channel import Channel, ReceiveChannel, SendChannel -from ._task_group import TaskGroup, warmup +from ._pool import ThreadPool, thread_pool +from ._task_group import TaskGroup __all__ = [ "Channel", "ReceiveChannel", "SendChannel", "TaskGroup", - "warmup", + "ThreadPool", + "thread_pool", ] diff --git a/localpost/threadtools/_pool.py b/localpost/threadtools/_pool.py new file mode 100644 index 0000000..7c06487 --- /dev/null +++ b/localpost/threadtools/_pool.py @@ -0,0 +1,165 @@ +"""Process-wide(-ish) thread pool tying our worker dispatch to AnyIO's +``to_thread`` machinery. + +The pool owns three things: + +* a :class:`anyio.from_thread.BlockingPortal` so sync code (worker threads, + test harnesses) can call back into the event loop; +* an ``anyio.TaskGroup`` of *host tasks* — one per live worker. Each host + task awaits ``anyio.to_thread.run_sync(worker._run, abandon_on_cancel=False, + limiter=...)``, which is what populates ``threadlocals.current_token`` + (asyncio) / ``PARENT_TASK_DATA`` (Trio) on the worker thread. That's + what makes :func:`anyio.from_thread.check_cancelled` work inside user + tasks running on our pool; +* a per-pool LIFO ``idle`` deque of parked workers, plus a set of all + live workers used to broadcast ``shutdown`` on teardown. + +The pool is exposed via :func:`thread_pool` (an async context manager) +and is typically opened inside :func:`localpost.hosting.run_app` so it's +ambient throughout the host's lifetime. :class:`TaskGroup` reads the +ambient pool from a contextvar. +""" + +from __future__ import annotations + +import functools +import math +from collections import deque +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, final + +from anyio import CapacityLimiter, create_task_group, to_thread +from anyio.from_thread import BlockingPortal + +from ._task_group import Worker, _current_pool + +if TYPE_CHECKING: + from anyio.abc import TaskGroup as AioTaskGroup + + +@final +class ThreadPool: + """A pool of worker threads, hosted by AnyIO's ``to_thread`` machinery. + + Construct via :func:`thread_pool` rather than directly. + """ + + __slots__ = ("_host_tg", "_idle", "_limiter", "_portal", "_workers") + + def __init__( + self, + *, + portal: BlockingPortal, + host_tg: AioTaskGroup, + limiter: CapacityLimiter, + ) -> None: + self._portal = portal + self._host_tg = host_tg + self._limiter = limiter + self._idle: deque[Worker] = deque() + # Tracks every worker spawned by this pool so teardown can wake + # parked ones. ``deque`` is documented thread-safe for + # ``append`` / ``remove``; we use a list-of-strong-refs and rely + # on ``Worker.shutdown`` being idempotent. + self._workers: list[Worker] = [] + + @property + def portal(self) -> BlockingPortal: + """The pool's blocking portal — used by tasks to call back into + the host event loop.""" + return self._portal + + @property + def idle(self) -> deque[Worker]: + """Per-pool LIFO deque of idle workers. Public for inspection + only; mutating it from outside is undefined.""" + return self._idle + + async def warmup(self, n: int, /) -> None: + """Pre-spawn ``n`` workers and park them in the idle deque. + + Useful at startup to amortise OS thread-creation cost so the + first burst of work doesn't pay it. + + Raises: + ValueError: ``n`` is negative. + """ + if n < 0: + raise ValueError("count must be >= 0") + for _ in range(n): + w = self._spawn_worker() + self._idle.append(w) + + def _spawn_worker(self) -> Worker: + """Create a worker and dispatch its ``_run`` to the host task + group. Must be called from the event-loop thread.""" + w = Worker(self._idle) + self._workers.append(w) + # ``functools.partial`` is needed because ``start_soon`` doesn't + # forward kwargs. + self._host_tg.start_soon( + functools.partial( + to_thread.run_sync, + w._run, + abandon_on_cancel=False, + limiter=self._limiter, + ) + ) + return w + + def _spawn_worker_blocking(self) -> Worker: + """Sync entry point for ``TaskGroup.create_task``. Hops to the + event loop via the portal to do the actual spawn.""" + return self._portal.call(self._spawn_worker) + + def _shutdown_all_workers(self) -> None: + """Signal every live worker to exit at its next opportunity.""" + for w in self._workers: + w.shutdown() + + +@asynccontextmanager +async def thread_pool( + *, limiter: CapacityLimiter | None = None +) -> AsyncGenerator[ThreadPool]: + """Open a :class:`ThreadPool`, set it as the ambient pool for + :class:`TaskGroup` callers, and tear it down on exit. + + The pool runs on the calling event loop: the + :class:`~anyio.from_thread.BlockingPortal` and the host task group + that owns worker host tasks are both opened inside this context. + + Args: + limiter: Caps how many worker threads can run concurrently from + this pool's POV. Defaults to ``CapacityLimiter(math.inf)`` + (no cap). Note that AnyIO's own + :func:`anyio.to_thread.run_sync` machinery underlies each + worker, but the limiter here is dedicated to this pool. + + Tasks running on workers spawned by this pool can call + :func:`anyio.from_thread.check_cancelled` to observe cancellation of + the surrounding scope. On teardown, idle workers are signalled to + exit; in-flight tasks that don't poll are not interrupted (we wait + for them to return). + """ + if limiter is None: + limiter = CapacityLimiter(math.inf) + async with BlockingPortal() as portal, create_task_group() as host_tg: + pool = ThreadPool(portal=portal, host_tg=host_tg, limiter=limiter) + token = _current_pool.set(pool) + try: + yield pool + finally: + _current_pool.reset(token) + # Wake idle workers parked in ``_cv.wait`` so their ``_run`` + # returns promptly (otherwise they'd sit on the IDLE_TIMEOUT + # for up to 60 s before the host task group could drain). + pool._shutdown_all_workers() + # Cancel host_tg so any task currently calling + # ``check_cancelled`` from inside a worker raises. With + # ``abandon_on_cancel=False`` the host tasks still wait for + # ``_run`` to return — workers exit either because the + # in-flight task completed, raised on cancel, or the + # ``_shutdown`` flag was observed. + host_tg.cancel_scope.cancel() diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index f27c3e4..c6c782e 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -9,14 +9,18 @@ from concurrent.futures import Future from typing import TYPE_CHECKING, Any, Self, final, overload -from .._utils import is_async_callable - if TYPE_CHECKING: - from anyio.from_thread import BlockingPortal + from ._pool import ThreadPool IDLE_TIMEOUT: float = 60.0 """Seconds an idle worker waits for new work before self-exiting.""" +_current_pool: contextvars.ContextVar[ThreadPool] = contextvars.ContextVar( + "localpost.threadtools.current_pool" +) +"""Ambient :class:`ThreadPool` for the current async context. Set by +:func:`thread_pool`; read by :class:`TaskGroup` on construction.""" + @dc.dataclass(slots=True) class Task: @@ -35,17 +39,12 @@ def run(self) -> None: result = self.context.run(self.fn, *self.args, **self.kwargs) if inspect.iscoroutine(result): # The user submitted an async callable; dispatch the - # coroutine to the event loop via the portal. The worker - # thread blocks on ``portal.call`` until the coroutine - # completes — same lifetime model as the sync path. - portal = self.group._aio_portal - if portal is None: - result.close() - raise RuntimeError( # noqa: TRY301 - "TaskGroup received a coroutine but was not given an aio_portal" - ) + # coroutine to the event loop via the pool's portal. The + # worker thread blocks on ``portal.call`` until the + # coroutine completes — same lifetime model as the sync + # path. coro = result - result = portal.call(lambda: coro) + result = self.group._pool.portal.call(lambda: coro) except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything self.future.set_exception(exc) self.group._record_error(exc) @@ -57,43 +56,60 @@ def run(self) -> None: @final class Worker: - """Idle-tracked worker thread with a per-worker inbox. - - Lifecycle: ``alive`` until the thread self-exits after ``IDLE_TIMEOUT`` - seconds with no work, after which the worker becomes a tombstone in the - global ``idle`` deque. The dispatcher detects tombstones via - :meth:`submit` returning ``False`` and pops the next worker / spawns a - fresh one. + """Idle-tracked worker with a per-worker inbox. + + Lifecycle: ``alive`` until the worker self-exits — either after + ``IDLE_TIMEOUT`` seconds with no work, or when the owning pool calls + :meth:`shutdown`. After self-exit the worker becomes a tombstone in + the pool's ``idle`` deque. The dispatcher detects tombstones via + :meth:`submit` returning ``False`` and pops the next worker / spawns + a fresh one. + + The worker thread itself is provided by the pool via + ``anyio.to_thread.run_sync(worker._run, abandon_on_cancel=False)`` — + AnyIO sets up the thread-local state needed for + :func:`anyio.from_thread.check_cancelled` to work inside user tasks. """ - __slots__ = ("_alive", "_cv", "_inbox") + __slots__ = ("_alive", "_cv", "_inbox", "_pool_idle", "_shutdown") - def __init__(self) -> None: + def __init__(self, pool_idle: deque[Worker]) -> None: self._inbox: deque[Task] = deque() self._cv = threading.Condition(threading.Lock()) # Set under ``_cv`` before lock release in ``_run``; that ordering is - # what makes ``submit`` race-free vs ``Thread.is_alive()``, which can - # still report ``True`` after ``_run`` has released the lock but - # before CPython's bootstrap marks the thread stopped. + # what makes ``submit`` race-free vs the host ``to_thread.run_sync`` + # call returning, which can otherwise still report the worker alive + # after ``_run`` has released the lock. self._alive = True - threading.Thread(target=self._run, daemon=True, name="localpost-worker").start() + self._shutdown = False + # Deque the worker re-enters after each completed task. Per-pool, so + # workers spawned by pool A are not picked up by tasks under pool B. + self._pool_idle = pool_idle def submit(self, task: Task) -> bool: - """Hand off a task. Returns ``False`` if the worker has self-exited.""" + """Hand off a task. Returns ``False`` if the worker has self-exited + or has been asked to shut down.""" with self._cv: - if not self._alive: + if not self._alive or self._shutdown: return False self._inbox.append(task) self._cv.notify() return True + def shutdown(self) -> None: + """Signal the worker to exit at its next opportunity. Wakes a + worker parked in ``_cv.wait``; in-flight tasks finish first.""" + with self._cv: + self._shutdown = True + self._cv.notify() + def _run(self) -> None: while True: with self._cv: - # Re-check inbox after each wait — covers both spurious wakeups - # and the lost-notify race where a submit lands between the - # outer ``inbox.popleft`` and the wait re-acquiring the lock. while not self._inbox: + if self._shutdown: + self._alive = False + return if not self._cv.wait(timeout=IDLE_TIMEOUT): # Timed out. One more inbox check under the lock to # close the timeout-vs-notify race. @@ -103,43 +119,28 @@ def _run(self) -> None: break task = self._inbox.popleft() task.run() - idle.append(self) - - -idle: deque[Worker] = deque() -"""Global LIFO stack of idle workers, shared across all ``TaskGroup``s.""" - - -def warmup(count: int, /) -> None: - """Pre-spawn ``count`` worker threads and park them in the global idle pool. - - Useful at process startup to amortise thread-creation cost so the first - bursts of work don't pay it. Pre-warmed workers are indistinguishable - from organically spawned ones — they self-exit on the same idle timeout - if unused. - - One-shot: ``warmup(8)`` always spawns 8 fresh workers, regardless of - how many idle workers already exist. Calling it again from a long-idle - process re-warms. - - Raises: - ValueError: ``count`` is negative. - """ - if count < 0: - raise ValueError("count must be >= 0") - for _ in range(count): - idle.append(Worker()) + if self._shutdown: + with self._cv: + self._alive = False + return + self._pool_idle.append(self) @final class TaskGroup: """Trio-style task group running sync callables on a shared thread pool. - Tasks submitted via :meth:`start_soon` run on a process-wide pool of - worker threads. Workers are spawned on demand, reused across all - ``TaskGroup`` instances, and self-exit after 60 s of idleness. There - is no concurrency cap — ``start_soon`` always succeeds (modulo OS - thread limits). + Tasks submitted via :meth:`start_soon` run on workers borrowed from + the ambient :class:`localpost.threadtools.ThreadPool`. Construction + requires an active ``thread_pool()`` context — typically provided by + :func:`localpost.hosting.run_app`. Without one, ``__init__`` raises + :class:`RuntimeError`. + + Workers are spawned on demand, reused across all ``TaskGroup`` + instances under the same pool, and self-exit after + :data:`IDLE_TIMEOUT` seconds of idleness. There is no concurrency + cap from this class (the pool may impose one via its + :class:`anyio.CapacityLimiter`). Lifetime: sync context manager. On exit, blocks until every task started inside the ``with`` block has finished, then re-raises any @@ -149,13 +150,14 @@ class TaskGroup: Example:: - with TaskGroup() as tg: - tg.start_soon(do_work, arg) # fire-and-forget - fut = tg.create_task(other_work) # observe via Future - # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. + async with thread_pool(): + with TaskGroup() as tg: + tg.start_soon(do_work, arg) # fire-and-forget + fut = tg.create_task(other_work) # observe via Future + # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. - Both :meth:`start_soon` and :meth:`create_task` are callable from any - thread, including from inside a task running on the same group + Both :meth:`start_soon` and :meth:`create_task` are callable from + any thread, including from inside a task running on the same group (recursive spawn). ``start_soon`` is fire-and-forget; ``create_task`` returns a :class:`concurrent.futures.Future` that captures the task's result or exception. Either way, task exceptions are surfaced via the @@ -168,23 +170,29 @@ class TaskGroup: confined to its copy — same semantics as :func:`asyncio.to_thread` and Trio / AnyIO task spawn. - If ``aio_portal`` is provided, both spawn methods also accept async - callables: the coroutine is dispatched to the portal's event loop - via :meth:`anyio.from_thread.BlockingPortal.call`, run from inside - the worker thread (the worker blocks until the coroutine returns). - Submitting an async callable without a portal raises ``RuntimeError``. + Async callables are accepted by both spawn methods: the coroutine is + dispatched to the pool's portal via + :meth:`anyio.from_thread.BlockingPortal.call`, run from inside the + worker thread (the worker blocks until the coroutine returns). + + Cancellation: because workers are spawned via + ``anyio.to_thread.run_sync`` under the pool, user code can call + :func:`anyio.from_thread.check_cancelled` to observe cancellation of + the pool's host scope. Tasks that don't poll won't be interrupted. """ - __slots__ = ("_aio_portal", "_closed", "_cv", "_errors", "_lock", "_name", "_pending") + __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending", "_pool") - def __init__( - self, - *, - name: str | None = None, - aio_portal: BlockingPortal | None = None, - ) -> None: + def __init__(self, *, name: str | None = None) -> None: + pool = _current_pool.get(None) + if pool is None: + raise RuntimeError( + "No active thread_pool() context. Wrap your code in " + "`async with thread_pool():` or run inside " + "`localpost.hosting.run_app()`." + ) self._name = name - self._aio_portal = aio_portal + self._pool = pool self._lock = threading.Lock() self._cv = threading.Condition(self._lock) self._pending = 0 @@ -253,17 +261,15 @@ def create_task( ) -> Future[Any]: """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future. - ``fn`` may be sync or async. Async callables require ``aio_portal``; - the worker invokes ``portal.call`` to await the coroutine on the - event loop. + ``fn`` may be sync or async. Async callables are dispatched to + the pool's portal; the worker invokes ``portal.call`` to await + the coroutine on the event loop. The returned future is observation-only. ``Future.cancel()`` only succeeds while the task is still queued; it cannot interrupt a running task. Reading ``.exception()`` does not suppress the ``ExceptionGroup`` raised at ``__exit__``. """ - if is_async_callable(fn) and self._aio_portal is None: - raise RuntimeError("TaskGroup got an async callable but the group has no aio_portal") with self._lock: if self._closed: raise RuntimeError("TaskGroup is closed") @@ -272,13 +278,15 @@ def create_task( # Snapshot the caller's context now (matches Trio / AnyIO / asyncio # ``to_thread`` semantics); each spawn captures independently. task = Task(fut, fn, args, kwargs, self, contextvars.copy_context()) + pool_idle = self._pool._idle while True: try: - w = idle.pop() + w = pool_idle.pop() except IndexError: # No idle worker — spawn a fresh one. ``submit`` on a fresh # worker is guaranteed to succeed (``_alive=True``). - Worker().submit(task) + w = self._pool._spawn_worker_blocking() + w.submit(task) return fut if w.submit(task): return fut @@ -291,10 +299,9 @@ def _task_done(self) -> None: self._cv.notify_all() def _record_error(self, exc: BaseException) -> None: - # No lock needed: ``list.append`` is atomic in CPython (GIL or - # free-threaded per-list mutex). Memory visibility to ``__exit__`` - # is established by ``_task_done``'s acquire of ``_cv``, which always - # runs after ``_record_error`` in the same task. + # No lock needed: ``deque.append`` is documented thread-safe (vs + # ``list.append`` which is only atomic by CPython implementation + # accident). Memory visibility to ``__exit__`` is established by + # ``_task_done``'s acquire of ``_cv``, which always runs after + # ``_record_error`` in the same task. self._errors.append(exc) - - diff --git a/pyproject.toml b/pyproject.toml index c98ce47..edd6370 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -324,4 +324,5 @@ python_files = [ ] markers = [ "integration: Consumers (SQS, Kafka,..) integration tests", + "no_ambient_pool: opt out of the autouse threadtools ambient pool fixture", ] diff --git a/tests/threadtools/conftest.py b/tests/threadtools/conftest.py new file mode 100644 index 0000000..46ec498 --- /dev/null +++ b/tests/threadtools/conftest.py @@ -0,0 +1,50 @@ +"""Shared fixtures for ``localpost.threadtools`` tests. + +The new design makes ``thread_pool()`` mandatory for ``TaskGroup``. These +fixtures spin up a per-test pool via :func:`anyio.from_thread.start_blocking_portal` +so the existing sync test bodies don't need to be rewritten as async. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from anyio.from_thread import BlockingPortal, start_blocking_portal + +from localpost.threadtools import ThreadPool, thread_pool +from localpost.threadtools._task_group import _current_pool + + +@pytest.fixture +def portal() -> Iterator[BlockingPortal]: + """A blocking portal backing the ``pool`` fixture. Tests can use it + directly when they need to call into async code from sync tests.""" + with start_blocking_portal() as p: + yield p + + +@pytest.fixture(autouse=True) +def pool(request: pytest.FixtureRequest, portal: BlockingPortal) -> Iterator[ThreadPool | None]: + """An ambient ``thread_pool`` for every test in this directory. + Autouse so existing sync test bodies don't need to ask for it + explicitly. Tests that *do* want introspection (``pool.idle``) can + request it by name. + + The pool itself is opened on the portal's event loop, but the + ``_current_pool`` contextvar is per-context — we explicitly mirror + the binding into the test thread's context so sync ``TaskGroup()`` + constructions find the pool. + + Tests that manage their own pool lifecycle (typically async tests + that use ``async with thread_pool():``) opt out via + ``@pytest.mark.no_ambient_pool``.""" + if request.node.get_closest_marker("no_ambient_pool"): + yield None + return + with portal.wrap_async_context_manager(thread_pool()) as p: + token = _current_pool.set(p) + try: + yield p + finally: + _current_pool.reset(token) diff --git a/tests/threadtools/thread_pool.py b/tests/threadtools/thread_pool.py new file mode 100644 index 0000000..ec8a56f --- /dev/null +++ b/tests/threadtools/thread_pool.py @@ -0,0 +1,118 @@ +"""Tests for ``localpost.threadtools.thread_pool`` and cross-backend +``from_thread.check_cancelled`` support inside worker threads. + +These tests explicitly run on both asyncio and trio. The point of the +pool's design is that workers are spawned via +``anyio.to_thread.run_sync`` so AnyIO populates the thread-local state +needed for ``from_thread.check_cancelled`` to work — and that should be +backend-agnostic. +""" + +from __future__ import annotations + +import threading +import time + +import anyio +import anyio.from_thread +import pytest + +from localpost.threadtools import TaskGroup, thread_pool +from localpost.threadtools._task_group import _current_pool + + +# Both backends, on every test in this module. Each test manages its +# own pool lifecycle (``async with thread_pool():``), so opt out of the +# autouse ambient pool from ``conftest.py``. +pytestmark = [ + pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]), + pytest.mark.no_ambient_pool, +] + + +async def test_check_cancelled_raises_in_worker_after_outer_cancel(anyio_backend): + """A worker task polling ``from_thread.check_cancelled`` observes + cancellation when an outer scope wrapping ``thread_pool()`` cancels. + + This is the cross-backend behaviour the pool exists to provide: + workers are spawned via ``to_thread.run_sync(abandon_on_cancel=False)``, + which sets up the threadlocals that AnyIO's ``check_cancelled`` + reads. Cancelling any ancestor scope of the pool's host task group + propagates to all in-flight worker tasks. + """ + saw_cancel = threading.Event() + runner_done = threading.Event() + runner_exc: list[BaseException] = [] + + def slow_task(): + try: + for _ in range(500): + anyio.from_thread.check_cancelled() + time.sleep(0.01) + except BaseException: + saw_cancel.set() + raise + + def runner(pool): + # ``threading.Thread`` doesn't propagate contextvars; set the + # pool explicitly so ``TaskGroup()`` here finds it. + _current_pool.set(pool) + try: + with TaskGroup() as tg: + tg.start_soon(slow_task) + except BaseException as e: # noqa: BLE001 — capturing for assertion + runner_exc.append(e) + finally: + runner_done.set() + + # Wrap ``thread_pool`` in an outer cancellable scope so we can + # cancel it without conflating teardown semantics with cancellation + # propagation. + with anyio.CancelScope() as scope: + async with thread_pool() as pool: + runner_thread = threading.Thread(target=runner, args=(pool,), daemon=True) + runner_thread.start() + # Let the worker get scheduled and start polling. + await anyio.sleep(0.1) + scope.cancel() + # ``thread_pool.__aexit__`` will run as the ``async with`` + # unwinds under cancellation; in-flight ``check_cancelled`` + # calls raise. + + runner_thread.join(timeout=5) + assert runner_done.is_set(), "runner thread did not terminate" + assert saw_cancel.is_set(), "worker task did not observe cancellation" + assert runner_exc, "TaskGroup did not surface an exception" + assert isinstance(runner_exc[0], BaseExceptionGroup) + + +async def test_warmup_populates_idle(anyio_backend): + """``await pool.warmup(N)`` spawns N workers and parks them in + ``pool.idle`` on both backends.""" + async with thread_pool() as pool: + await pool.warmup(3) + assert len(pool.idle) == 3 + assert all(w._alive for w in pool.idle) + + +async def test_pool_teardown_drains_idle_workers(anyio_backend): + """Idle workers parked in ``_cv.wait`` must exit promptly when the + pool is torn down — otherwise teardown blocks for the full + ``IDLE_TIMEOUT`` (60 s).""" + started = time.monotonic() + async with thread_pool() as pool: + await pool.warmup(2) + elapsed = time.monotonic() - started + # Generous bound: shutdown should take well under a second; the + # 60 s ``IDLE_TIMEOUT`` would catastrophically blow this. + assert elapsed < 5.0, f"pool teardown took {elapsed:.1f}s — idle workers not woken" + + +async def test_taskgroup_outside_pool_raises(anyio_backend): + """The error message should point users at ``thread_pool`` / + ``run_app`` rather than failing silently or with a confusing + AttributeError.""" + # Make sure no ambient pool from a prior test leaks in. + assert _current_pool.get(None) is None + with pytest.raises(RuntimeError, match="thread_pool"): + TaskGroup() diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index 0a6958d..ea962e2 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -1,4 +1,9 @@ -"""Tests for ``localpost.threadtools.TaskGroup``.""" +"""Tests for ``localpost.threadtools.TaskGroup``. + +These tests rely on an ambient ``thread_pool()``, supplied autouse by +``conftest.py``. The pool itself runs on a blocking-portal-backed event +loop so the sync test bodies remain sync. +""" import contextvars import threading @@ -6,9 +11,9 @@ from concurrent.futures import Future import pytest -from anyio.from_thread import start_blocking_portal +from anyio.from_thread import BlockingPortal -from localpost.threadtools import TaskGroup, warmup +from localpost.threadtools import TaskGroup, ThreadPool from localpost.threadtools import _task_group as _tg @@ -19,15 +24,15 @@ def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch): return 0.1 -def _drain_idle_workers() -> None: - """Wait for the global idle deque to empty. +def _drain_idle(pool: ThreadPool) -> None: + """Wait for the pool's idle deque to empty. Tests that monkeypatch ``IDLE_TIMEOUT`` to a small value rely on this so they don't leak workers (or interact with each other through the shared ``idle`` deque). """ deadline = time.monotonic() + 2.0 - while _tg.idle and time.monotonic() < deadline: + while pool.idle and time.monotonic() < deadline: time.sleep(0.05) @@ -69,6 +74,27 @@ def work(i: int) -> int: assert sorted(f.result() for f in futs) == [i * i for i in range(n)] +# --------------------------------------------------------------------------- +# Construction outside thread_pool() raises +# --------------------------------------------------------------------------- + + +def test_taskgroup_without_pool_raises(): + """Without an active ``thread_pool()`` context, ``TaskGroup()`` must + raise rather than silently spawning detached workers. + + The autouse ``pool`` fixture sets the ambient pool for this module; + we briefly pop it via a fresh contextvars context to simulate the + no-pool case.""" + ctx = contextvars.Context() + + def attempt(): + with pytest.raises(RuntimeError, match="thread_pool"): + TaskGroup() + + ctx.run(attempt) + + # --------------------------------------------------------------------------- # Exception capture # --------------------------------------------------------------------------- @@ -295,7 +321,7 @@ def test_group_cannot_be_reused(): # --------------------------------------------------------------------------- -def test_workers_are_shared_across_groups(fast_idle_timeout): +def test_workers_are_shared_across_groups(pool: ThreadPool, fast_idle_timeout): """A worker idle from one group should be reused by another.""" seen: set[int] = set() @@ -308,7 +334,7 @@ def record(): first_run = set(seen) # Second group sees at least some of the same threads (workers parked - # in the global idle deque). + # in the pool's idle deque). seen.clear() with TaskGroup() as tg: for _ in range(4): @@ -316,20 +342,20 @@ def record(): second_run = set(seen) assert first_run & second_run, "no worker reuse across groups" - _drain_idle_workers() + _drain_idle(pool) -def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): +def test_idle_workers_self_exit_on_timeout(pool: ThreadPool, fast_idle_timeout): # Drop any leftover workers from earlier tests — they were spawned before # the monkeypatch and are stuck on their original 60 s ``inbox.get``. # Orphaning them is safe (daemon threads, won't be reused). - _tg.idle.clear() + pool.idle.clear() # Spawn a fresh worker and grab a reference to it. with TaskGroup() as tg: tg.create_task(lambda: None).result(timeout=5) - assert len(_tg.idle) >= 1 - fresh = _tg.idle[-1] # LIFO: most recently parked + assert len(pool.idle) >= 1 + fresh = pool.idle[-1] # LIFO: most recently parked # Wait past the idle timeout. time.sleep(fast_idle_timeout * 10) @@ -338,7 +364,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): # Submitting again skips the dead tombstone and spawns fresh. with TaskGroup() as tg: tg.create_task(lambda: None).result(timeout=5) - _drain_idle_workers() + _drain_idle(pool) # --------------------------------------------------------------------------- @@ -346,7 +372,7 @@ def test_idle_workers_self_exit_on_timeout(fast_idle_timeout): # --------------------------------------------------------------------------- -def test_claim_vs_idle_timeout_race(fast_idle_timeout): +def test_claim_vs_idle_timeout_race(pool: ThreadPool, fast_idle_timeout): """Stress the pop+claim vs self-die race. Idle workers may time out *exactly* as a dispatcher pops them. The @@ -372,7 +398,7 @@ def tick(): time.sleep(fast_idle_timeout * 1.5) assert counter == n * 5 - _drain_idle_workers() + _drain_idle(pool) def test_start_soon_from_arbitrary_thread(): @@ -400,27 +426,27 @@ def _record(i: int, sink: list[int], lock: threading.Lock) -> None: # --------------------------------------------------------------------------- -# warmup() +# warmup # --------------------------------------------------------------------------- -def test_warmup_adds_workers_to_idle_pool(): - """``warmup(N)`` spawns N workers and puts them in the global idle deque.""" - _tg.idle.clear() - warmup(4) - assert len(_tg.idle) == 4 - # Each entry is a distinct, alive worker. - workers = list(_tg.idle) +def test_warmup_adds_workers_to_idle_pool(pool: ThreadPool, portal: BlockingPortal): + """``await pool.warmup(N)`` spawns N workers and parks them in the + pool's idle deque.""" + pool.idle.clear() + portal.call(pool.warmup, 4) + assert len(pool.idle) == 4 + workers = list(pool.idle) assert all(w._alive for w in workers) assert len({id(w) for w in workers}) == 4 -def test_warmup_workers_are_used_by_dispatch(): +def test_warmup_workers_are_used_by_dispatch(pool: ThreadPool, portal: BlockingPortal): """A subsequent ``start_soon`` reuses a pre-warmed worker rather than spawning a fresh one.""" - _tg.idle.clear() - warmup(2) - pre = {id(w) for w in _tg.idle} + pool.idle.clear() + portal.call(pool.warmup, 2) + pre = {id(w) for w in pool.idle} seen: set[int] = set() seen_lock = threading.Lock() @@ -433,19 +459,19 @@ def record_self() -> None: tg.create_task(record_self).result(timeout=5) # The worker that ran is one of the pre-warmed set. - post = {id(w) for w in _tg.idle} + post = {id(w) for w in pool.idle} assert pre & post # at least one of the warmed workers is still parked -def test_warmup_zero_is_noop(): - _tg.idle.clear() - warmup(0) - assert len(_tg.idle) == 0 +def test_warmup_zero_is_noop(pool: ThreadPool, portal: BlockingPortal): + pool.idle.clear() + portal.call(pool.warmup, 0) + assert len(pool.idle) == 0 -def test_warmup_negative_raises(): +def test_warmup_negative_raises(pool: ThreadPool, portal: BlockingPortal): with pytest.raises(ValueError, match=">= 0"): - warmup(-1) + portal.call(pool.warmup, -1) # --------------------------------------------------------------------------- @@ -517,71 +543,35 @@ def observe() -> int: # --------------------------------------------------------------------------- -# Async callables via aio_portal +# Async callables (dispatched via the pool's portal) # --------------------------------------------------------------------------- -async def test_async_callable_runs_via_portal(): +def test_async_callable_runs_via_pool_portal(): """An async callable submitted via ``create_task`` is awaited on the - portal's event loop; the future resolves with the awaited result.""" + pool's portal event loop; the future resolves with the awaited result.""" async def add(a: int, b: int) -> int: return a + b - with start_blocking_portal() as portal: - with TaskGroup(aio_portal=portal) as tg: - fut = tg.create_task(add, 2, 3) + with TaskGroup() as tg: + fut = tg.create_task(add, 2, 3) - assert fut.result(timeout=5) == 5 + assert fut.result(timeout=5) == 5 -async def test_async_callable_exception_flows_through_future(): +def test_async_callable_exception_flows_through_future(): """Exceptions raised inside an async callable surface on the future (and into the TaskGroup's exception group).""" async def boom(): raise ValueError("boom") - with start_blocking_portal() as portal: - tg = TaskGroup(aio_portal=portal) - with pytest.raises(ExceptionGroup) as ei: - with tg: - fut = tg.create_task(boom) - - with pytest.raises(ValueError, match="boom"): - fut.result(timeout=5) - assert any(isinstance(e, ValueError) for e in ei.value.exceptions) - - -def test_async_callable_without_portal_raises(): - """Submitting a coroutine function to a TaskGroup without an - ``aio_portal`` raises immediately — for both spawn methods.""" - - async def whatever(): - return None - - with TaskGroup() as tg: - with pytest.raises(RuntimeError, match="aio_portal"): - tg.start_soon(whatever) - with pytest.raises(RuntimeError, match="aio_portal"): - tg.create_task(whatever) - - -def test_sync_callable_returning_coroutine_without_portal_raises(): - """Defence in depth: a sync callable that returns a coroutine still - needs a portal — detected at run time, surfaced through the future.""" - - async def inner(): - return 1 - - def returns_coro(): - return inner() - + tg = TaskGroup() with pytest.raises(ExceptionGroup) as ei: - with TaskGroup() as tg: - tg.start_soon(returns_coro) + with tg: + fut = tg.create_task(boom) - assert any( - isinstance(e, RuntimeError) and "aio_portal" in str(e) - for e in ei.value.exceptions - ) + with pytest.raises(ValueError, match="boom"): + fut.result(timeout=5) + assert any(isinstance(e, ValueError) for e in ei.value.exceptions) From 779ed802090c62c58f9be43ea6690b85e343b5f9 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 08:06:52 +0000 Subject: [PATCH 247/286] WIP on task group rework --- localpost/http/_pool.py | 44 ------ localpost/threadtools/_pool.py | 165 --------------------- localpost/threadtools/_task_group.py | 205 +++++++++++++++++---------- 3 files changed, 128 insertions(+), 286 deletions(-) delete mode 100644 localpost/threadtools/_pool.py diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 7559715..1deedf9 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -169,47 +169,3 @@ def _emit_body_too_large(ctx: _NativeReqCtx) -> None: with suppress(Exception): ctx.conn.close() - -# -------------------------------------------------------------------------- -# Public wrappers -# -------------------------------------------------------------------------- - - -@asynccontextmanager -async def thread_pool_handler(inner: RequestHandler, /) -> AsyncGenerator[RequestHandler]: - """Async context manager: yields a ``RequestHandler`` that runs each - request on a worker thread with a *borrowed* conn. - - ``inner`` runs on a worker on a blocking-with-timeout socket — body - reads (``ctx.receive(...)`` / :func:`localpost.http.read_body`) and - other blocking syscalls don't stall the selector. Workers come from - a process-wide :class:`localpost.threadtools.TaskGroup` and - are reused across all pool wrappers; there is no concurrency cap. - - Per-request cancellation surfaces through - :func:`localpost.http.check_cancelled` (client disconnect via - non-blocking ``MSG_PEEK``; pool shutdown via a single - :class:`threading.Event` shared by every in-flight token). - - Routing-only handlers (no body, no I/O) can run on the selector by - not wrapping the router with this. The Router's 404/405 path - completes inline via ``ctx.complete``; matched routes hit the - per-route handler — wrap *that* with the pool, or wrap the whole - router. Whichever shape the user composes is what runs. - - Example:: - - async with thread_pool_handler(router.as_handler()) as h: - async with http_server(config, h): - ... - """ - async with _pool_context() as pool: - yield pool.dispatch(inner) - - -# Back-compat alias: the old streaming_pool_handler took a -# ``Callable[[_NativeReqCtx], None]`` and dispatched on a borrowed conn -# without pre-buffering body. After unifying the dispatch shape that's -# exactly what thread_pool_handler does, so streaming_pool_handler is -# now an alias for it. -streaming_pool_handler = thread_pool_handler diff --git a/localpost/threadtools/_pool.py b/localpost/threadtools/_pool.py deleted file mode 100644 index 7c06487..0000000 --- a/localpost/threadtools/_pool.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Process-wide(-ish) thread pool tying our worker dispatch to AnyIO's -``to_thread`` machinery. - -The pool owns three things: - -* a :class:`anyio.from_thread.BlockingPortal` so sync code (worker threads, - test harnesses) can call back into the event loop; -* an ``anyio.TaskGroup`` of *host tasks* — one per live worker. Each host - task awaits ``anyio.to_thread.run_sync(worker._run, abandon_on_cancel=False, - limiter=...)``, which is what populates ``threadlocals.current_token`` - (asyncio) / ``PARENT_TASK_DATA`` (Trio) on the worker thread. That's - what makes :func:`anyio.from_thread.check_cancelled` work inside user - tasks running on our pool; -* a per-pool LIFO ``idle`` deque of parked workers, plus a set of all - live workers used to broadcast ``shutdown`` on teardown. - -The pool is exposed via :func:`thread_pool` (an async context manager) -and is typically opened inside :func:`localpost.hosting.run_app` so it's -ambient throughout the host's lifetime. :class:`TaskGroup` reads the -ambient pool from a contextvar. -""" - -from __future__ import annotations - -import functools -import math -from collections import deque -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, final - -from anyio import CapacityLimiter, create_task_group, to_thread -from anyio.from_thread import BlockingPortal - -from ._task_group import Worker, _current_pool - -if TYPE_CHECKING: - from anyio.abc import TaskGroup as AioTaskGroup - - -@final -class ThreadPool: - """A pool of worker threads, hosted by AnyIO's ``to_thread`` machinery. - - Construct via :func:`thread_pool` rather than directly. - """ - - __slots__ = ("_host_tg", "_idle", "_limiter", "_portal", "_workers") - - def __init__( - self, - *, - portal: BlockingPortal, - host_tg: AioTaskGroup, - limiter: CapacityLimiter, - ) -> None: - self._portal = portal - self._host_tg = host_tg - self._limiter = limiter - self._idle: deque[Worker] = deque() - # Tracks every worker spawned by this pool so teardown can wake - # parked ones. ``deque`` is documented thread-safe for - # ``append`` / ``remove``; we use a list-of-strong-refs and rely - # on ``Worker.shutdown`` being idempotent. - self._workers: list[Worker] = [] - - @property - def portal(self) -> BlockingPortal: - """The pool's blocking portal — used by tasks to call back into - the host event loop.""" - return self._portal - - @property - def idle(self) -> deque[Worker]: - """Per-pool LIFO deque of idle workers. Public for inspection - only; mutating it from outside is undefined.""" - return self._idle - - async def warmup(self, n: int, /) -> None: - """Pre-spawn ``n`` workers and park them in the idle deque. - - Useful at startup to amortise OS thread-creation cost so the - first burst of work doesn't pay it. - - Raises: - ValueError: ``n`` is negative. - """ - if n < 0: - raise ValueError("count must be >= 0") - for _ in range(n): - w = self._spawn_worker() - self._idle.append(w) - - def _spawn_worker(self) -> Worker: - """Create a worker and dispatch its ``_run`` to the host task - group. Must be called from the event-loop thread.""" - w = Worker(self._idle) - self._workers.append(w) - # ``functools.partial`` is needed because ``start_soon`` doesn't - # forward kwargs. - self._host_tg.start_soon( - functools.partial( - to_thread.run_sync, - w._run, - abandon_on_cancel=False, - limiter=self._limiter, - ) - ) - return w - - def _spawn_worker_blocking(self) -> Worker: - """Sync entry point for ``TaskGroup.create_task``. Hops to the - event loop via the portal to do the actual spawn.""" - return self._portal.call(self._spawn_worker) - - def _shutdown_all_workers(self) -> None: - """Signal every live worker to exit at its next opportunity.""" - for w in self._workers: - w.shutdown() - - -@asynccontextmanager -async def thread_pool( - *, limiter: CapacityLimiter | None = None -) -> AsyncGenerator[ThreadPool]: - """Open a :class:`ThreadPool`, set it as the ambient pool for - :class:`TaskGroup` callers, and tear it down on exit. - - The pool runs on the calling event loop: the - :class:`~anyio.from_thread.BlockingPortal` and the host task group - that owns worker host tasks are both opened inside this context. - - Args: - limiter: Caps how many worker threads can run concurrently from - this pool's POV. Defaults to ``CapacityLimiter(math.inf)`` - (no cap). Note that AnyIO's own - :func:`anyio.to_thread.run_sync` machinery underlies each - worker, but the limiter here is dedicated to this pool. - - Tasks running on workers spawned by this pool can call - :func:`anyio.from_thread.check_cancelled` to observe cancellation of - the surrounding scope. On teardown, idle workers are signalled to - exit; in-flight tasks that don't poll are not interrupted (we wait - for them to return). - """ - if limiter is None: - limiter = CapacityLimiter(math.inf) - async with BlockingPortal() as portal, create_task_group() as host_tg: - pool = ThreadPool(portal=portal, host_tg=host_tg, limiter=limiter) - token = _current_pool.set(pool) - try: - yield pool - finally: - _current_pool.reset(token) - # Wake idle workers parked in ``_cv.wait`` so their ``_run`` - # returns promptly (otherwise they'd sit on the IDLE_TIMEOUT - # for up to 60 s before the host task group could drain). - pool._shutdown_all_workers() - # Cancel host_tg so any task currently calling - # ``check_cancelled`` from inside a worker raises. With - # ``abandon_on_cancel=False`` the host tasks still wait for - # ``_run`` to return — workers exit either because the - # in-flight task completed, raised on cancel, or the - # ``_shutdown`` flag was observed. - host_tg.cancel_scope.cancel() diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index c6c782e..ddcdc0f 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -2,22 +2,27 @@ import contextvars import dataclasses as dc +import functools import inspect +import math import threading from collections import deque -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable from concurrent.futures import Future -from typing import TYPE_CHECKING, Any, Self, final, overload +from contextlib import AbstractContextManager, asynccontextmanager +from typing import Any, Self, final, overload -if TYPE_CHECKING: - from ._pool import ThreadPool +from anyio import CapacityLimiter, to_thread +from anyio.abc import TaskGroup as AioTaskGroup +from anyio.from_thread import BlockingPortal +from coverage.debug import pp + +from localpost._utils import set_cvar IDLE_TIMEOUT: float = 60.0 """Seconds an idle worker waits for new work before self-exiting.""" -_current_pool: contextvars.ContextVar[ThreadPool] = contextvars.ContextVar( - "localpost.threadtools.current_pool" -) +_current_pool: contextvars.ContextVar[WorkerPool] = contextvars.ContextVar("localpost.threadtools.current_pool") """Ambient :class:`ThreadPool` for the current async context. Set by :func:`thread_pool`; read by :class:`TaskGroup` on construction.""" @@ -71,9 +76,7 @@ class Worker: :func:`anyio.from_thread.check_cancelled` to work inside user tasks. """ - __slots__ = ("_alive", "_cv", "_inbox", "_pool_idle", "_shutdown") - - def __init__(self, pool_idle: deque[Worker]) -> None: + def __init__(self, pool: WorkerPool) -> None: self._inbox: deque[Task] = deque() self._cv = threading.Condition(threading.Lock()) # Set under ``_cv`` before lock release in ``_run``; that ordering is @@ -81,49 +84,36 @@ def __init__(self, pool_idle: deque[Worker]) -> None: # call returning, which can otherwise still report the worker alive # after ``_run`` has released the lock. self._alive = True - self._shutdown = False - # Deque the worker re-enters after each completed task. Per-pool, so - # workers spawned by pool A are not picked up by tasks under pool B. - self._pool_idle = pool_idle + self._pool = pool + + def wakeup(self) -> None: + with self._cv: + self._cv.notify() def submit(self, task: Task) -> bool: - """Hand off a task. Returns ``False`` if the worker has self-exited - or has been asked to shut down.""" + """Hand off a task. Returns ``False`` if the worker has self-exited.""" with self._cv: - if not self._alive or self._shutdown: + if not self._alive: return False self._inbox.append(task) self._cv.notify() return True - def shutdown(self) -> None: - """Signal the worker to exit at its next opportunity. Wakes a - worker parked in ``_cv.wait``; in-flight tasks finish first.""" - with self._cv: - self._shutdown = True - self._cv.notify() - def _run(self) -> None: - while True: + while not self._pool._closed: with self._cv: while not self._inbox: - if self._shutdown: - self._alive = False - return + # TODO Can we use wait_for here? if not self._cv.wait(timeout=IDLE_TIMEOUT): - # Timed out. One more inbox check under the lock to - # close the timeout-vs-notify race. + # Idle timeout or pool closed. + # One more inbox check under the lock to close the timeout-vs-notify race. if not self._inbox: self._alive = False return break task = self._inbox.popleft() task.run() - if self._shutdown: - with self._cv: - self._alive = False - return - self._pool_idle.append(self) + self._pool._idle.append(self) @final @@ -152,8 +142,8 @@ class TaskGroup: async with thread_pool(): with TaskGroup() as tg: - tg.start_soon(do_work, arg) # fire-and-forget - fut = tg.create_task(other_work) # observe via Future + tg.start_soon(do_work, arg) # fire-and-forget + fut = tg.create_task(other_work) # observe via Future # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. Both :meth:`start_soon` and :meth:`create_task` are callable from @@ -181,17 +171,8 @@ class TaskGroup: the pool's host scope. Tasks that don't poll won't be interrupted. """ - __slots__ = ("_closed", "_cv", "_errors", "_lock", "_name", "_pending", "_pool") - - def __init__(self, *, name: str | None = None) -> None: - pool = _current_pool.get(None) - if pool is None: - raise RuntimeError( - "No active thread_pool() context. Wrap your code in " - "`async with thread_pool():` or run inside " - "`localpost.hosting.run_app()`." - ) - self._name = name + def __init__(self, pool: WorkerPool, *, name: str | None = None) -> None: + self._name = name # TODO Remove self._pool = pool self._lock = threading.Lock() self._cv = threading.Condition(self._lock) @@ -232,13 +213,9 @@ def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> N raise BaseExceptionGroup(label, all_errors) @overload - def start_soon[**P]( - self, fn: Callable[P, Awaitable[Any]], /, *args: P.args, **kwargs: P.kwargs - ) -> None: ... + def start_soon[**P](self, fn: Callable[P, Awaitable[Any]], /, *args: P.args, **kwargs: P.kwargs) -> None: ... @overload - def start_soon[**P]( - self, fn: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs - ) -> None: ... + def start_soon[**P](self, fn: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs) -> None: ... def start_soon(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None: """Submit ``fn(*args, **kwargs)`` to a worker thread. Fire-and-forget. @@ -249,16 +226,10 @@ def start_soon(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> No self.create_task(fn, *args, **kwargs) @overload - def create_task[**P, R]( - self, fn: Callable[P, Awaitable[R]], /, *args: P.args, **kwargs: P.kwargs - ) -> Future[R]: ... + def create_task[**P, R](self, fn: Callable[P, Awaitable[R]], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: ... @overload - def create_task[**P, R]( - self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs - ) -> Future[R]: ... - def create_task( - self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any - ) -> Future[Any]: + def create_task[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: ... + def create_task(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Future[Any]: """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future. ``fn`` may be sync or async. Async callables are dispatched to @@ -272,25 +243,15 @@ def create_task( """ with self._lock: if self._closed: - raise RuntimeError("TaskGroup is closed") + raise RuntimeError("TaskGroup is closed") # TODO AnyIO ClosedResourceError ? self._pending += 1 fut: Future[Any] = Future() - # Snapshot the caller's context now (matches Trio / AnyIO / asyncio - # ``to_thread`` semantics); each spawn captures independently. + # Snapshot the caller's context (matches Trio / AnyIO / asyncio semantics) task = Task(fut, fn, args, kwargs, self, contextvars.copy_context()) - pool_idle = self._pool._idle while True: - try: - w = pool_idle.pop() - except IndexError: - # No idle worker — spawn a fresh one. ``submit`` on a fresh - # worker is guaranteed to succeed (``_alive=True``). - w = self._pool._spawn_worker_blocking() - w.submit(task) + if self._pool.get_idle_worker().submit(task): return fut - if w.submit(task): - return fut - # Tombstone (self-died on idle timeout); pop the next one. + # Tombstone (worker self-died on idle timeout), pop the next one def _task_done(self) -> None: with self._cv: @@ -305,3 +266,93 @@ def _record_error(self, exc: BaseException) -> None: # ``_task_done``'s acquire of ``_cv``, which always runs after # ``_record_error`` in the same task. self._errors.append(exc) + + +def task_group(*, name: str | None = None) -> AbstractContextManager[TaskGroup]: + if pool := _current_pool.get(None): + return TaskGroup(pool, name=name) + raise RuntimeError( + "No active thread_pool() context. Wrap your code in " + "`async with thread_pool():` or run inside " + "`localpost.hosting.run_app()`." + ) + + +class AnyIOWorkerExecutor: + def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: + pass + + +class AnyIOExecutor: + def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: + pass + + +class WorkerExecutor: + def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: + pass + + +@final +class WorkerPool: + def __init__(self, portal: BlockingPortal, host_tg: AioTaskGroup) -> None: + self._portal = portal + self._host_tg = host_tg + self._idle: deque[Worker] = deque() + self._limiter = CapacityLimiter(math.inf) + self._closed = False + + def get_idle_worker(self) -> Worker: + try: + return self._idle.pop() + except IndexError: + return self._spawn_worker_blocking() + + async def warmup(self, n: int, /) -> None: + """Pre-spawn ``n`` workers and park them in the idle deque. + + Useful at startup to amortise OS thread-creation cost so the + first burst of work doesn't pay it. + + Raises: + ValueError: ``n`` is negative. + """ + if n < 0: + raise ValueError("count must be >= 0") + for _ in range(n): + w = self._spawn_worker() + self._idle.append(w) + + def _spawn_worker(self) -> Worker: + """Create a worker (must be called from the event-loop thread).""" + w = Worker(self) + self._host_tg.start_soon( + functools.partial( + to_thread.run_sync, + w._run, + abandon_on_cancel=False, + limiter=self._limiter, + ) + ) + return w + + def _spawn_worker_blocking(self) -> Worker: + if self._closed: + raise RuntimeError("pool is closed") + return self._portal.call(self._spawn_worker) + + def _shutdown_all_workers(self) -> None: + self._closed = True + while self._idle: + self._idle.pop().wakeup() + + +@asynccontextmanager +async def thread_pool(portal: BlockingPortal, host_tg: AioTaskGroup) -> AsyncGenerator[WorkerPool]: + with set_cvar(_current_pool, WorkerPool(portal, host_tg)) as pool: + try: + yield pool + finally: + # At that point all the workers should be idle. Wake them up so they can exit promptly, otherwise they'd sit + # on the IDLE_TIMEOUT before the host task group could drain). + pool._shutdown_all_workers() From f08dfd9efd6dece0744d84d564eee065e6ea699c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 12:14:38 +0400 Subject: [PATCH 248/286] Revert "chore: cleanup channels" This reverts commit 6746edecd80987bfd9e9d04a02dd7e19fa32d65c. --- localpost/threadtools/_channel.py | 130 ++++++++++++++++-------------- 1 file changed, 69 insertions(+), 61 deletions(-) diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py index 7fbffad..3dc1570 100644 --- a/localpost/threadtools/_channel.py +++ b/localpost/threadtools/_channel.py @@ -4,7 +4,7 @@ import threading from collections import deque from collections.abc import Callable, Iterator -from typing import Self, final +from typing import Protocol, Self, final, override from anyio import ( ClosedResourceError, @@ -42,6 +42,63 @@ def create( return tx, rx +class BaseReceiveChannel[T](Protocol): + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def __iter__(self) -> Iterator[T]: + while True: + try: + yield self.get() + except EndOfStream: + break + + def clone(self) -> ReceiveChannel[T]: ... + + # Raises: + # EndOfStream - if the sender has been closed cleanly, and no more objects are coming. This is not an error + # condition. + # ClosedResourceError - if you previously closed this ReceiveChannel object. + # BrokenResourceError - if something has gone wrong, and the channel is broken. + def get(self) -> T: ... + + def close(self): ... + + +class BaseSendChannel[T](Protocol): + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def clone(self) -> SendChannel[T]: ... + + # Raises: + # BrokenResourceError - if something has gone wrong, and the channel is broken. For example, you may get this if + # the receiver has already been closed. + # ClosedResourceError - if you previously closed this SendChannel object, or if another task closes it while + # put() is running. + def put(self, item: T, /) -> None: ... + + def close(self) -> None: ... + + @final @dc.dataclass(slots=True) class ChannelState[T]: @@ -109,28 +166,14 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: @final -class SendChannel[T]: +# TODO dataclass + repr +class SendChannel[T](BaseSendChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False - def __repr__(self) -> str: - state = self._state - return f"SendChannel(closed={self._closed}, capacity={state.capacity}, qsize={len(state.buffer)})" - - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def clone(self) -> SendChannel[T]: + @override + def clone(self): with self._state as state: if self._closed: raise ClosedResourceError("send channel is already closed") @@ -150,16 +193,8 @@ def put_nowait(self, item: T, /) -> None: state.pending_handoffs += 1 state.not_empty.notify() + @override def put(self, item: T, /) -> None: - """Put ``item`` into the channel, blocking until there is space (or, for a rendezvous - channel, until a receiver consumes it). - - Raises: - BrokenResourceError: Something has gone wrong and the channel is broken — for - example, the receiver has already been closed. - ClosedResourceError: This send channel was previously closed, or another task - closed it while ``put`` was running. - """ state = self._state my_target = 0 # Phase 1: wait for space in the buffer. @@ -205,50 +240,22 @@ def close(self) -> None: @final -class ReceiveChannel[T]: +# TODO dataclass + repr +class ReceiveChannel[T](BaseReceiveChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False - def __repr__(self) -> str: - state = self._state - return f"ReceiveChannel(closed={self._closed}, capacity={state.capacity}, qsize={len(state.buffer)})" - - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def __iter__(self) -> Iterator[T]: - while True: - try: - yield self.get() - except EndOfStream: - break - - def clone(self) -> ReceiveChannel[T]: + @override + def clone(self): with self._state as state: if self._closed: raise ClosedResourceError("receive channel is already closed") state.open_receive_channels += 1 return ReceiveChannel(state) + @override def get(self) -> T: - """Get the next item from the channel, blocking until one is available. - - Raises: - EndOfStream: The sender has been closed cleanly and no more items are - coming. This is not an error condition. - ClosedResourceError: This receive channel was previously closed. - BrokenResourceError: Something has gone wrong and the channel is broken. - """ # Cancellation is observed inside ``state.__enter__`` (cancellable lock # acquire) and ``state.not_empty.wait`` (cancellable condition). state = self._state @@ -271,6 +278,7 @@ def get(self) -> T: state.waiting_receivers -= 1 raise ClosedResourceError("receive channel has been closed") + @override def close(self) -> None: with self._state as state: if not self._closed: From 53570243790a2ad0d92f7c97fdc5b400101bae6c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 11:57:34 +0000 Subject: [PATCH 249/286] WIP on task group rework --- localpost/threadtools/_task_group.py | 104 ++++++++------------------- 1 file changed, 28 insertions(+), 76 deletions(-) diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index ddcdc0f..c831940 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -10,7 +10,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable from concurrent.futures import Future from contextlib import AbstractContextManager, asynccontextmanager -from typing import Any, Self, final, overload +from typing import Any, Protocol, Self, final, overload from anyio import CapacityLimiter, to_thread from anyio.abc import TaskGroup as AioTaskGroup @@ -171,7 +171,7 @@ class TaskGroup: the pool's host scope. Tasks that don't poll won't be interrupted. """ - def __init__(self, pool: WorkerPool, *, name: str | None = None) -> None: + def __init__(self, pool: Executor, *, name: str | None = None) -> None: self._name = name # TODO Remove self._pool = pool self._lock = threading.Lock() @@ -268,91 +268,43 @@ def _record_error(self, exc: BaseException) -> None: self._errors.append(exc) -def task_group(*, name: str | None = None) -> AbstractContextManager[TaskGroup]: - if pool := _current_pool.get(None): - return TaskGroup(pool, name=name) - raise RuntimeError( - "No active thread_pool() context. Wrap your code in " - "`async with thread_pool():` or run inside " - "`localpost.hosting.run_app()`." - ) +class Executor(Protocol): + def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: ... -class AnyIOWorkerExecutor: - def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: +# Channel based worker pool. Starts from 1 worker (for the channel reader that was just created). +# A worker: +# wait on channel_receiver.get(timeout=60), if the timeout happened (built-in TimeoutError?) — just stop the worker. +# if .close() is callaed from the manager (executor shutdown) — stop the worker. +# (ChannelReceiver.close() should notify the worker somehow, so we can wake up in .get()) +# Submission: +# 1. future created +# 2. new worker started, if needed +# 3. task submitted to the channel +class WorkerExecutor: + def __init__(self, *, max_concurrency: int | None = None, backlog: int | None = None) -> None: + # max_concurrency: maximum number of workers to run concurrently (or infinite if None) + # backlog: maximum number of pending tasks to queue before blocking (or infinite if None) pass - -class AnyIOExecutor: def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: pass - -class WorkerExecutor: - def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: + async def warmup(self, n: int, /) -> None: pass -@final -class WorkerPool: - def __init__(self, portal: BlockingPortal, host_tg: AioTaskGroup) -> None: - self._portal = portal - self._host_tg = host_tg - self._idle: deque[Worker] = deque() - self._limiter = CapacityLimiter(math.inf) - self._closed = False - - def get_idle_worker(self) -> Worker: - try: - return self._idle.pop() - except IndexError: - return self._spawn_worker_blocking() +# Same as WorkerExecutor, but each worker starts via AnyIO loop thread, so `from_thread.check_cancelled()` is available +class AnyIOWorkerExecutor: + def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: + pass async def warmup(self, n: int, /) -> None: - """Pre-spawn ``n`` workers and park them in the idle deque. - - Useful at startup to amortise OS thread-creation cost so the - first burst of work doesn't pay it. - - Raises: - ValueError: ``n`` is negative. - """ - if n < 0: - raise ValueError("count must be >= 0") - for _ in range(n): - w = self._spawn_worker() - self._idle.append(w) - - def _spawn_worker(self) -> Worker: - """Create a worker (must be called from the event-loop thread).""" - w = Worker(self) - self._host_tg.start_soon( - functools.partial( - to_thread.run_sync, - w._run, - abandon_on_cancel=False, - limiter=self._limiter, - ) - ) - return w - - def _spawn_worker_blocking(self) -> Worker: - if self._closed: - raise RuntimeError("pool is closed") - return self._portal.call(self._spawn_worker) - - def _shutdown_all_workers(self) -> None: - self._closed = True - while self._idle: - self._idle.pop().wakeup() + pass -@asynccontextmanager -async def thread_pool(portal: BlockingPortal, host_tg: AioTaskGroup) -> AsyncGenerator[WorkerPool]: - with set_cvar(_current_pool, WorkerPool(portal, host_tg)) as pool: - try: - yield pool - finally: - # At that point all the workers should be idle. Wake them up so they can exit promptly, otherwise they'd sit - # on the IDLE_TIMEOUT before the host task group could drain). - pool._shutdown_all_workers() +# Each task — AnyIO task (from_thread -> to_thread), a roundrtip via AnyIO loop thread +class AnyIOExecutor: + # TODO max_concurrency & backpressure (backlog, max_size) - via Channel + def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: + pass From 077d3cb8e17299493bfc95650373cf4105abacbb Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 19:56:56 +0400 Subject: [PATCH 250/286] refactor(threadtools)!: rebuild Channel on plain locks, add timeout API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel no longer depends on CancellableLock/cancellable_condition. Cancellation moves up a layer (executor / task group / caller polls timeouts). - put()/get() take an explicit ``timeout`` parameter, raising ``TimeoutError`` on expiry. - New ``ReceiveChannel.get_nowait()`` mirrors ``put_nowait()`` and raises ``WouldBlock`` / ``EndOfStream`` / ``ClosedResourceError``. - ``close()`` now broadcasts (``notify_all``) so cloned senders / receivers blocked on the shared conditions all wake and re-check their state. - ``_sync.py`` and ``_base.py`` are deleted — they only existed to serve the channel. This is a stepping stone for the upcoming Executor / TaskGroup rework: the ``__init__`` now exports only the channel surface; the broken ``_pool`` import is dropped. ``localpost.hosting`` and ``localpost.http._pool`` still import the old names and are left broken intentionally — they will be rewired in follow-up commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools/__init__.py | 5 - localpost/threadtools/_base.py | 17 --- localpost/threadtools/_channel.py | 193 ++++++++++++++++++------------ localpost/threadtools/_sync.py | 113 ----------------- tests/threadtools/channels.py | 104 ++++++++++++++++ tests/threadtools/conftest.py | 51 +------- 6 files changed, 221 insertions(+), 262 deletions(-) delete mode 100644 localpost/threadtools/_base.py delete mode 100644 localpost/threadtools/_sync.py diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index ce77d84..9d61fb7 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,12 +1,7 @@ from ._channel import Channel, ReceiveChannel, SendChannel -from ._pool import ThreadPool, thread_pool -from ._task_group import TaskGroup __all__ = [ "Channel", "ReceiveChannel", "SendChannel", - "TaskGroup", - "ThreadPool", - "thread_pool", ] diff --git a/localpost/threadtools/_base.py b/localpost/threadtools/_base.py deleted file mode 100644 index ad7a81d..0000000 --- a/localpost/threadtools/_base.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import time - -CHECK_TIMEOUT: float = 1.0 -"""Timeout (seconds) for cancellation checks (e.g. in the server loop).""" - - -def _noop_check() -> None: - """Default cancellation probe — a no-op. - - Pass ``anyio.from_thread.check_cancelled`` (or any custom callable) to a - primitive's ``check_cancelled`` argument to opt in to cancellation. - """ - - -current_time = time.monotonic diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py index 3dc1570..ecdad1a 100644 --- a/localpost/threadtools/_channel.py +++ b/localpost/threadtools/_channel.py @@ -2,8 +2,9 @@ import dataclasses as dc import threading +import time from collections import deque -from collections.abc import Callable, Iterator +from collections.abc import Iterator from typing import Protocol, Self, final, override from anyio import ( @@ -12,29 +13,19 @@ WouldBlock, ) -from ._base import _noop_check -from ._sync import CancellableLock, cancellable_condition - @final class Channel[T]: @staticmethod - def create( - capacity: int | None = None, - *, - check_cancelled: Callable[[], None] = _noop_check, - ) -> tuple[SendChannel[T], ReceiveChannel[T]]: + def create(capacity: int | None = None) -> tuple[SendChannel[T], ReceiveChannel[T]]: """Create a channel sender/receiver pair. Args: - capacity: Buffer size. None means unbounded, 0 means rendezvous - (put blocks until a receiver consumes the item), N>0 means bounded. - check_cancelled: Cancellation probe invoked from blocking lock / - condition waits. Defaults to a no-op; pass - ``anyio.from_thread.check_cancelled`` to make the channel - cancellation-aware from inside an anyio worker thread. + capacity: Buffer size. ``None`` means unbounded; ``0`` means rendezvous + (put blocks until a receiver consumes the item); ``N > 0`` means + bounded (put blocks while ``len(buffer) >= N``). """ - with ChannelState(capacity, check_cancelled=check_cancelled) as state: + with ChannelState[T](capacity) as state: state.open_send_channels += 1 tx = SendChannel(state) state.open_receive_channels += 1 @@ -65,13 +56,14 @@ def __iter__(self) -> Iterator[T]: def clone(self) -> ReceiveChannel[T]: ... # Raises: - # EndOfStream - if the sender has been closed cleanly, and no more objects are coming. This is not an error - # condition. - # ClosedResourceError - if you previously closed this ReceiveChannel object. - # BrokenResourceError - if something has gone wrong, and the channel is broken. - def get(self) -> T: ... + # EndOfStream - all senders closed cleanly and the buffer is drained. + # TimeoutError - ``timeout`` elapsed without an item. + # ClosedResourceError - this receive channel was already closed. + def get(self, timeout: float | None = None) -> T: ... + + def get_nowait(self) -> T: ... - def close(self): ... + def close(self) -> None: ... class BaseSendChannel[T](Protocol): @@ -90,11 +82,12 @@ async def __aexit__(self, exc_type, exc_value, traceback) -> None: def clone(self) -> SendChannel[T]: ... # Raises: - # BrokenResourceError - if something has gone wrong, and the channel is broken. For example, you may get this if - # the receiver has already been closed. - # ClosedResourceError - if you previously closed this SendChannel object, or if another task closes it while - # put() is running. - def put(self, item: T, /) -> None: ... + # TimeoutError - ``timeout`` elapsed without buffer space (or, in rendezvous mode, without + # the item being consumed). + # ClosedResourceError - this send channel was already closed, or no receivers remain. + def put(self, item: T, /, timeout: float | None = None) -> None: ... + + def put_nowait(self, item: T, /) -> None: ... def close(self) -> None: ... @@ -109,38 +102,37 @@ class ChannelState[T]: waiting_receivers: int pending_handoffs: int items_consumed: int - _lock: CancellableLock + _lock: threading.RLock not_empty: threading.Condition not_full: threading.Condition - def __init__(self, capacity: int | None = None, *, check_cancelled: Callable[[], None] = _noop_check): + def __init__(self, capacity: int | None = None): if capacity is not None and capacity < 0: raise ValueError("capacity must be >= 0 or None") self.buffer = deque() self.capacity = capacity - """ - None: unbounded - 0: rendezvous — put blocks until its own item is consumed by a receiver; put_nowait succeeds - only if an unclaimed waiting receiver is available. With N waiting receivers, up to N - puts can be in flight concurrently. - Positive int: bounded (put blocks until len(buffer) < capacity) - """ + # capacity semantics: + # None: unbounded + # 0: rendezvous — put blocks until its own item is consumed by a receiver; put_nowait + # succeeds only if an unclaimed waiting receiver is available. With N waiting + # receivers, up to N puts can be in flight concurrently. + # Positive int: bounded (put blocks until len(buffer) < capacity) self.open_send_channels = 0 self.open_receive_channels = 0 self.waiting_receivers = 0 - # Rendezvous bookkeeping (capacity=0 only). ``pending_handoffs`` is a budget counter for - # buffered items already paired with a waiting receiver; ``items_consumed`` is monotonic - # and lets a blocking ``put`` detect consumption of its own item even when the buffer - # holds other in-flight items. + # Rendezvous bookkeeping (capacity=0 only). ``pending_handoffs`` is a budget counter + # for buffered items already paired with a waiting receiver; ``items_consumed`` is + # monotonic and lets a blocking ``put`` detect consumption of its own item even when + # the buffer holds other in-flight items. self.pending_handoffs = 0 self.items_consumed = 0 - self._lock = CancellableLock(threading.RLock(), check_cancelled=check_cancelled) - self.not_empty = cancellable_condition(self._lock, check_cancelled=check_cancelled) - self.not_full = cancellable_condition(self._lock, check_cancelled=check_cancelled) + self._lock = threading.RLock() + self.not_empty = threading.Condition(self._lock) + self.not_full = threading.Condition(self._lock) @property def can_put(self) -> bool: - if self.open_receive_channels == 0: # TODO Make this state permanent + if self.open_receive_channels == 0: raise ClosedResourceError("no more receivers") if self.capacity == 0: return self.waiting_receivers > self.pending_handoffs or len(self.buffer) == 0 @@ -148,7 +140,7 @@ def can_put(self) -> bool: @property def can_put_nowait(self) -> bool: - if self.open_receive_channels == 0: # TODO Make this state permanent + if self.open_receive_channels == 0: raise ClosedResourceError("no more receivers") if self.capacity == 0: return self.waiting_receivers > self.pending_handoffs @@ -165,21 +157,38 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: self._lock.release() +def _deadline(timeout: float | None) -> float | None: + if timeout is None: + return None + if timeout < 0: + raise ValueError("timeout must be >= 0") + return time.monotonic() + timeout + + +def _remaining(deadline: float | None) -> float | None: + if deadline is None: + return None + return deadline - time.monotonic() + + @final -# TODO dataclass + repr class SendChannel[T](BaseSendChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False + def __repr__(self) -> str: + return f"" + @override - def clone(self): + def clone(self) -> SendChannel[T]: with self._state as state: if self._closed: raise ClosedResourceError("send channel is already closed") state.open_send_channels += 1 return SendChannel(state) + @override def put_nowait(self, item: T, /) -> None: with self._state as state: if self._closed: @@ -194,14 +203,11 @@ def put_nowait(self, item: T, /) -> None: state.not_empty.notify() @override - def put(self, item: T, /) -> None: + def put(self, item: T, /, timeout: float | None = None) -> None: state = self._state + deadline = _deadline(timeout) my_target = 0 - # Phase 1: wait for space in the buffer. - # Body of ``put_nowait`` is inlined here to avoid re-entering the lock - # and to skip the ``WouldBlock`` exception on the contended path. - # Cancellation is observed inside ``state.__enter__`` (cancellable lock - # acquire) and ``state.not_full.wait`` (cancellable condition). + # Phase 1: wait for buffer space. while True: with state: if self._closed: @@ -212,71 +218,101 @@ def put(self, item: T, /) -> None: if state.waiting_receivers > state.pending_handoffs: state.pending_handoffs += 1 # Snapshot a target for Phase 2: consumption of *our* item bumps - # ``items_consumed`` to (at least) this value, regardless of any other - # in-flight items the buffer holds. + # ``items_consumed`` to (at least) this value, regardless of any + # other in-flight items the buffer holds. my_target = state.items_consumed + len(state.buffer) state.not_empty.notify() break - state.not_full.wait() + wait_for = _remaining(deadline) + if wait_for is not None and wait_for <= 0: + raise TimeoutError + state.not_full.wait(timeout=wait_for) - # Phase 2 (rendezvous only): wait until *our* item is consumed + # Phase 2 (rendezvous only): wait until *our* item is consumed. + # On timeout the item stays in the buffer; some later receiver may still consume it. if state.capacity == 0: while True: with state: if self._closed: raise ClosedResourceError("send channel has been closed") if state.items_consumed >= my_target: - return # Consumed + return # consumed if state.open_receive_channels == 0: - return # No receivers left - state.not_full.wait() + return # no receivers left + wait_for = _remaining(deadline) + if wait_for is not None and wait_for <= 0: + raise TimeoutError + state.not_full.wait(timeout=wait_for) + @override def close(self) -> None: with self._state as state: if not self._closed: self._closed = True state.open_send_channels -= 1 - state.not_full.notify() # Wake up threads waiting in put() immediately + # Broadcast: any sender blocked in put()'s Phase 2 needs to re-check; receivers + # blocked in get() need to see open_send_channels==0 and raise EndOfStream. + state.not_empty.notify_all() + state.not_full.notify_all() @final -# TODO dataclass + repr class ReceiveChannel[T](BaseReceiveChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False + def __repr__(self) -> str: + return f"" + @override - def clone(self): + def clone(self) -> ReceiveChannel[T]: with self._state as state: if self._closed: raise ClosedResourceError("receive channel is already closed") state.open_receive_channels += 1 return ReceiveChannel(state) + def _take(self, state: ChannelState[T]) -> T: + item = state.buffer.popleft() + if state.capacity == 0: + state.items_consumed += 1 + if state.pending_handoffs > 0: + state.pending_handoffs -= 1 + state.not_full.notify() + return item + @override - def get(self) -> T: - # Cancellation is observed inside ``state.__enter__`` (cancellable lock - # acquire) and ``state.not_empty.wait`` (cancellable condition). + def get_nowait(self) -> T: + with self._state as state: + if self._closed: + raise ClosedResourceError("receive channel has been closed") + if state.buffer: + return self._take(state) + if state.open_send_channels == 0: + raise EndOfStream("no more senders") + raise WouldBlock + + @override + def get(self, timeout: float | None = None) -> T: state = self._state - while not self._closed: + deadline = _deadline(timeout) + while True: with state: + if self._closed: + raise ClosedResourceError("receive channel has been closed") if state.buffer: - item = state.buffer.popleft() - if state.capacity == 0: - state.items_consumed += 1 - if state.pending_handoffs > 0: - state.pending_handoffs -= 1 - state.not_full.notify() - return item + return self._take(state) if state.open_send_channels == 0: raise EndOfStream("no more senders") + wait_for = _remaining(deadline) + if wait_for is not None and wait_for <= 0: + raise TimeoutError state.waiting_receivers += 1 try: - state.not_empty.wait() + state.not_empty.wait(timeout=wait_for) finally: state.waiting_receivers -= 1 - raise ClosedResourceError("receive channel has been closed") @override def close(self) -> None: @@ -284,4 +320,7 @@ def close(self) -> None: if not self._closed: self._closed = True state.open_receive_channels -= 1 - state.not_empty.notify() # Wake up threads waiting in get() immediately + # Broadcast: any thread blocked in get() on this rx needs to see _closed and + # raise; senders blocked in put() need to see open_receive_channels==0. + state.not_empty.notify_all() + state.not_full.notify_all() diff --git a/localpost/threadtools/_sync.py b/localpost/threadtools/_sync.py deleted file mode 100644 index 93c736f..0000000 --- a/localpost/threadtools/_sync.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import threading -from collections.abc import Callable -from typing import final - -from ._base import CHECK_TIMEOUT, _noop_check, current_time - - -@final -class CancellableLock: - """Same interface as threading.Lock, but acquire() is cancellation aware. - - The class deliberately mirrors a few CPython-private attributes from - ``threading.RLock`` (``_release_save``, ``_acquire_restore``, ``_is_owned``) - so that ``threading.Condition(lock=CancellableLock(...))`` accepts it - transparently — the stdlib's ``Condition`` does its own duck-typing on - these names. This is intentional, not a leaky abstraction. - """ - - __slots__ = ( - "__exit__", - "_acquire_restore", - "_check_cancelled", - "_is_owned", - "_release_save", - "locked", - "release", - "source", - ) - - def __init__( - self, - lock: threading.Lock | threading.RLock | None = None, - *, - check_cancelled: Callable[[], None] = _noop_check, - ) -> None: - lock = lock or threading.Lock() - self.source = lock - self.release = lock.release - self.__exit__ = lock.__exit__ - self._check_cancelled = check_cancelled - if hasattr(self.source, "locked"): - self.locked = lock.locked # type: ignore - if hasattr(lock, "_release_save"): - self._release_save = lock._release_save # type: ignore - if hasattr(lock, "_acquire_restore"): - self._acquire_restore = lock._acquire_restore # type: ignore - if hasattr(lock, "_is_owned"): - self._is_owned = lock._is_owned # type: ignore - - def acquire(self, blocking: bool = True, timeout: float | None = None) -> bool: - if not blocking: - return self.source.acquire(blocking=False) - if timeout is None or timeout < 0: - # No timeout — loop until acquired, checking for cancellation - while not self.source.acquire(timeout=CHECK_TIMEOUT): - self._check_cancelled() - return True - # Finite timeout — respect the deadline - deadline = current_time() + timeout - while (remaining := deadline - current_time()) > 0: - if self.source.acquire(timeout=min(CHECK_TIMEOUT, remaining)): - return True - self._check_cancelled() - return False - - __enter__ = acquire - - -def cancellable_condition( - lock: CancellableLock | None = None, - *, - check_cancelled: Callable[[], None] = _noop_check, -) -> threading.Condition: - # ``Condition`` accepts any duck-typed lock with the right private attrs; - # ``CancellableLock`` mirrors them (see its docstring). - # Note: ``check_cancelled`` controls the condition's ``wait`` loop only. - # The lock keeps whatever probe it was constructed with — pass a lock built - # with the same callable if you want a single coherent policy. - cond = threading.Condition(lock or CancellableLock(threading.RLock())) # type: ignore - orig_wait = cond.wait - - def cancellable_wait(timeout: float | None = None) -> bool: - if timeout is None or timeout < 0: - while True: - check_cancelled() - if orig_wait(CHECK_TIMEOUT): - return True - - end_time = current_time() + timeout - while True: - now = current_time() - if now >= end_time: - return False - check_cancelled() - remaining = min(CHECK_TIMEOUT, max(0.0, end_time - now)) - if orig_wait(remaining): - return True - - cond.wait = cancellable_wait # type: ignore - return cond - - -def cancellable_semaphore( - value: int = 1, *, check_cancelled: Callable[[], None] = _noop_check -) -> threading.BoundedSemaphore: - # ``Semaphore`` / ``BoundedSemaphore`` use a private ``_cond`` for blocking - # waits; swap it for our cancellable variant so the semaphore's blocking - # ``acquire`` becomes cancellation-aware. - source = threading.BoundedSemaphore(value) - source._cond = cancellable_condition(check_cancelled=check_cancelled) # type: ignore - return source diff --git a/tests/threadtools/channels.py b/tests/threadtools/channels.py index 5d53790..284a4bf 100644 --- a/tests/threadtools/channels.py +++ b/tests/threadtools/channels.py @@ -508,6 +508,110 @@ def receiver_work(r): assert sorted(received) == sorted(expected) +def test_get_with_timeout_raises_timeout_error(): + sender, receiver = Channel.create() + started = time.monotonic() + with pytest.raises(TimeoutError): + receiver.get(timeout=0.05) + elapsed = time.monotonic() - started + assert 0.04 <= elapsed < 0.5 + sender.close() + receiver.close() + + +def test_get_with_timeout_zero_is_nonblocking(): + sender, receiver = Channel.create() + with pytest.raises(TimeoutError): + receiver.get(timeout=0) + sender.put(1) + assert receiver.get(timeout=0) == 1 + sender.close() + receiver.close() + + +def test_put_with_timeout_raises_when_buffer_full(): + sender, receiver = Channel.create(capacity=1) + sender.put(1) # fills the buffer + started = time.monotonic() + with pytest.raises(TimeoutError): + sender.put(2, timeout=0.05) + elapsed = time.monotonic() - started + assert 0.04 <= elapsed < 0.5 + sender.close() + receiver.close() + + +def test_get_nowait_empty_raises_would_block(): + sender, receiver = Channel.create() + with pytest.raises(WouldBlock): + receiver.get_nowait() + sender.put(1) + assert receiver.get_nowait() == 1 + sender.close() + receiver.close() + + +def test_get_nowait_after_senders_closed_raises_eos(): + sender, receiver = Channel.create() + sender.close() + with pytest.raises(EndOfStream): + receiver.get_nowait() + receiver.close() + + +def test_get_unblocks_when_receiver_closed_from_other_thread(): + sender, receiver = Channel.create() + raised: list[BaseException] = [] + + def receive(): + try: + receiver.get() + except BaseException as exc: # noqa: BLE001 + raised.append(exc) + + t = threading.Thread(target=receive) + t.start() + time.sleep(0.05) + receiver.close() + t.join(timeout=1.0) + assert not t.is_alive() + assert len(raised) == 1 + assert isinstance(raised[0], ClosedResourceError) + sender.close() + + +def test_close_broadcasts_to_cloned_receivers(): + """When senders close, every cloned receiver waiting in get() must wake + up and observe EndOfStream — not just one of them.""" + sender, receiver = Channel.create() + receivers = [receiver] + [receiver.clone() for _ in range(3)] + seen_eos = threading.Event() + eos_count = 0 + eos_lock = threading.Lock() + + def receive(r): + nonlocal eos_count + try: + r.get() + except EndOfStream: + with eos_lock: + eos_count += 1 + if eos_count == len(receivers): + seen_eos.set() + finally: + r.close() + + threads = [threading.Thread(target=receive, args=(r,)) for r in receivers] + for t in threads: + t.start() + time.sleep(0.05) + sender.close() + for t in threads: + t.join(timeout=1.0) + assert not t.is_alive() + assert seen_eos.is_set() + + def test_channel_cleanup(): """Test that channels clean up properly when references are dropped.""" for _ in range(10): diff --git a/tests/threadtools/conftest.py b/tests/threadtools/conftest.py index 46ec498..83cb2b0 100644 --- a/tests/threadtools/conftest.py +++ b/tests/threadtools/conftest.py @@ -1,50 +1 @@ -"""Shared fixtures for ``localpost.threadtools`` tests. - -The new design makes ``thread_pool()`` mandatory for ``TaskGroup``. These -fixtures spin up a per-test pool via :func:`anyio.from_thread.start_blocking_portal` -so the existing sync test bodies don't need to be rewritten as async. -""" - -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from anyio.from_thread import BlockingPortal, start_blocking_portal - -from localpost.threadtools import ThreadPool, thread_pool -from localpost.threadtools._task_group import _current_pool - - -@pytest.fixture -def portal() -> Iterator[BlockingPortal]: - """A blocking portal backing the ``pool`` fixture. Tests can use it - directly when they need to call into async code from sync tests.""" - with start_blocking_portal() as p: - yield p - - -@pytest.fixture(autouse=True) -def pool(request: pytest.FixtureRequest, portal: BlockingPortal) -> Iterator[ThreadPool | None]: - """An ambient ``thread_pool`` for every test in this directory. - Autouse so existing sync test bodies don't need to ask for it - explicitly. Tests that *do* want introspection (``pool.idle``) can - request it by name. - - The pool itself is opened on the portal's event loop, but the - ``_current_pool`` contextvar is per-context — we explicitly mirror - the binding into the test thread's context so sync ``TaskGroup()`` - constructions find the pool. - - Tests that manage their own pool lifecycle (typically async tests - that use ``async with thread_pool():``) opt out via - ``@pytest.mark.no_ambient_pool``.""" - if request.node.get_closest_marker("no_ambient_pool"): - yield None - return - with portal.wrap_async_context_manager(thread_pool()) as p: - token = _current_pool.set(p) - try: - yield p - finally: - _current_pool.reset(token) +"""Shared fixtures for ``localpost.threadtools`` tests.""" From 4f262b75ec0600defc96bd38c92038c994f38359 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 20:13:41 +0400 Subject: [PATCH 251/286] feat(threadtools)!: Executor protocol + Future-based TaskGroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ambient-pool / contextvar model with explicit, caller-owned executors. ``TaskGroup`` is now a thin sync bookkeeping layer over an ``Executor``; ``localpost.http`` follows the same pattern. New surface (``localpost.threadtools``): - ``Executor`` protocol: sync context manager + ``submit`` (matches ``concurrent.futures.Executor``). - ``WorkerExecutor`` — channel-backed pool of plain ``threading.Thread``s. Lazy spawn driven by ``put_nowait`` / ``WouldBlock``. Idle workers self-exit after ``idle_timeout`` and remove their cloned receiver under the executor's lock to keep bookkeeping consistent. At ``max_concurrency`` extra submissions block on ``Channel.put``. - ``AnyIOWorkerExecutor`` — same shape but workers run via ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` over a ``BlockingPortal`` so user code can call ``anyio.from_thread.check_cancelled``. - ``AnyIOExecutor`` — one fresh AnyIO task per submit, optional ``CapacityLimiter`` for ``max_concurrency``. - ``TaskGroup(executor, *, name=None)`` — tracks a ``list[Future]``, drops successful tasks via ``add_done_callback``, drains in ``__exit__`` via ``concurrent.futures.wait``, raises a ``BaseExceptionGroup`` of body + task failures (dedup by identity). Pure collect-and-raise: no cancel signal — cooperative cancel is the AnyIO executors' concern. All three executors snapshot ``contextvars.copy_context()`` per submit, matching ``asyncio.to_thread`` / Trio / AnyIO spawn semantics. Removed (the old worker / pool machinery): - ``ThreadPool`` / ``thread_pool()``, ``Worker``, the ``_current_pool`` contextvar. - ``CancellableLock`` / ``cancellable_condition`` / ``cancellable_semaphore`` (the channel rebuild deleted ``_sync.py`` / ``_base.py`` already). ``localpost.http._pool``: ``thread_pool_handler(inner, executor)`` now takes a caller-owned executor; the wrapper opens a per-handler ``TaskGroup`` for drain semantics but does not own the executor. ``streaming_pool_handler`` stays as the alias it has been. Call-site updates: - ``localpost.hosting._host``: dropped the ambient ``thread_pool()`` context — services that need threads create their own executor. - ``localpost.http.app.HttpApp.service`` and ``localpost.openapi.app.HttpApp.service`` accept an optional ``executor=``; default opens an internal ``AnyIOWorkerExecutor`` for the lifetime of the service. - ``localpost.http.__main__`` opens an ``AnyIOWorkerExecutor`` when ``--pool`` is set. - HTTP integration test harness updated to match. Tests under ``tests/threadtools/`` are about to be rewritten in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/hosting/_host.py | 14 +- localpost/http/__main__.py | 8 +- localpost/http/_pool.py | 135 ++++--- localpost/http/app.py | 39 ++- localpost/http/compress.py | 4 +- localpost/http/flask.py | 7 +- localpost/http/flask_sentry.py | 7 +- localpost/http/router_sentry.py | 7 +- localpost/http/server_httptools.py | 7 +- localpost/http/static.py | 15 +- localpost/http/wsgi.py | 6 +- localpost/openapi/app.py | 19 +- localpost/threadtools/__init__.py | 14 + localpost/threadtools/_executor.py | 503 +++++++++++++++++++++++++++ localpost/threadtools/_task_group.py | 359 +++++-------------- tests/http/_integration_app.py | 8 +- tests/http/service.py | 62 ++-- 17 files changed, 785 insertions(+), 429 deletions(-) create mode 100644 localpost/threadtools/_executor.py diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 0de1863..e5b74e0 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -31,7 +31,6 @@ unwrap_exc, wait_all, ) -from localpost.threadtools import thread_pool logger = logging.getLogger("localpost.hosting") @@ -436,10 +435,7 @@ async def wait_stopped(): await child_lt.stopped wait_tg.cancel_scope.cancel() - # Open an ambient ``thread_pool`` alongside the portal so service - # bodies and anything they spawn can use ``threadtools.TaskGroup`` - # without a per-callsite wrapper. - async with BlockingPortal() as portal, thread_pool(): + async with BlockingPortal() as portal: child_lt = ServiceLifetime(portal) app_token = _app_lt.set(child_lt) try: @@ -491,16 +487,10 @@ async def wait_stopped(): async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: if parent is not None: - # Nested run inherits the parent's portal and ambient thread pool - # (the latter via contextvar — no extra setup needed here). lt = ServiceLifetime(parent.portal) await _run(svc_f, lt) return lt.exit_code - # Root run opens a portal and an ambient thread pool so any service - # body — and anything it transitively spawns — runs under an active - # ``thread_pool()`` context. Without this, ``threadtools.TaskGroup()`` - # would raise. - async with BlockingPortal() as portal, thread_pool(): + async with BlockingPortal() as portal: lt = ServiceLifetime(portal) app_token = _app_lt.set(lt) try: diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py index 17bc927..a89cdf3 100644 --- a/localpost/http/__main__.py +++ b/localpost/http/__main__.py @@ -10,6 +10,7 @@ from localpost import hosting from localpost.http import RequestHandler, ServerConfig, http_server, thread_pool_handler +from localpost.threadtools import AnyIOWorkerExecutor def _load_handler(app_str: str) -> RequestHandler: @@ -47,9 +48,10 @@ def main(app: str, host: str, port: int, pool: bool, selectors: int, acceptor: b @hosting.service async def _serve(): if pool: - async with thread_pool_handler(handler) as h: - async with http_server(config, h, selectors=selectors, acceptor=acceptor): - yield + with AnyIOWorkerExecutor() as executor: + async with thread_pool_handler(handler, executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield else: async with http_server(config, handler, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/http/_pool.py b/localpost/http/_pool.py index 1deedf9..a60676a 100644 --- a/localpost/http/_pool.py +++ b/localpost/http/_pool.py @@ -1,15 +1,15 @@ """Worker-pool wrapper for HTTP request handlers. :func:`thread_pool_handler` wraps a :data:`RequestHandler` so each -request runs on a worker thread with a *borrowed* connection. Body -reads (``ctx.receive(size)`` / :func:`localpost.http.read_body`) and -syscalls like ``ctx.sendfile`` block the worker, not the selector. - -Workers come from a process-wide -:class:`localpost.threadtools.TaskGroup` and are reused across -all pool wrappers / HTTP servers in the process. There is no -concurrency cap — admission control is the deployment's job -(front-LB / OS limits). +request runs on a worker thread (borrowed from a caller-owned +:class:`localpost.threadtools.Executor`) with a borrowed connection. +Body reads (``ctx.receive(size)`` / :func:`localpost.http.read_body`) +and syscalls like ``ctx.sendfile`` block the worker, not the selector. + +The executor is *not* owned by this wrapper — pass an open executor in. +The wrapper opens an internal :class:`localpost.threadtools.TaskGroup` +on the caller's executor for the duration of the ``async with`` so it +can drain only the requests it dispatched at handler shutdown. """ from __future__ import annotations @@ -17,12 +17,11 @@ import logging import threading from collections.abc import AsyncGenerator -from contextlib import AsyncExitStack, asynccontextmanager, suppress +from contextlib import asynccontextmanager, suppress from typing import cast from anyio import to_thread -from localpost import threadtools from localpost.http._base import ( PAYLOAD_TOO_LARGE_BODY, PAYLOAD_TOO_LARGE_RESPONSE, @@ -33,20 +32,15 @@ from localpost.http._cancel import RequestCancel, RequestCancelled, _enter_request from localpost.http._types import BodyTooLarge from localpost.http.config import LOGGER_NAME - -# -------------------------------------------------------------------------- -# Internal pool primitive -# -------------------------------------------------------------------------- +from localpost.threadtools import Executor, TaskGroup class _Pool: - """Dispatcher that runs request handlers on a shared - :class:`TaskGroup`. - """ + """Dispatcher that runs request handlers on a shared :class:`TaskGroup`.""" __slots__ = ("_shutdown_event", "_tg") - def __init__(self, tg: threadtools.TaskGroup, shutdown_event: threading.Event) -> None: + def __init__(self, tg: TaskGroup, shutdown_event: threading.Event) -> None: self._tg = tg self._shutdown_event = shutdown_event @@ -79,8 +73,7 @@ def pre_body(ctx: _NativeReqCtx) -> None: def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: RequestHandler) -> None: """Run a single request on the worker thread. - Mirrors the failure handling the previous worker loop performed: - body-too-large maps to 413; other exceptions are logged and turned + Body-too-large maps to 413; other exceptions are logged and turned into a generic 500 (or close on the spot if the response already started). On per-request cancellation the conn is closed because its protocol state is uncertain. @@ -91,15 +84,11 @@ def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: RequestHandler) try: fn(ctx) except RequestCancelled: - # Handler bailed cleanly. Connection state is uncertain — close. with suppress(Exception): ctx.conn.close() except BodyTooLarge: _emit_body_too_large(ctx) except Exception: - # The future captures the exception, but nothing reads it on - # the HTTP path; log here so failures are visible immediately - # rather than only at pool shutdown. logger.exception( "Pool handler raised for %s %r", ctx.request.method, @@ -107,59 +96,15 @@ def _run_request(ctx: _NativeReqCtx, cancel: RequestCancel, fn: RequestHandler) ) emit_handler_error(ctx) finally: - # Conn-release policy: - # - # On the success path the handler's response-write code already - # re-tracked the conn via ``_maybe_give_back`` — we MUST NOT touch - # it here. We can't read ``ctx.conn.tracked`` either: that field - # is shared with the next request's dispatcher, which clears it - # via ``stop_tracking`` before this finally runs. - # - # The only case left to handle here is per-request cancellation: - # the handler caught the signal and returned, but the conn is in - # an uncertain state. ``cancel.fired`` is the cheap (no-syscall) - # check. + # On the success path the handler's response-write code already re-tracked the conn + # via ``_maybe_give_back``; we MUST NOT touch it here. The only case left is per-request + # cancellation: the handler caught the signal and returned, but the conn is in an + # uncertain state. ``cancel.fired`` is the cheap (no-syscall) check. if cancel.fired: with suppress(Exception): ctx.conn.close() -@asynccontextmanager -async def _pool_context() -> AsyncGenerator[_Pool]: - """Open a worker pool backed by a :class:`TaskGroup`. - - The task group is process-wide in spirit (workers are shared across - all pools), but each ``_pool_context`` owns its own group so its - teardown drains exactly the requests it dispatched. - - Reuses the ambient :class:`localpost.threadtools.ThreadPool` when - one is set (the normal case under :func:`localpost.hosting.run_app` - / :func:`localpost.hosting.serve`); otherwise opens a private one - for the duration of this context so :func:`thread_pool_handler` can - be used standalone. - - On exit, signals in-flight handlers via the cancel event and waits - for the group to drain. Drain is offloaded to a thread so the - surrounding event loop stays responsive. - """ - # Imported lazily to avoid importing threadtools internals at - # module load time. - from localpost.threadtools import thread_pool - from localpost.threadtools._task_group import _current_pool - - async with AsyncExitStack() as stack: - if _current_pool.get(None) is None: - await stack.enter_async_context(thread_pool()) - shutdown_event = threading.Event() - tg = threadtools.TaskGroup(name="http-pool") - tg.__enter__() - try: - yield _Pool(tg, shutdown_event) - finally: - shutdown_event.set() - await to_thread.run_sync(tg.__exit__, None, None, None) - - def _emit_body_too_large(ctx: _NativeReqCtx) -> None: """Map worker-side body-limit failures to 413 instead of generic 500.""" if ctx.response_status is None: @@ -169,3 +114,47 @@ def _emit_body_too_large(ctx: _NativeReqCtx) -> None: with suppress(Exception): ctx.conn.close() + +@asynccontextmanager +async def thread_pool_handler( + inner: RequestHandler, + executor: Executor, + /, +) -> AsyncGenerator[RequestHandler]: + """Yields a :data:`RequestHandler` that runs each request on a worker + thread borrowed from ``executor``. + + ``inner`` runs on a worker on a blocking-with-timeout socket — body + reads (``ctx.receive(...)`` / :func:`localpost.http.read_body`) and + other blocking syscalls don't stall the selector. The executor is + caller-owned; this wrapper does not enter / close it. + + Per-request cancellation surfaces through + :func:`localpost.http.check_cancelled` (client disconnect via + non-blocking ``MSG_PEEK``; handler shutdown via a single + :class:`threading.Event` shared by every in-flight token). + + On exit, signals in-flight handlers via the cancel event and waits + for the internal task group to drain. The drain is offloaded to a + thread so the surrounding event loop stays responsive. + + Example:: + + with WorkerExecutor() as ex: + async with thread_pool_handler(router.as_handler(), ex) as h: + async with http_server(config, h): + ... + """ + shutdown_event = threading.Event() + tg = TaskGroup(executor, name="http-pool") + tg.__enter__() + try: + yield _Pool(tg, shutdown_event).dispatch(inner) + finally: + shutdown_event.set() + await to_thread.run_sync(tg.__exit__, None, None, None) + + +# Streaming wrapping shape ended up identical to ``thread_pool_handler`` after the dispatch +# unification — keep the alias so callers that prefer the streaming-shaped name still resolve. +streaming_pool_handler = thread_pool_handler diff --git a/localpost/http/app.py b/localpost/http/app.py index 8d7f5b9..30251f9 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -58,11 +58,12 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from localpost import hosting from localpost.http._base import HTTPReqCtx, Middleware, RequestHandler, compose -from localpost.http._pool import _Pool, _pool_context +from localpost.http._pool import thread_pool_handler from localpost.http._service import http_server from localpost.http._types import Response from localpost.http.config import ServerConfig from localpost.http.router import Routes, URITemplate, route_match +from localpost.threadtools import AnyIOWorkerExecutor, Executor __all__ = ["HttpApp"] @@ -260,7 +261,7 @@ def deco(fn: Callable[..., Any]) -> Callable[..., Any]: # ----- Building ----- - def _build_route_handler(self, route: _Route, pool: _Pool | None) -> RequestHandler: + def _build_route_handler(self, route: _Route) -> RequestHandler: resolvers = _build_resolvers(route.fn, route.path) fn = route.fn @@ -270,19 +271,17 @@ def inner(ctx: HTTPReqCtx) -> None: response, body = _wrap_response(result) ctx.complete(response, body) - handler: RequestHandler = pool.dispatch(inner) if pool is not None else inner # type: ignore[arg-type] - return self._with_route_middleware(handler, route.middleware) + return self._with_route_middleware(inner, route.middleware) def _with_route_middleware(self, handler: RequestHandler, middleware: tuple[Middleware, ...]) -> RequestHandler: if not middleware: return handler return compose(*middleware)(handler) - def _build_router_handler(self, pool: _Pool | None) -> RequestHandler: + def _build_router_handler(self) -> RequestHandler: routes = Routes() for route in self._routes: - handler = self._build_route_handler(route, pool) - routes.add(route.method, route.path, handler) + routes.add(route.method, route.path, self._build_route_handler(route)) router = routes.build().as_handler() if self._middleware: router = compose(*self._middleware)(router) @@ -294,6 +293,7 @@ def service( self, config: ServerConfig, *, + executor: Executor | None = None, selectors: int = 1, acceptor: bool = False, ): @@ -303,21 +303,32 @@ def service( :attr:`ServerConfig.backend`). Use with :func:`localpost.hosting.run_app` or :func:`localpost.hosting.serve`. - ``selectors`` and ``acceptor`` forward to :func:`http_server` — see - its docstring for the full topology rules. + ``executor`` is the thread executor that runs handlers when + ``pooled=True``; pass an already-open + :class:`localpost.threadtools.Executor` to share one across + services. When omitted (and ``pooled=True``), an + :class:`AnyIOWorkerExecutor` is opened for the lifetime of the + service. + + ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ pooled = self.pooled + inner = self._build_router_handler() @hosting.service async def _app_service(): if not pooled: - inner = self._build_router_handler(None) async with http_server(config, inner, selectors=selectors, acceptor=acceptor): yield return - async with _pool_context() as pool: - inner = self._build_router_handler(pool) - async with http_server(config, inner, selectors=selectors, acceptor=acceptor): - yield + if executor is not None: + async with thread_pool_handler(inner, executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + return + with AnyIOWorkerExecutor() as own_executor: + async with thread_pool_handler(inner, own_executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield return _app_service() diff --git a/localpost/http/compress.py b/localpost/http/compress.py index a8c9cea..6093a52 100644 --- a/localpost/http/compress.py +++ b/localpost/http/compress.py @@ -483,9 +483,7 @@ def stream(self, response: Response, chunks: Iterator[bytes], /) -> None: return encoder = _STREAM_ENCODER_FACTORIES[self._encoding]() new_headers = _streaming_rewrite_headers(response.headers, encoding=self._encoding) - new_response = Response( - status_code=response.status_code, headers=new_headers, reason=response.reason - ) + new_response = Response(status_code=response.status_code, headers=new_headers, reason=response.reason) self._inner.stream(new_response, _compress_stream(chunks, encoder)) # ----- intercepted one-shot path ----- diff --git a/localpost/http/flask.py b/localpost/http/flask.py index 3f298ab..b61781c 100644 --- a/localpost/http/flask.py +++ b/localpost/http/flask.py @@ -86,8 +86,9 @@ def flask_server(config: ServerConfig, app: Flask, /): request at a time wrap the handler with :func:`localpost.http.thread_pool_handler`:: - async with thread_pool_handler(flask_handler(app)) as h: - async with http_server(config, h): - ... + with WorkerExecutor() as ex: + async with thread_pool_handler(flask_handler(app), ex) as h: + async with http_server(config, h): + ... """ return http_server(config, flask_handler(app)) diff --git a/localpost/http/flask_sentry.py b/localpost/http/flask_sentry.py index c7cda1f..43c0ba6 100644 --- a/localpost/http/flask_sentry.py +++ b/localpost/http/flask_sentry.py @@ -18,9 +18,10 @@ sentry_sdk.init(dsn=..., traces_sample_rate=1.0) handler = sentry_flask_handler(my_flask_app) - async with thread_pool_handler(handler) as wrapped: - async with http_server(config, wrapped): - ... + with WorkerExecutor() as ex: + async with thread_pool_handler(handler, ex) as wrapped: + async with http_server(config, wrapped): + ... """ from __future__ import annotations diff --git a/localpost/http/router_sentry.py b/localpost/http/router_sentry.py index 993e316..deca2d2 100644 --- a/localpost/http/router_sentry.py +++ b/localpost/http/router_sentry.py @@ -23,9 +23,10 @@ def get_book(ctx): ... sentry_sdk.init(dsn=..., traces_sample_rate=1.0) handler = sentry_router_handler(router) - async with thread_pool_handler(handler) as wrapped: - async with http_server(config, wrapped): - ... + with WorkerExecutor() as ex: + async with thread_pool_handler(handler, ex) as wrapped: + async with http_server(config, wrapped): + ... Pure Router instrumentation — no Flask import. Use :mod:`localpost.http.flask_sentry` for Flask apps. diff --git a/localpost/http/server_httptools.py b/localpost/http/server_httptools.py index f3f850b..7e1c521 100644 --- a/localpost/http/server_httptools.py +++ b/localpost/http/server_httptools.py @@ -246,12 +246,7 @@ def on_headers_complete(self) -> None: # never observe EOM. Mark the message complete and force the # conn closed after the response — matches nginx-style "send # final response + Connection: close" behaviour. - if ( - not ctx.borrowed - and self._response_started - and self._cur_expect_100 - and not ctx._continue_sent - ): + if not ctx.borrowed and self._response_started and self._cur_expect_100 and not ctx._continue_sent: self._close_after_response = True self._message_complete = True diff --git a/localpost/http/static.py b/localpost/http/static.py index 341fe29..2e4cf15 100644 --- a/localpost/http/static.py +++ b/localpost/http/static.py @@ -66,10 +66,17 @@ def static_handler( from localpost.http import http_server, thread_pool_handler from localpost.http.static import static_handler - - h = thread_pool_handler( - static_handler("/var/www", prefix=b"/static/", cache_control="public, max-age=31536000, immutable"), - ) + from localpost.threadtools import WorkerExecutor + + with WorkerExecutor() as ex: + h = thread_pool_handler( + static_handler( + "/var/www", + prefix=b"/static/", + cache_control="public, max-age=31536000, immutable", + ), + ex, + ) """ root_path = Path(os.fspath(root)).resolve(strict=True) if not root_path.is_dir(): diff --git a/localpost/http/wsgi.py b/localpost/http/wsgi.py index dadc113..c718840 100644 --- a/localpost/http/wsgi.py +++ b/localpost/http/wsgi.py @@ -299,11 +299,7 @@ def _respond(self, start_response: Callable[..., Any]) -> Iterable[bytes]: raise RuntimeError("Handler returned without producing a response") def _check_not_started(self) -> None: - if ( - self._completed is not None - or self._streaming is not None - or self._sendfile is not None - ): + if self._completed is not None or self._streaming is not None or self._sendfile is not None: raise RuntimeError("Response already started") diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 47cf4c6..4796c09 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -43,6 +43,7 @@ def hello(name: str) -> str: from localpost.openapi.middleware import OpMiddleware from localpost.openapi.operation import Operation from localpost.openapi.schemas import SchemaRegistry +from localpost.threadtools import AnyIOWorkerExecutor, Executor __all__ = ["HttpApp"] @@ -189,6 +190,7 @@ def service( self, config: ServerConfig, *, + executor: Executor | None = None, selectors: int = 1, acceptor: bool = False, ) -> hosting.ServiceF: @@ -199,15 +201,26 @@ def service( registered operation runs on a worker after the request body is buffered by the selector. + ``executor`` is the thread executor that runs handlers; pass an + already-open :class:`localpost.threadtools.Executor` to share one + across services. When omitted, an :class:`AnyIOWorkerExecutor` is + opened for the lifetime of the service. + ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ router = self._build_router_handler() @hosting.service async def _app_service(): - async with thread_pool_handler(router) as h: - async with http_server(config, h, selectors=selectors, acceptor=acceptor): - yield + if executor is not None: + async with thread_pool_handler(router, executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield + return + with AnyIOWorkerExecutor() as own_executor: + async with thread_pool_handler(router, own_executor) as h: + async with http_server(config, h, selectors=selectors, acceptor=acceptor): + yield return _app_service() diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index 9d61fb7..a7e7ccb 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,7 +1,21 @@ from ._channel import Channel, ReceiveChannel, SendChannel +from ._executor import ( + DEFAULT_IDLE_TIMEOUT, + AnyIOExecutor, + AnyIOWorkerExecutor, + Executor, + WorkerExecutor, +) +from ._task_group import TaskGroup __all__ = [ + "DEFAULT_IDLE_TIMEOUT", + "AnyIOExecutor", + "AnyIOWorkerExecutor", "Channel", + "Executor", "ReceiveChannel", "SendChannel", + "TaskGroup", + "WorkerExecutor", ] diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py new file mode 100644 index 0000000..cf8c4b4 --- /dev/null +++ b/localpost/threadtools/_executor.py @@ -0,0 +1,503 @@ +"""Thread-management primitives for ``localpost.threadtools``. + +Three flavors, all sync context managers exposing a ``submit`` method: + +* :class:`WorkerExecutor` — channel-backed pool of plain ``threading.Thread`` + workers. Lazy spawn (driven by ``put_nowait``/``WouldBlock``), idle-timeout + self-exit. Standalone — does not require AnyIO at runtime. +* :class:`AnyIOWorkerExecutor` — same channel/lazy-spawn shape, but workers + run inside ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` via + a :class:`anyio.from_thread.BlockingPortal`. That populates AnyIO's thread + locals so user code can call :func:`anyio.from_thread.check_cancelled`. +* :class:`AnyIOExecutor` — every :meth:`submit` schedules a fresh AnyIO task + on the portal that goes through ``to_thread.run_sync``. No worker reuse. + +All three propagate the caller's :class:`contextvars.Context` to the task, +matching the semantics of :func:`asyncio.to_thread` / Trio / AnyIO spawn. +""" + +from __future__ import annotations + +import contextvars +import dataclasses as dc +import functools +import threading +from collections.abc import Callable +from concurrent.futures import Future +from contextlib import ExitStack +from types import TracebackType +from typing import Any, Protocol, Self, cast, final, runtime_checkable + +from anyio import ( + CapacityLimiter, + ClosedResourceError, + EndOfStream, + WouldBlock, + to_thread, +) +from anyio.from_thread import BlockingPortal, start_blocking_portal + +from ._channel import Channel, ReceiveChannel, SendChannel + +DEFAULT_IDLE_TIMEOUT: float = 60.0 +"""Seconds an idle worker waits for new work before self-exiting.""" + + +@runtime_checkable +class Executor(Protocol): + """Minimal executor contract — a sync context manager with ``submit``.""" + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: ... + + def submit[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> Future[R]: ... + + +@dc.dataclass(slots=True) +class _Envelope: + future: Future[Any] + fn: Callable[..., Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + context: contextvars.Context + + def run(self) -> None: + if not self.future.set_running_or_notify_cancel(): + return # Future was cancelled while queued + try: + result = self.context.run(self.fn, *self.args, **self.kwargs) + except BaseException as exc: # noqa: BLE001 + self.future.set_exception(exc) + else: + self.future.set_result(result) + + +def _make_envelope(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> _Envelope: + return _Envelope( + future=Future(), + fn=fn, + args=args, + kwargs=kwargs, + context=contextvars.copy_context(), + ) + + +# -------------------------------------------------------------------------- +# WorkerExecutor — channel + plain threads +# -------------------------------------------------------------------------- + + +@final +class WorkerExecutor: + """Channel-backed worker pool of plain ``threading.Thread``s. + + ``submit`` tries ``put_nowait`` first; on ``WouldBlock`` it spawns a new + worker (up to ``max_concurrency``) and falls through to a blocking + ``put`` (which is what backpressures the caller when at the cap). + + Workers self-exit after ``idle_timeout`` seconds without work; on exit + they remove their cloned receiver from ``open_receivers`` under the + executor's lock so submission and worker bookkeeping stay consistent. + """ + + def __init__( + self, + *, + max_concurrency: int | None = None, + backlog: int | None = 0, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT, + thread_name_prefix: str = "lp-worker", + ) -> None: + if max_concurrency is not None and max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0 or None") + if idle_timeout <= 0: + raise ValueError("idle_timeout must be > 0") + self._max_concurrency = max_concurrency + self._idle_timeout = idle_timeout + self._thread_name_prefix = thread_name_prefix + self._tx: SendChannel[_Envelope] + self._rx_template: ReceiveChannel[_Envelope] + self._tx, self._rx_template = Channel.create(capacity=backlog) + self._lock = threading.Lock() + self._open_receivers: list[ReceiveChannel[_Envelope]] = [self._rx_template] + self._workers: list[threading.Thread] = [] + self._opened = False + self._closed = False + self._next_worker_id = 0 + + @property + def open_receivers(self) -> list[ReceiveChannel[_Envelope]]: + return self._open_receivers + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("WorkerExecutor cannot be reused") + self._opened = True + # Spawn the initial worker tied to the seed receiver so the first + # ``put_nowait`` lands on a live waiter (no cold-start latency in + # rendezvous / small-backlog configs). + self._spawn_worker(self._rx_template) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + with self._lock: + if self._closed: + return + self._closed = True + # Closing the sender lets in-flight workers drain whatever is buffered + # and then observe EndOfStream on their next ``get``. + self._tx.close() + # Wait for every worker thread to terminate. + for t in list(self._workers): + t.join() + + def submit[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> Future[R]: + if not self._opened or self._closed: + raise RuntimeError("WorkerExecutor is not open") + env = _make_envelope(fn, args, kwargs) + try: + self._tx.put_nowait(env) + except WouldBlock: + with self._lock: + if self._closed: + raise RuntimeError("WorkerExecutor is closed") from None + if self._max_concurrency is None or len(self._open_receivers) < self._max_concurrency: + rx = self._rx_template.clone() + self._open_receivers.append(rx) + self._spawn_worker(rx) + self._tx.put(env) # blocks once we're at the cap + return env.future + + def _spawn_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + wid = self._next_worker_id + self._next_worker_id += 1 + t = threading.Thread( + target=self._run_worker, + args=(rx,), + name=f"{self._thread_name_prefix}-{wid}", + daemon=True, + ) + self._workers.append(t) + t.start() + + def _retire(self, rx: ReceiveChannel[_Envelope]) -> None: + try: + rx.close() + except ClosedResourceError: + pass + with self._lock: + if rx in self._open_receivers: + self._open_receivers.remove(rx) + + def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + try: + while True: + try: + env = rx.get(timeout=self._idle_timeout) + except TimeoutError: + # Idle. Close the timeout-vs-submit race under the lock: + # if a submitter slipped a task into the channel between + # ``get`` returning and us deciding to die, take it. + with self._lock: + try: + env = rx.get_nowait() + except (WouldBlock, EndOfStream, ClosedResourceError): + if rx in self._open_receivers: + self._open_receivers.remove(rx) + try: + rx.close() + except ClosedResourceError: + pass + return + except (EndOfStream, ClosedResourceError): + return + env.run() + finally: + try: + rx.close() + except ClosedResourceError: + pass + + +# -------------------------------------------------------------------------- +# AnyIOWorkerExecutor — channel + AnyIO-backed worker threads +# -------------------------------------------------------------------------- + + +@final +class AnyIOWorkerExecutor: + """Like :class:`WorkerExecutor`, but workers run via + ``anyio.to_thread.run_sync`` so user code can call + :func:`anyio.from_thread.check_cancelled`. + + Construction is sync; if no ``portal`` is passed the executor opens its + own via :func:`anyio.from_thread.start_blocking_portal` and tears it + down at exit. Pass an existing portal when integrating into an outer + AnyIO event loop. + """ + + def __init__( + self, + *, + max_concurrency: int | None = None, + backlog: int | None = 0, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT, + portal: BlockingPortal | None = None, + backend: str = "asyncio", + ) -> None: + if max_concurrency is not None and max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0 or None") + if idle_timeout <= 0: + raise ValueError("idle_timeout must be > 0") + self._max_concurrency = max_concurrency + self._idle_timeout = idle_timeout + self._tx: SendChannel[_Envelope] + self._rx_template: ReceiveChannel[_Envelope] + self._tx, self._rx_template = Channel.create(capacity=backlog) + self._lock = threading.Lock() + self._open_receivers: list[ReceiveChannel[_Envelope]] = [self._rx_template] + # ``Future``s returned by ``portal.start_task_soon`` — one per host task. + self._host_tasks: list[Future[None]] = [] + self._external_portal = portal + self._portal: BlockingPortal | None = None + self._backend = backend + self._stack: ExitStack | None = None + self._opened = False + self._closed = False + + @property + def portal(self) -> BlockingPortal: + if self._portal is None: + raise RuntimeError("AnyIOWorkerExecutor is not open") + return self._portal + + @property + def open_receivers(self) -> list[ReceiveChannel[_Envelope]]: + return self._open_receivers + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("AnyIOWorkerExecutor cannot be reused") + self._stack = ExitStack().__enter__() + if self._external_portal is not None: + self._portal = self._external_portal + else: + self._portal = self._stack.enter_context(start_blocking_portal(backend=self._backend)) + self._opened = True + self._spawn_worker(self._rx_template) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + with self._lock: + if self._closed: + return + self._closed = True + self._tx.close() + # Wait for every worker host task to finish (its ``to_thread.run_sync`` + # returns once the inner loop exits cleanly). + for f in list(self._host_tasks): + try: + f.result() + except BaseException: # noqa: BLE001, S110 + pass + if self._stack is not None: + self._stack.__exit__(exc_type, exc, tb) + self._stack = None + self._portal = None + + def submit[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> Future[R]: + if not self._opened or self._closed: + raise RuntimeError("AnyIOWorkerExecutor is not open") + env = _make_envelope(fn, args, kwargs) + try: + self._tx.put_nowait(env) + except WouldBlock: + with self._lock: + if self._closed: + raise RuntimeError("AnyIOWorkerExecutor is closed") from None + if self._max_concurrency is None or len(self._open_receivers) < self._max_concurrency: + rx = self._rx_template.clone() + self._open_receivers.append(rx) + self._spawn_worker(rx) + self._tx.put(env) + return env.future + + def _spawn_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + portal = self._portal + assert portal is not None + + async def host_task() -> None: + await to_thread.run_sync(self._run_worker, rx, abandon_on_cancel=False) + + f = portal.start_task_soon(host_task) + self._host_tasks.append(f) + + def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + try: + while True: + try: + env = rx.get(timeout=self._idle_timeout) + except TimeoutError: + with self._lock: + try: + env = rx.get_nowait() + except (WouldBlock, EndOfStream, ClosedResourceError): + if rx in self._open_receivers: + self._open_receivers.remove(rx) + try: + rx.close() + except ClosedResourceError: + pass + return + except (EndOfStream, ClosedResourceError): + return + env.run() + finally: + try: + rx.close() + except ClosedResourceError: + pass + + +# -------------------------------------------------------------------------- +# AnyIOExecutor — fresh AnyIO task per submit +# -------------------------------------------------------------------------- + + +@final +class AnyIOExecutor: + """One AnyIO task per :meth:`submit`, executed via + ``anyio.to_thread.run_sync``. + + Useful when you want every task to be its own AnyIO scope rather than + reusing a long-lived worker. ``max_concurrency`` is enforced by an + :class:`anyio.CapacityLimiter`. + """ + + def __init__( + self, + *, + max_concurrency: int | None = None, + portal: BlockingPortal | None = None, + backend: str = "asyncio", + ) -> None: + if max_concurrency is not None and max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0 or None") + self._max_concurrency = max_concurrency + self._external_portal = portal + self._portal: BlockingPortal | None = None + self._backend = backend + self._stack: ExitStack | None = None + self._limiter: CapacityLimiter | None = None + self._opened = False + self._closed = False + self._inflight: list[Future[Any]] = [] + self._inflight_lock = threading.Lock() + + @property + def portal(self) -> BlockingPortal: + if self._portal is None: + raise RuntimeError("AnyIOExecutor is not open") + return self._portal + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("AnyIOExecutor cannot be reused") + self._stack = ExitStack().__enter__() + if self._external_portal is not None: + self._portal = self._external_portal + else: + self._portal = self._stack.enter_context(start_blocking_portal(backend=self._backend)) + if self._max_concurrency is not None: + self._limiter = self._portal.call(CapacityLimiter, self._max_concurrency) + self._opened = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + if self._closed: + return + self._closed = True + # Drain in-flight submissions before closing the portal — otherwise the + # portal's task group will cancel them on shutdown. + for f in list(self._inflight): + try: + f.result() + except BaseException: # noqa: BLE001, S110 + pass + if self._stack is not None: + self._stack.__exit__(exc_type, exc, tb) + self._stack = None + self._portal = None + self._limiter = None + + def submit[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> Future[R]: + if not self._opened or self._closed: + raise RuntimeError("AnyIOExecutor is not open") + portal = self._portal + assert portal is not None + ctx = contextvars.copy_context() + bound = functools.partial(ctx.run, fn, *args, **kwargs) + limiter = self._limiter + + async def task() -> R: + if limiter is not None: + return cast("R", await to_thread.run_sync(bound, limiter=limiter, abandon_on_cancel=False)) + return cast("R", await to_thread.run_sync(bound, abandon_on_cancel=False)) + + fut = portal.start_task_soon(task) + with self._inflight_lock: + self._inflight.append(fut) + fut.add_done_callback(self._on_done) + return fut + + def _on_done(self, fut: Future[Any]) -> None: + with self._inflight_lock: + try: + self._inflight.remove(fut) + except ValueError: + pass diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index c831940..11de641 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -1,215 +1,94 @@ -from __future__ import annotations - -import contextvars -import dataclasses as dc -import functools -import inspect -import math -import threading -from collections import deque -from collections.abc import AsyncGenerator, Awaitable, Callable -from concurrent.futures import Future -from contextlib import AbstractContextManager, asynccontextmanager -from typing import Any, Protocol, Self, final, overload - -from anyio import CapacityLimiter, to_thread -from anyio.abc import TaskGroup as AioTaskGroup -from anyio.from_thread import BlockingPortal -from coverage.debug import pp - -from localpost._utils import set_cvar - -IDLE_TIMEOUT: float = 60.0 -"""Seconds an idle worker waits for new work before self-exiting.""" - -_current_pool: contextvars.ContextVar[WorkerPool] = contextvars.ContextVar("localpost.threadtools.current_pool") -"""Ambient :class:`ThreadPool` for the current async context. Set by -:func:`thread_pool`; read by :class:`TaskGroup` on construction.""" - - -@dc.dataclass(slots=True) -class Task: - future: Future[Any] - fn: Callable[..., Any] - args: tuple[Any, ...] - kwargs: dict[str, Any] - group: TaskGroup - context: contextvars.Context - """Snapshot of the caller's ContextVars at ``start_soon`` time. The user - callable runs inside this context; mutations are confined to the task.""" - - def run(self) -> None: - try: - try: - result = self.context.run(self.fn, *self.args, **self.kwargs) - if inspect.iscoroutine(result): - # The user submitted an async callable; dispatch the - # coroutine to the event loop via the pool's portal. The - # worker thread blocks on ``portal.call`` until the - # coroutine completes — same lifetime model as the sync - # path. - coro = result - result = self.group._pool.portal.call(lambda: coro) - except BaseException as exc: # noqa: BLE001 — Trio-style: capture everything - self.future.set_exception(exc) - self.group._record_error(exc) - else: - self.future.set_result(result) - finally: - self.group._task_done() - +"""A thread-friendly task group that owns a logical set of submissions to an +:class:`Executor`. -@final -class Worker: - """Idle-tracked worker with a per-worker inbox. - - Lifecycle: ``alive`` until the worker self-exits — either after - ``IDLE_TIMEOUT`` seconds with no work, or when the owning pool calls - :meth:`shutdown`. After self-exit the worker becomes a tombstone in - the pool's ``idle`` deque. The dispatcher detects tombstones via - :meth:`submit` returning ``False`` and pops the next worker / spawns - a fresh one. - - The worker thread itself is provided by the pool via - ``anyio.to_thread.run_sync(worker._run, abandon_on_cancel=False)`` — - AnyIO sets up the thread-local state needed for - :func:`anyio.from_thread.check_cancelled` to work inside user tasks. - """ +The group enforces structured concurrency: every task started inside a +``with TaskGroup(executor) as tg:`` block must complete before the block +exits. Failures are collected and surfaced as a :class:`BaseExceptionGroup`. - def __init__(self, pool: WorkerPool) -> None: - self._inbox: deque[Task] = deque() - self._cv = threading.Condition(threading.Lock()) - # Set under ``_cv`` before lock release in ``_run``; that ordering is - # what makes ``submit`` race-free vs the host ``to_thread.run_sync`` - # call returning, which can otherwise still report the worker alive - # after ``_run`` has released the lock. - self._alive = True - self._pool = pool +There is no cancel signal — running tasks are not interrupted on the first +failure. Use an executor that propagates AnyIO cancellation +(:class:`AnyIOWorkerExecutor`, :class:`AnyIOExecutor`) and have your tasks +poll :func:`anyio.from_thread.check_cancelled` if cooperative cancel is +needed in your application. +""" - def wakeup(self) -> None: - with self._cv: - self._cv.notify() +from __future__ import annotations - def submit(self, task: Task) -> bool: - """Hand off a task. Returns ``False`` if the worker has self-exited.""" - with self._cv: - if not self._alive: - return False - self._inbox.append(task) - self._cv.notify() - return True +import threading +from collections.abc import Awaitable, Callable +from concurrent.futures import ALL_COMPLETED, Future, wait +from types import TracebackType +from typing import Any, Self, final, overload - def _run(self) -> None: - while not self._pool._closed: - with self._cv: - while not self._inbox: - # TODO Can we use wait_for here? - if not self._cv.wait(timeout=IDLE_TIMEOUT): - # Idle timeout or pool closed. - # One more inbox check under the lock to close the timeout-vs-notify race. - if not self._inbox: - self._alive = False - return - break - task = self._inbox.popleft() - task.run() - self._pool._idle.append(self) +from ._executor import Executor @final class TaskGroup: - """Trio-style task group running sync callables on a shared thread pool. - - Tasks submitted via :meth:`start_soon` run on workers borrowed from - the ambient :class:`localpost.threadtools.ThreadPool`. Construction - requires an active ``thread_pool()`` context — typically provided by - :func:`localpost.hosting.run_app`. Without one, ``__init__`` raises - :class:`RuntimeError`. - - Workers are spawned on demand, reused across all ``TaskGroup`` - instances under the same pool, and self-exit after - :data:`IDLE_TIMEOUT` seconds of idleness. There is no concurrency - cap from this class (the pool may impose one via its - :class:`anyio.CapacityLimiter`). - - Lifetime: sync context manager. On exit, blocks until every task - started inside the ``with`` block has finished, then re-raises any - task exceptions wrapped in an :class:`ExceptionGroup` (Trio - ``strict_exception_groups=True`` semantics — a body exception and - task exceptions are merged into one group). - - Example:: - - async with thread_pool(): - with TaskGroup() as tg: - tg.start_soon(do_work, arg) # fire-and-forget - fut = tg.create_task(other_work) # observe via Future - # On exit: drains in-flight tasks; raises ExceptionGroup if any failed. - - Both :meth:`start_soon` and :meth:`create_task` are callable from - any thread, including from inside a task running on the same group - (recursive spawn). ``start_soon`` is fire-and-forget; ``create_task`` - returns a :class:`concurrent.futures.Future` that captures the task's - result or exception. Either way, task exceptions are surfaced via the - ``ExceptionGroup`` raised at ``__exit__`` — for ``create_task``, - reading the future's exception does not suppress that group raise. - - ``contextvars`` are propagated: each spawn snapshots the caller's - context with :func:`contextvars.copy_context`, and the task runs - inside that snapshot. Mutations the task makes to ContextVars stay - confined to its copy — same semantics as :func:`asyncio.to_thread` - and Trio / AnyIO task spawn. - - Async callables are accepted by both spawn methods: the coroutine is - dispatched to the pool's portal via - :meth:`anyio.from_thread.BlockingPortal.call`, run from inside the - worker thread (the worker blocks until the coroutine returns). - - Cancellation: because workers are spawned via - ``anyio.to_thread.run_sync`` under the pool, user code can call - :func:`anyio.from_thread.check_cancelled` to observe cancellation of - the pool's host scope. Tasks that don't poll won't be interrupted. + """Trio-style task group backed by a user-supplied :class:`Executor`. + + Tasks are submitted via :meth:`start_soon` (fire-and-forget) or + :meth:`create_task` (returns a :class:`~concurrent.futures.Future`). + The group is a sync context manager; on exit it waits for every + in-flight task — including ones spawned recursively from inside other + tasks — to settle, then re-raises any failures wrapped in a + :class:`BaseExceptionGroup` (Trio ``strict_exception_groups=True`` + semantics: a body exception and task exceptions are merged into one + group). + + ``contextvars`` propagation, thread-of-execution, and concurrency + limits are the executor's concern. The task group is a thin + bookkeeping layer. """ - def __init__(self, pool: Executor, *, name: str | None = None) -> None: - self._name = name # TODO Remove - self._pool = pool + def __init__(self, executor: Executor, *, name: str | None = None) -> None: + self._executor = executor + self._name = name self._lock = threading.Lock() - self._cv = threading.Condition(self._lock) - self._pending = 0 - # ``deque.append`` is documented thread-safe (vs ``list.append`` which - # is only atomic by CPython implementation accident). Workers append - # without holding any group-level lock; ``__exit__`` reads after - # drain, so the ``_cv`` release/acquire in ``_task_done`` provides - # the visibility barrier. - self._errors: deque[BaseException] = deque() + # ``_tasks`` holds every Future the group has produced. Successful tasks + # are removed by ``_on_done``; failed / cancelled tasks remain so we can + # collect their exceptions at exit. + self._tasks: list[Future[Any]] = [] + self._opened = False self._closed = False def __enter__(self) -> Self: if self._closed: raise RuntimeError("TaskGroup cannot be reused") + self._opened = True return self - def __exit__(self, exc_type: object, exc: BaseException | None, tb: object) -> None: - # Drain. Nested ``start_soon`` from in-flight tasks is allowed — the - # while loop re-checks after each notify. - with self._cv: - while self._pending > 0: - self._cv.wait() + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + # Drain. Tasks may spawn more tasks; loop until no pending remain. + while True: + with self._lock: + pending = [t for t in self._tasks if not t.done()] + if not pending: + break + wait(pending, return_when=ALL_COMPLETED) + + with self._lock: self._closed = True - # Seed from ``_errors`` first so recorded tasks keep their original - # order. Prepend the body exception only if it isn't already in - # there — a task exception observed via ``Future.result()`` and let - # to propagate is the same instance as the one in ``_errors``; - # surfacing it once preserves both dedup and record order. - all_errors: list[BaseException] = list(self._errors) + failed = list(self._tasks) + + all_errors: list[BaseException] = [] + for f in failed: + if f.cancelled(): + continue + e = f.exception() + if e is not None: + all_errors.append(e) + # Dedup body exception by identity, prepended to preserve task-completion + # order for everything else. if exc is not None and all(e is not exc for e in all_errors): all_errors.insert(0, exc) if all_errors: label = f"TaskGroup {self._name!r} failed" if self._name else "TaskGroup failed" - # ``BaseExceptionGroup(...)`` returns ``ExceptionGroup`` when every - # member is an ``Exception`` subclass, ``BaseExceptionGroup`` otherwise - # — matches Trio semantics for ``KeyboardInterrupt`` / ``SystemExit``. raise BaseExceptionGroup(label, all_errors) @overload @@ -217,11 +96,11 @@ def start_soon[**P](self, fn: Callable[P, Awaitable[Any]], /, *args: P.args, **k @overload def start_soon[**P](self, fn: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs) -> None: ... def start_soon(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> None: - """Submit ``fn(*args, **kwargs)`` to a worker thread. Fire-and-forget. + """Submit ``fn(*args, **kwargs)`` to the executor. Fire-and-forget. - Errors still surface via the ``ExceptionGroup`` raised at + Errors still surface via the :class:`BaseExceptionGroup` raised at ``__exit__``. Use :meth:`create_task` if you need to observe the - task's result or exception via a :class:`concurrent.futures.Future`. + task's outcome via a :class:`~concurrent.futures.Future`. """ self.create_task(fn, *args, **kwargs) @@ -230,81 +109,31 @@ def create_task[**P, R](self, fn: Callable[P, Awaitable[R]], /, *args: P.args, * @overload def create_task[**P, R](self, fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: ... def create_task(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Future[Any]: - """Submit ``fn(*args, **kwargs)`` to a worker thread. Returns a future. + """Submit ``fn(*args, **kwargs)``. Returns the executor's future. - ``fn`` may be sync or async. Async callables are dispatched to - the pool's portal; the worker invokes ``portal.call`` to await - the coroutine on the event loop. - - The returned future is observation-only. ``Future.cancel()`` only - succeeds while the task is still queued; it cannot interrupt a - running task. Reading ``.exception()`` does not suppress the - ``ExceptionGroup`` raised at ``__exit__``. + Reading ``Future.exception()`` does *not* suppress the + :class:`BaseExceptionGroup` raised at ``__exit__`` — body code that + re-raises a task exception will see it deduped (by identity) inside + the group. """ with self._lock: if self._closed: - raise RuntimeError("TaskGroup is closed") # TODO AnyIO ClosedResourceError ? - self._pending += 1 - fut: Future[Any] = Future() - # Snapshot the caller's context (matches Trio / AnyIO / asyncio semantics) - task = Task(fut, fn, args, kwargs, self, contextvars.copy_context()) - while True: - if self._pool.get_idle_worker().submit(task): - return fut - # Tombstone (worker self-died on idle timeout), pop the next one - - def _task_done(self) -> None: - with self._cv: - self._pending -= 1 - if self._pending == 0: - self._cv.notify_all() - - def _record_error(self, exc: BaseException) -> None: - # No lock needed: ``deque.append`` is documented thread-safe (vs - # ``list.append`` which is only atomic by CPython implementation - # accident). Memory visibility to ``__exit__`` is established by - # ``_task_done``'s acquire of ``_cv``, which always runs after - # ``_record_error`` in the same task. - self._errors.append(exc) - - -class Executor(Protocol): - def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: ... - - -# Channel based worker pool. Starts from 1 worker (for the channel reader that was just created). -# A worker: -# wait on channel_receiver.get(timeout=60), if the timeout happened (built-in TimeoutError?) — just stop the worker. -# if .close() is callaed from the manager (executor shutdown) — stop the worker. -# (ChannelReceiver.close() should notify the worker somehow, so we can wake up in .get()) -# Submission: -# 1. future created -# 2. new worker started, if needed -# 3. task submitted to the channel -class WorkerExecutor: - def __init__(self, *, max_concurrency: int | None = None, backlog: int | None = None) -> None: - # max_concurrency: maximum number of workers to run concurrently (or infinite if None) - # backlog: maximum number of pending tasks to queue before blocking (or infinite if None) - pass - - def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: - pass - - async def warmup(self, n: int, /) -> None: - pass - - -# Same as WorkerExecutor, but each worker starts via AnyIO loop thread, so `from_thread.check_cancelled()` is available -class AnyIOWorkerExecutor: - def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: - pass - - async def warmup(self, n: int, /) -> None: - pass - - -# Each task — AnyIO task (from_thread -> to_thread), a roundrtip via AnyIO loop thread -class AnyIOExecutor: - # TODO max_concurrency & backpressure (backlog, max_size) - via Channel - def submit[R](fn: Callable[..., R], /, *args, **kwargs) -> Future[R]: - pass + raise RuntimeError("TaskGroup is closed") + if not self._opened: + raise RuntimeError("TaskGroup must be entered before submitting tasks") + fut = self._executor.submit(fn, *args, **kwargs) + with self._lock: + self._tasks.append(fut) + fut.add_done_callback(self._on_done) + return fut + + def _on_done(self, f: Future[Any]) -> None: + if f.cancelled(): + return + if f.exception() is not None: + return # keep in ``_tasks`` for collection at __exit__ + with self._lock: + try: + self._tasks.remove(f) + except ValueError: + pass diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index 8e71455..ccebf5b 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -97,6 +97,7 @@ def _main() -> int: from localpost.hosting import run, service # noqa: PLC0415 from localpost.hosting.middleware import shutdown_on_signal # noqa: PLC0415 from localpost.http import ServerConfig, http_server, thread_pool_handler # noqa: PLC0415 + from localpost.threadtools import WorkerExecutor # noqa: PLC0415 # Honor LP_TEST_BACKEND so tests can pin asyncio vs trio. backend = os.environ.get("LP_TEST_BACKEND", "asyncio") @@ -106,9 +107,10 @@ def _main() -> int: @service async def app(): - async with thread_pool_handler(handler) as wrapped: - async with http_server(cfg, wrapped): - yield + with WorkerExecutor() as executor: + async with thread_pool_handler(handler, executor) as wrapped: + async with http_server(cfg, wrapped): + yield svc = shutdown_on_signal()(app()) return anyio.run(run, svc, None, backend=backend) diff --git a/tests/http/service.py b/tests/http/service.py index 3df2dfd..d3f88e9 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -33,6 +33,7 @@ streaming_pool_handler, thread_pool_handler, ) +from localpost.threadtools import WorkerExecutor pytestmark = pytest.mark.anyio @@ -49,9 +50,10 @@ async def _serve_pooled( Tests own shutdown via the yielded lifetime; the pool drains on exit. """ - async with thread_pool_handler(handler) as wrapped: - async with serve(http_server(cfg, wrapped, selectors=selectors, acceptor=acceptor)) as lt: - yield lt + with WorkerExecutor() as executor: + async with thread_pool_handler(handler, executor) as wrapped: + async with serve(http_server(cfg, wrapped, selectors=selectors, acceptor=acceptor)) as lt: + yield lt def _handler_200(body: bytes = b"ok"): @@ -287,27 +289,28 @@ async def test_serves_requests_pooled_concurrent(self, free_port): """selectors=4 + thread pool. Multiple selectors push onto the single shared channel; the pool drains across all producers.""" cfg = ServerConfig(host="127.0.0.1", port=free_port) - async with thread_pool_handler(_handler_200(b"hi")) as wrapped: - async with serve(http_server(cfg, wrapped, selectors=4)) as lt: - await lt.started - await _wait_server_ready(free_port) + with WorkerExecutor() as executor: + async with thread_pool_handler(_handler_200(b"hi"), executor) as wrapped: + async with serve(http_server(cfg, wrapped, selectors=4)) as lt: + await lt.started + await _wait_server_ready(free_port) - results: list[httpx.Response | None] = [None] * 10 + results: list[httpx.Response | None] = [None] * 10 - async def fire(i: int) -> None: - results[i] = await _get(f"http://127.0.0.1:{free_port}/") + async def fire(i: int) -> None: + results[i] = await _get(f"http://127.0.0.1:{free_port}/") - async with anyio.create_task_group() as tg: - for i in range(10): - tg.start_soon(fire, i) + async with anyio.create_task_group() as tg: + for i in range(10): + tg.start_soon(fire, i) - for r in results: - assert r is not None - assert r.status_code == 200 - assert r.text == "hi" + for r in results: + assert r is not None + assert r.status_code == 200 + assert r.text == "hi" - lt.shutdown() - await lt.stopped + lt.shutdown() + await lt.stopped assert lt.exit_code == 0 async def test_shutdown_stops_all_selectors(self, free_port): @@ -556,16 +559,17 @@ def work(_ctx: HTTPReqCtx) -> None: ran.set() try: - async with streaming_pool_handler(work) as wrapped: - assert wrapped(cast(HTTPReqCtx, ctx)) is None - assert ctx.deferred is not None - assert selector.stopped is False - assert ran.wait(0.05) is False - - ctx.deferred() - assert await to_thread.run_sync(lambda: ran.wait(2.0)) - assert selector.stopped is True - assert ctx.conn.tracked is False + with WorkerExecutor() as executor: + async with streaming_pool_handler(work, executor) as wrapped: + assert wrapped(cast(HTTPReqCtx, ctx)) is None + assert ctx.deferred is not None + assert selector.stopped is False + assert ran.wait(0.05) is False + + ctx.deferred() + assert await to_thread.run_sync(lambda: ran.wait(2.0)) + assert selector.stopped is True + assert ctx.conn.tracked is False finally: sock.close() peer.close() From 7c27f9756328b20a770bfef371d74e5f6d7aa440 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 20:33:20 +0400 Subject: [PATCH 252/286] test(threadtools): rewrite tests for executor / TaskGroup API - conftest.py replaces ambient-pool fixture with per-test WorkerExecutor. - executor.py: new file covering submit/result, exception capture, lazy worker spawn, max_concurrency, idle-timeout self-exit, contextvars propagation, plus AnyIOWorkerExecutor / AnyIOExecutor smoke and capacity paths. - thread_pool.py: deleted. - thread_task_group.py: ported to TaskGroup(executor); async-callable tests dropped. - HTTP integration tests + example servers updated to pass an open WorkerExecutor to thread_pool_handler / streaming_pool_handler. Also fix a bug the new tests caught: when a worker idle-timed out it closed the shared receive-channel template, dropping open_receive_channels to zero. The template is now retained for the executor's lifetime; workers always run on a clone they own. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/compressed_api.py | 8 +- examples/http/flask_app_server.py | 8 +- examples/http/middleware_basic_auth.py | 8 +- examples/http/middleware_rate_limit.py | 8 +- examples/http/multithread_server.py | 8 +- examples/http/sentry_router_server.py | 8 +- examples/http/sse_compressed.py | 22 +- examples/http/static_files.py | 8 +- examples/http/wsgi_app_server.py | 9 +- localpost/threadtools/_executor.py | 20 +- tests/http/acceptor_stress.py | 1 - tests/http/app.py | 1 - tests/http/body.py | 1 - tests/http/compress.py | 5 - tests/http/router.py | 3 - tests/http/router_props.py | 1 - tests/http/server.py | 2 - tests/http/service.py | 3 - tests/http/wsgi_to.py | 2 - tests/threadtools/conftest.py | 15 + tests/threadtools/executor.py | 257 +++++++++++++++++ tests/threadtools/thread_pool.py | 118 -------- tests/threadtools/thread_task_group.py | 382 +++++-------------------- 23 files changed, 412 insertions(+), 486 deletions(-) create mode 100644 tests/threadtools/executor.py delete mode 100644 tests/threadtools/thread_pool.py diff --git a/examples/http/compressed_api.py b/examples/http/compressed_api.py index 0145038..46f14b3 100644 --- a/examples/http/compressed_api.py +++ b/examples/http/compressed_api.py @@ -35,6 +35,7 @@ http_server, thread_pool_handler, ) +from localpost.threadtools import WorkerExecutor def _items(ctx: HTTPReqCtx) -> BodyHandler | None: @@ -69,9 +70,10 @@ async def app(): h = compress_handler(build_router().as_handler(), algorithms=("gzip",)) config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(h) as wrapped: - async with http_server(config, wrapped): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(h, ex) as wrapped: + async with http_server(config, wrapped): + yield def main() -> int: diff --git a/examples/http/flask_app_server.py b/examples/http/flask_app_server.py index 578558c..bfc31ea 100644 --- a/examples/http/flask_app_server.py +++ b/examples/http/flask_app_server.py @@ -25,6 +25,7 @@ from localpost.hosting import run_app, service from localpost.http import ServerConfig, http_server, thread_pool_handler from localpost.http.flask import flask_handler +from localpost.threadtools import WorkerExecutor def build_app() -> Flask: @@ -55,9 +56,10 @@ def _log_teardown(exc): @service async def app_svc(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(flask_handler(build_app())) as wrapped: - async with http_server(config, wrapped): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(flask_handler(build_app()), ex) as wrapped: + async with http_server(config, wrapped): + yield def main() -> int: diff --git a/examples/http/middleware_basic_auth.py b/examples/http/middleware_basic_auth.py index 3a76696..188e47b 100644 --- a/examples/http/middleware_basic_auth.py +++ b/examples/http/middleware_basic_auth.py @@ -31,6 +31,7 @@ route_match, thread_pool_handler, ) +from localpost.threadtools import WorkerExecutor # ----------- credentials store (replace with a real check) ---------------- @@ -102,9 +103,10 @@ def build_router() -> RequestHandler: @service async def app(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(build_router()) as h: - async with http_server(config, h): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(build_router(), ex) as h: + async with http_server(config, h): + yield def main() -> int: diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py index b65df1d..5cc4a1e 100644 --- a/examples/http/middleware_rate_limit.py +++ b/examples/http/middleware_rate_limit.py @@ -34,6 +34,7 @@ http_server, thread_pool_handler, ) +from localpost.threadtools import WorkerExecutor # ----------- rate-limit state (selector-thread-safe via lock) ------------- @@ -112,9 +113,10 @@ def build_handler() -> RequestHandler: @service async def app(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(build_handler()) as h: - async with http_server(config, h): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(build_handler(), ex) as h: + async with http_server(config, h): + yield def main() -> int: diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index 3f46cc9..e32d7e2 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -34,6 +34,7 @@ route_match, thread_pool_handler, ) +from localpost.threadtools import WorkerExecutor def _emit(ctx: HTTPReqCtx, body: bytes) -> None: @@ -74,9 +75,10 @@ def build_router() -> Router: @service async def app(): config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(build_router().as_handler()) as wrapped: - async with http_server(config, wrapped): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(build_router().as_handler(), ex) as wrapped: + async with http_server(config, wrapped): + yield def main() -> int: diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index 199976d..c76cb95 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -33,6 +33,7 @@ thread_pool_handler, ) from localpost.http.router_sentry import sentry_router_handler +from localpost.threadtools import WorkerExecutor def _emit(ctx: HTTPReqCtx, body: bytes) -> None: @@ -70,9 +71,10 @@ def build_router(): async def app(): handler = sentry_router_handler(build_router()) config = ServerConfig(host="127.0.0.1", port=8000) - async with thread_pool_handler(handler) as wrapped: - async with http_server(config, wrapped): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(handler, ex) as wrapped: + async with http_server(config, wrapped): + yield def main() -> int: diff --git a/examples/http/sse_compressed.py b/examples/http/sse_compressed.py index c4ae4a3..791a9fd 100644 --- a/examples/http/sse_compressed.py +++ b/examples/http/sse_compressed.py @@ -42,6 +42,7 @@ http_server, streaming_pool_handler, ) +from localpost.threadtools import WorkerExecutor def _events(ctx: HTTPReqCtx) -> None: @@ -69,16 +70,17 @@ def ticks() -> Iterator[bytes]: async def app(): # Streaming SSE handlers run on a streaming pool — body is not # pre-buffered, the worker holds the borrowed conn for the duration. - async with streaming_pool_handler(_events) as inner: - # Same compress_handler call as for JSON APIs; the middleware - # picks the streaming path automatically when the response has no - # Content-Length and the content type is in the allowlist (which - # text/event-stream is, by default). - wrapped = compress_handler(inner, algorithms=("br", "gzip")) - - config = ServerConfig(host="127.0.0.1", port=8000) - async with http_server(config, wrapped): - yield + with WorkerExecutor() as ex: + async with streaming_pool_handler(_events, ex) as inner: + # Same compress_handler call as for JSON APIs; the middleware + # picks the streaming path automatically when the response has no + # Content-Length and the content type is in the allowlist (which + # text/event-stream is, by default). + wrapped = compress_handler(inner, algorithms=("br", "gzip")) + + config = ServerConfig(host="127.0.0.1", port=8000) + async with http_server(config, wrapped): + yield def main() -> int: diff --git a/examples/http/static_files.py b/examples/http/static_files.py index 0335755..45ed693 100644 --- a/examples/http/static_files.py +++ b/examples/http/static_files.py @@ -33,6 +33,7 @@ static_handler, thread_pool_handler, ) +from localpost.threadtools import WorkerExecutor def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: @@ -71,9 +72,10 @@ async def app(): def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: return (static if ctx.request.path.startswith(b"/static/") else api)(ctx) - async with thread_pool_handler(dispatch) as wrapped: - async with http_server(config, wrapped): - yield + with WorkerExecutor() as ex: + async with thread_pool_handler(dispatch, ex) as wrapped: + async with http_server(config, wrapped): + yield def main() -> int: diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py index 87775ed..f9df3f2 100644 --- a/examples/http/wsgi_app_server.py +++ b/examples/http/wsgi_app_server.py @@ -19,6 +19,7 @@ from localpost.hosting import run_app, service from localpost.http import ServerConfig, http_server, thread_pool_handler, wrap_wsgi +from localpost.threadtools import WorkerExecutor def build_app() -> Flask: @@ -47,10 +48,10 @@ async def wsgi_app_service(): config = ServerConfig(host="127.0.0.1", port=8000) # WSGI views block on response-body iteration, so wrap with a thread pool # to serve more than one request at a time. - async with thread_pool_handler(wrap_wsgi(build_app())) as wrapped: - async with http_server(config, wrapped): - yield - + with WorkerExecutor() as ex: + async with thread_pool_handler(wrap_wsgi(build_app()), ex) as wrapped: + async with http_server(config, wrapped): + yield def main() -> int: logging.basicConfig(level=logging.INFO) diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index cf8c4b4..ab8cfc2 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -128,10 +128,15 @@ def __init__( self._idle_timeout = idle_timeout self._thread_name_prefix = thread_name_prefix self._tx: SendChannel[_Envelope] + # ``_rx_template`` is kept open for the lifetime of the executor so + # ``open_receive_channels`` never drops to zero between worker spawns + # — that would make the next ``put_nowait`` raise ClosedResourceError. + # Workers always run on a clone; when they retire they close the clone, + # never the template. self._rx_template: ReceiveChannel[_Envelope] self._tx, self._rx_template = Channel.create(capacity=backlog) self._lock = threading.Lock() - self._open_receivers: list[ReceiveChannel[_Envelope]] = [self._rx_template] + self._open_receivers: list[ReceiveChannel[_Envelope]] = [] self._workers: list[threading.Thread] = [] self._opened = False self._closed = False @@ -145,10 +150,6 @@ def __enter__(self) -> Self: if self._closed: raise RuntimeError("WorkerExecutor cannot be reused") self._opened = True - # Spawn the initial worker tied to the seed receiver so the first - # ``put_nowait`` lands on a live waiter (no cold-start latency in - # rendezvous / small-backlog configs). - self._spawn_worker(self._rx_template) return self def __exit__( @@ -164,9 +165,10 @@ def __exit__( # Closing the sender lets in-flight workers drain whatever is buffered # and then observe EndOfStream on their next ``get``. self._tx.close() - # Wait for every worker thread to terminate. for t in list(self._workers): t.join() + # Drop the template once every worker is gone. + self._rx_template.close() def submit[**P, R]( self, @@ -278,7 +280,9 @@ def __init__( self._rx_template: ReceiveChannel[_Envelope] self._tx, self._rx_template = Channel.create(capacity=backlog) self._lock = threading.Lock() - self._open_receivers: list[ReceiveChannel[_Envelope]] = [self._rx_template] + # See ``WorkerExecutor`` — the template stays open for the executor's + # lifetime so the channel never sees ``open_receive_channels == 0``. + self._open_receivers: list[ReceiveChannel[_Envelope]] = [] # ``Future``s returned by ``portal.start_task_soon`` — one per host task. self._host_tasks: list[Future[None]] = [] self._external_portal = portal @@ -307,7 +311,6 @@ def __enter__(self) -> Self: else: self._portal = self._stack.enter_context(start_blocking_portal(backend=self._backend)) self._opened = True - self._spawn_worker(self._rx_template) return self def __exit__( @@ -328,6 +331,7 @@ def __exit__( f.result() except BaseException: # noqa: BLE001, S110 pass + self._rx_template.close() if self._stack is not None: self._stack.__exit__(exc_type, exc, tb) self._stack = None diff --git a/tests/http/acceptor_stress.py b/tests/http/acceptor_stress.py index ece3d39..534309e 100644 --- a/tests/http/acceptor_stress.py +++ b/tests/http/acceptor_stress.py @@ -43,7 +43,6 @@ def handler(ctx: HTTPReqCtx) -> None: Response(status_code=200, headers=[(b"content-length", b"2")]), b"ok", ) - return None return handler diff --git a/tests/http/app.py b/tests/http/app.py index 34bc3d5..cacff46 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -708,6 +708,5 @@ def hit() -> bytes: assert captured == ["upload:1024", "ping"] - # Avoid "imported but unused" lints — the helper is part of the public smoke API. _keep = (contextlib,) diff --git a/tests/http/body.py b/tests/http/body.py index 5954aee..c2b34b1 100644 --- a/tests/http/body.py +++ b/tests/http/body.py @@ -6,7 +6,6 @@ from localpost.http import BodyTooLarge, aread_body, read_body - # --- Sync ---------------------------------------------------------------- diff --git a/tests/http/compress.py b/tests/http/compress.py index b19fab9..8359e85 100644 --- a/tests/http/compress.py +++ b/tests/http/compress.py @@ -265,7 +265,6 @@ def _emit(ctx: HTTPReqCtx, content_type: bytes, body: bytes, **headers: bytes) - def _make_handler(content_type: bytes, body: bytes): def handler(ctx: HTTPReqCtx) -> None: _emit(ctx, content_type, body) - return None return handler @@ -512,7 +511,6 @@ def chunks() -> Iterator[bytes]: Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]), chunks(), ) - return None return handler @@ -557,7 +555,6 @@ def chunks() -> Iterator[bytes]: ), chunks(), ) - return None h = compress_handler(handler, algorithms=("gzip",)) with serve_backend_in_thread(h) as port: @@ -588,7 +585,6 @@ def chunks() -> Iterator[bytes]: ), chunks(), ) - return None h = compress_handler(handler, algorithms=("gzip",)) with serve_backend_in_thread(h) as port: @@ -646,7 +642,6 @@ def chunks() -> Iterator[bytes]: Response(status_code=200, headers=[(b"content-type", b"text/event-stream")]), chunks(), ) - return None h = compress_handler(handler, algorithms=("gzip",)) with serve_backend_in_thread(h) as port, socket.create_connection(("127.0.0.1", port), timeout=5) as s: diff --git a/tests/http/router.py b/tests/http/router.py index cd7487c..2f4744f 100644 --- a/tests/http/router.py +++ b/tests/http/router.py @@ -38,7 +38,6 @@ def handler(ctx: HTTPReqCtx) -> None: ), body, ) - return None return handler @@ -121,7 +120,6 @@ def handler(ctx: HTTPReqCtx) -> None: ), b"ok", ) - return None routes = Routes() routes.get("/books/{book_id}")(handler) @@ -271,7 +269,6 @@ def handler(ctx: HTTPReqCtx) -> None: m = route_match(ctx) captured["name"] = m.path_args["name"] ctx.complete(Response(200, [(b"content-length", b"2")]), b"ok") - return None routes = Routes() routes.get("/u/{name}")(handler) diff --git a/tests/http/router_props.py b/tests/http/router_props.py index 1b79e82..6f4a584 100644 --- a/tests/http/router_props.py +++ b/tests/http/router_props.py @@ -111,7 +111,6 @@ class _Outcome: def _route_handler(ctx: HTTPReqCtx) -> None: ctx.complete(Response(200, [(b"content-length", b"0")]), b"") - return None def _build_router(routes_list: list[tuple[str, frozenset[HTTPMethod], str]]) -> Router: diff --git a/tests/http/server.py b/tests/http/server.py index 22340b5..f12a21b 100644 --- a/tests/http/server.py +++ b/tests/http/server.py @@ -464,8 +464,6 @@ def handler(ctx: HTTPReqCtx) -> None: assert b"resp-for-/b" in data - - # --- Stale-connection cleanup ------------------------------------------------ diff --git a/tests/http/service.py b/tests/http/service.py index d3f88e9..b8b5811 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -236,7 +236,6 @@ def get_book(ctx: HTTPReqCtx) -> None: ), body, ) - return None assert get_book is not None router = routes.build() @@ -361,7 +360,6 @@ def handler(ctx: HTTPReqCtx) -> None: Response(status_code=200, headers=[(b"content-length", b"2")]), b"hi", ) - return None cfg = ServerConfig(host="127.0.0.1", port=free_port) async with serve(http_server(cfg, handler, selectors=4, acceptor=True)) as lt: @@ -491,7 +489,6 @@ def hit(ctx: HTTPReqCtx) -> None: ), b"ok", ) - return None assert hit is not None router = routes.build() diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py index 9c783f6..957beb1 100644 --- a/tests/http/wsgi_to.py +++ b/tests/http/wsgi_to.py @@ -11,8 +11,6 @@ from collections.abc import Iterable, Iterator from typing import Any -import pytest - from localpost.http import ( HTTPReqCtx, Response, diff --git a/tests/threadtools/conftest.py b/tests/threadtools/conftest.py index 83cb2b0..8dc3b91 100644 --- a/tests/threadtools/conftest.py +++ b/tests/threadtools/conftest.py @@ -1 +1,16 @@ """Shared fixtures for ``localpost.threadtools`` tests.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from localpost.threadtools import WorkerExecutor + + +@pytest.fixture +def executor() -> Iterator[WorkerExecutor]: + """A per-test :class:`WorkerExecutor` opened as a sync context manager.""" + with WorkerExecutor() as ex: + yield ex diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py new file mode 100644 index 0000000..be0e052 --- /dev/null +++ b/tests/threadtools/executor.py @@ -0,0 +1,257 @@ +"""Tests for the ``localpost.threadtools`` executors. + +Three executors are exercised here: + +- :class:`WorkerExecutor` — channel-backed pool of plain ``threading.Thread`` + workers. Most tests target this one — it has no external loop dependency. +- :class:`AnyIOWorkerExecutor` — same shape but workers spawn through a + ``BlockingPortal`` + ``to_thread.run_sync``. The cross-backend + ``check_cancelled`` test lives here. +- :class:`AnyIOExecutor` — one AnyIO task per submit. Shorter coverage — + enough to confirm submit/result/exception flow. +""" + +from __future__ import annotations + +import contextvars +import threading +import time + +import anyio +import anyio.from_thread +import pytest + +from localpost.threadtools import ( + AnyIOExecutor, + AnyIOWorkerExecutor, + WorkerExecutor, +) +from localpost.threadtools import _executor as _ex + + +@pytest.fixture +def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch) -> float: + """Make idle-timeout tests fast: 100 ms instead of 60 s.""" + monkeypatch.setattr(_ex, "DEFAULT_IDLE_TIMEOUT", 0.1) + return 0.1 + + +# --------------------------------------------------------------------------- +# WorkerExecutor — submit / result +# --------------------------------------------------------------------------- + + +def test_submit_returns_future_with_result(executor: WorkerExecutor): + fut = executor.submit(lambda x: x * 2, 21) + assert fut.result(timeout=5) == 42 + + +def test_many_concurrent_submissions_all_complete(executor: WorkerExecutor): + n = 50 + + def work(i: int) -> int: + time.sleep(0.005) + return i * i + + futs = [executor.submit(work, i) for i in range(n)] + assert sorted(f.result() for f in futs) == [i * i for i in range(n)] + + +def test_submit_with_kwargs(executor: WorkerExecutor): + fut = executor.submit(lambda *, a, b: a + b, a=1, b=2) + assert fut.result(timeout=5) == 3 + + +def test_submit_propagates_exception_to_future(executor: WorkerExecutor): + class Boom(Exception): + pass + + def bad(): + raise Boom("nope") + + fut = executor.submit(bad) + with pytest.raises(Boom): + fut.result(timeout=5) + + +# --------------------------------------------------------------------------- +# Lifecycle / lazy spawn / max_concurrency +# --------------------------------------------------------------------------- + + +def test_lazy_worker_spawn_under_max_concurrency(): + """Workers are spawned on demand — never more than ``max_concurrency``.""" + seen: set[int] = set() + seen_lock = threading.Lock() + + def record(): + with seen_lock: + seen.add(threading.get_ident()) + time.sleep(0.005) + + with WorkerExecutor(max_concurrency=3) as ex: + futs = [ex.submit(record) for _ in range(40)] + for f in futs: + f.result(timeout=5) + assert len(seen) <= 3 + + +def test_unlimited_concurrency_spawns_per_pending_task(): + """No cap → every concurrent submission gets its own worker thread.""" + n = 8 + seen: set[int] = set() + barrier = threading.Barrier(n) + seen_lock = threading.Lock() + + def hold(): + # Sync at a barrier so all tasks must be running concurrently — + # they cannot be served by a single reused worker. + barrier.wait(timeout=5) + with seen_lock: + seen.add(threading.get_ident()) + + with WorkerExecutor() as ex: + futs = [ex.submit(hold) for _ in range(n)] + for f in futs: + f.result(timeout=5) + assert len(seen) == n + + +def test_idle_workers_self_exit_on_timeout(fast_idle_timeout: float): + """A worker that sees no work for ``idle_timeout`` should retire.""" + with WorkerExecutor(idle_timeout=fast_idle_timeout) as ex: + # The constructor seeds one worker; a single submit + result keeps it warm. + ex.submit(lambda: None).result(timeout=5) + # The seed receiver is always present in open_receivers; what we want to + # confirm is that no *additional* worker hangs around past the idle window. + time.sleep(fast_idle_timeout * 5) + # At least one receiver remains (the seed); none should exceed it. + assert len(ex.open_receivers) <= 1 + # Submitting again still works — a fresh worker is spawned for the WouldBlock. + ex.submit(lambda: None).result(timeout=5) + + +def test_submit_after_close_raises(): + ex = WorkerExecutor() + with ex: + ex.submit(lambda: None).result(timeout=5) + with pytest.raises(RuntimeError): + ex.submit(lambda: None) + + +def test_executor_cannot_be_reused(): + ex = WorkerExecutor() + with ex: + pass + with pytest.raises(RuntimeError, match="cannot be reused"): + with ex: + pass + + +# --------------------------------------------------------------------------- +# ContextVar propagation +# --------------------------------------------------------------------------- + + +def test_task_sees_caller_context(executor: WorkerExecutor): + var: contextvars.ContextVar[str] = contextvars.ContextVar("tt_var") + var.set("caller-value") + fut = executor.submit(var.get) + assert fut.result(timeout=5) == "caller-value" + + +def test_task_mutation_does_not_leak_to_caller(executor: WorkerExecutor): + var: contextvars.ContextVar[str] = contextvars.ContextVar("tt_var", default="original") + + def mutate() -> str: + var.set("task-mutated") + return var.get() + + fut = executor.submit(mutate) + assert fut.result(timeout=5) == "task-mutated" + assert var.get() == "original" + + +def test_each_submit_captures_independently(executor: WorkerExecutor): + var: contextvars.ContextVar[int] = contextvars.ContextVar("tt_var", default=0) + var.set(1) + f1 = executor.submit(var.get) + var.set(2) + f2 = executor.submit(var.get) + assert f1.result(timeout=5) == 1 + assert f2.result(timeout=5) == 2 + + +# --------------------------------------------------------------------------- +# AnyIOWorkerExecutor — cross-backend check_cancelled inside workers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("backend", ["asyncio", "trio"]) +def test_anyio_worker_executor_check_cancelled_callable_in_task(backend: str): + """``from_thread.check_cancelled`` is callable inside a task running on an + :class:`AnyIOWorkerExecutor` worker — i.e. AnyIO's threadlocals are wired + up. This is the structural property the executor exists to provide; the + actual cancel propagation is the user's outer-scope concern. + """ + polled = threading.Event() + + def task() -> str: + # If the threadlocals weren't set, this would raise RuntimeError. + anyio.from_thread.check_cancelled() + polled.set() + return "ok" + + with AnyIOWorkerExecutor(backend=backend) as ex: + fut = ex.submit(task) + assert fut.result(timeout=5) == "ok" + assert polled.is_set() + + +def test_anyio_worker_executor_basic_submit(): + with AnyIOWorkerExecutor() as ex: + fut = ex.submit(lambda x: x + 1, 41) + assert fut.result(timeout=5) == 42 + + +# --------------------------------------------------------------------------- +# AnyIOExecutor — fresh AnyIO task per submit +# --------------------------------------------------------------------------- + + +def test_anyio_executor_basic_submit(): + with AnyIOExecutor() as ex: + fut = ex.submit(lambda x: x * 3, 14) + assert fut.result(timeout=5) == 42 + + +def test_anyio_executor_capacity_limiter(): + """Tasks beyond ``max_concurrency`` queue rather than running.""" + n_concurrent = 0 + max_seen = 0 + lock = threading.Lock() + + def hold(): + nonlocal n_concurrent, max_seen + with lock: + n_concurrent += 1 + max_seen = max(max_seen, n_concurrent) + time.sleep(0.05) + with lock: + n_concurrent -= 1 + + with AnyIOExecutor(max_concurrency=2) as ex: + futs = [ex.submit(hold) for _ in range(8)] + for f in futs: + f.result(timeout=5) + assert max_seen <= 2 + + +def test_anyio_executor_propagates_exception(): + class Boom(Exception): + pass + + with AnyIOExecutor() as ex: + fut = ex.submit(lambda: (_ for _ in ()).throw(Boom("nope"))) + with pytest.raises(Boom): + fut.result(timeout=5) diff --git a/tests/threadtools/thread_pool.py b/tests/threadtools/thread_pool.py deleted file mode 100644 index ec8a56f..0000000 --- a/tests/threadtools/thread_pool.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for ``localpost.threadtools.thread_pool`` and cross-backend -``from_thread.check_cancelled`` support inside worker threads. - -These tests explicitly run on both asyncio and trio. The point of the -pool's design is that workers are spawned via -``anyio.to_thread.run_sync`` so AnyIO populates the thread-local state -needed for ``from_thread.check_cancelled`` to work — and that should be -backend-agnostic. -""" - -from __future__ import annotations - -import threading -import time - -import anyio -import anyio.from_thread -import pytest - -from localpost.threadtools import TaskGroup, thread_pool -from localpost.threadtools._task_group import _current_pool - - -# Both backends, on every test in this module. Each test manages its -# own pool lifecycle (``async with thread_pool():``), so opt out of the -# autouse ambient pool from ``conftest.py``. -pytestmark = [ - pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]), - pytest.mark.no_ambient_pool, -] - - -async def test_check_cancelled_raises_in_worker_after_outer_cancel(anyio_backend): - """A worker task polling ``from_thread.check_cancelled`` observes - cancellation when an outer scope wrapping ``thread_pool()`` cancels. - - This is the cross-backend behaviour the pool exists to provide: - workers are spawned via ``to_thread.run_sync(abandon_on_cancel=False)``, - which sets up the threadlocals that AnyIO's ``check_cancelled`` - reads. Cancelling any ancestor scope of the pool's host task group - propagates to all in-flight worker tasks. - """ - saw_cancel = threading.Event() - runner_done = threading.Event() - runner_exc: list[BaseException] = [] - - def slow_task(): - try: - for _ in range(500): - anyio.from_thread.check_cancelled() - time.sleep(0.01) - except BaseException: - saw_cancel.set() - raise - - def runner(pool): - # ``threading.Thread`` doesn't propagate contextvars; set the - # pool explicitly so ``TaskGroup()`` here finds it. - _current_pool.set(pool) - try: - with TaskGroup() as tg: - tg.start_soon(slow_task) - except BaseException as e: # noqa: BLE001 — capturing for assertion - runner_exc.append(e) - finally: - runner_done.set() - - # Wrap ``thread_pool`` in an outer cancellable scope so we can - # cancel it without conflating teardown semantics with cancellation - # propagation. - with anyio.CancelScope() as scope: - async with thread_pool() as pool: - runner_thread = threading.Thread(target=runner, args=(pool,), daemon=True) - runner_thread.start() - # Let the worker get scheduled and start polling. - await anyio.sleep(0.1) - scope.cancel() - # ``thread_pool.__aexit__`` will run as the ``async with`` - # unwinds under cancellation; in-flight ``check_cancelled`` - # calls raise. - - runner_thread.join(timeout=5) - assert runner_done.is_set(), "runner thread did not terminate" - assert saw_cancel.is_set(), "worker task did not observe cancellation" - assert runner_exc, "TaskGroup did not surface an exception" - assert isinstance(runner_exc[0], BaseExceptionGroup) - - -async def test_warmup_populates_idle(anyio_backend): - """``await pool.warmup(N)`` spawns N workers and parks them in - ``pool.idle`` on both backends.""" - async with thread_pool() as pool: - await pool.warmup(3) - assert len(pool.idle) == 3 - assert all(w._alive for w in pool.idle) - - -async def test_pool_teardown_drains_idle_workers(anyio_backend): - """Idle workers parked in ``_cv.wait`` must exit promptly when the - pool is torn down — otherwise teardown blocks for the full - ``IDLE_TIMEOUT`` (60 s).""" - started = time.monotonic() - async with thread_pool() as pool: - await pool.warmup(2) - elapsed = time.monotonic() - started - # Generous bound: shutdown should take well under a second; the - # 60 s ``IDLE_TIMEOUT`` would catastrophically blow this. - assert elapsed < 5.0, f"pool teardown took {elapsed:.1f}s — idle workers not woken" - - -async def test_taskgroup_outside_pool_raises(anyio_backend): - """The error message should point users at ``thread_pool`` / - ``run_app`` rather than failing silently or with a confusing - AttributeError.""" - # Make sure no ambient pool from a prior test leaks in. - assert _current_pool.get(None) is None - with pytest.raises(RuntimeError, match="thread_pool"): - TaskGroup() diff --git a/tests/threadtools/thread_task_group.py b/tests/threadtools/thread_task_group.py index ea962e2..389f735 100644 --- a/tests/threadtools/thread_task_group.py +++ b/tests/threadtools/thread_task_group.py @@ -1,98 +1,67 @@ """Tests for ``localpost.threadtools.TaskGroup``. -These tests rely on an ambient ``thread_pool()``, supplied autouse by -``conftest.py``. The pool itself runs on a blocking-portal-backed event -loop so the sync test bodies remain sync. +These run against a per-test :class:`WorkerExecutor` provided by +``conftest.py``. The TaskGroup itself is sync; the executor is sync. +No portal / async context is needed. """ +from __future__ import annotations + import contextvars import threading import time from concurrent.futures import Future import pytest -from anyio.from_thread import BlockingPortal - -from localpost.threadtools import TaskGroup, ThreadPool -from localpost.threadtools import _task_group as _tg - - -@pytest.fixture -def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch): - """Make idle-timeout tests fast: 100 ms instead of 60 s.""" - monkeypatch.setattr(_tg, "IDLE_TIMEOUT", 0.1) - return 0.1 - - -def _drain_idle(pool: ThreadPool) -> None: - """Wait for the pool's idle deque to empty. - - Tests that monkeypatch ``IDLE_TIMEOUT`` to a small value rely on this - so they don't leak workers (or interact with each other through the - shared ``idle`` deque). - """ - deadline = time.monotonic() + 2.0 - while pool.idle and time.monotonic() < deadline: - time.sleep(0.05) +from localpost.threadtools import TaskGroup, WorkerExecutor # --------------------------------------------------------------------------- # Basic submit / result # --------------------------------------------------------------------------- -def test_start_soon_returns_none(): - """``start_soon`` is fire-and-forget — explicit ``None`` return.""" - with TaskGroup() as tg: +def test_start_soon_returns_none(executor: WorkerExecutor): + with TaskGroup(executor) as tg: result = tg.start_soon(lambda: 42) assert result is None -def test_create_task_returns_future_with_result(): - with TaskGroup() as tg: +def test_create_task_returns_future_with_result(executor: WorkerExecutor): + with TaskGroup(executor) as tg: fut = tg.create_task(lambda x: x * 2, 21) assert isinstance(fut, Future) assert fut.result(timeout=5) == 42 -def test_create_task_with_kwargs(): - with TaskGroup() as tg: +def test_create_task_with_kwargs(executor: WorkerExecutor): + with TaskGroup(executor) as tg: fut = tg.create_task(lambda *, a, b: a + b, a=1, b=2) assert fut.result(timeout=5) == 3 -def test_many_concurrent_tasks_all_complete(): +def test_many_concurrent_tasks_all_complete(executor: WorkerExecutor): n = 50 def work(i: int) -> int: - time.sleep(0.01) + time.sleep(0.005) return i * i - with TaskGroup() as tg: + with TaskGroup(executor) as tg: futs = [tg.create_task(work, i) for i in range(n)] assert sorted(f.result() for f in futs) == [i * i for i in range(n)] # --------------------------------------------------------------------------- -# Construction outside thread_pool() raises +# Construction outside an entered context # --------------------------------------------------------------------------- -def test_taskgroup_without_pool_raises(): - """Without an active ``thread_pool()`` context, ``TaskGroup()`` must - raise rather than silently spawning detached workers. - - The autouse ``pool`` fixture sets the ambient pool for this module; - we briefly pop it via a fresh contextvars context to simulate the - no-pool case.""" - ctx = contextvars.Context() - - def attempt(): - with pytest.raises(RuntimeError, match="thread_pool"): - TaskGroup() - - ctx.run(attempt) +def test_create_task_before_enter_raises(executor: WorkerExecutor): + tg = TaskGroup(executor) + with pytest.raises(RuntimeError, match="entered"): + tg.create_task(lambda: None) # --------------------------------------------------------------------------- @@ -100,7 +69,7 @@ def attempt(): # --------------------------------------------------------------------------- -def test_create_task_exception_captured_in_future_and_raised_at_exit(): +def test_create_task_exception_captured_in_future_and_raised_at_exit(executor: WorkerExecutor): class Boom(Exception): pass @@ -108,17 +77,15 @@ def bad(): raise Boom("nope") with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 - with TaskGroup() as tg: + with TaskGroup(executor) as tg: fut = tg.create_task(bad) - # Future records the exception too — and consuming it does - # *not* suppress the group's ExceptionGroup at __exit__. with pytest.raises(Boom): fut.result(timeout=5) assert len(ei.value.exceptions) == 1 assert isinstance(ei.value.exceptions[0], Boom) -def test_start_soon_exception_surfaces_in_group(): +def test_start_soon_exception_surfaces_in_group(executor: WorkerExecutor): """Even fire-and-forget tasks have their errors escalate to the group.""" class Boom(Exception): @@ -128,14 +95,14 @@ def bad(): raise Boom("nope") with pytest.raises(ExceptionGroup) as ei: - with TaskGroup() as tg: + with TaskGroup(executor) as tg: tg.start_soon(bad) assert len(ei.value.exceptions) == 1 assert isinstance(ei.value.exceptions[0], Boom) -def test_create_task_result_reraise_is_deduped_in_group(): +def test_create_task_result_reraise_is_deduped_in_group(executor: WorkerExecutor): """When ``Future.result()`` re-raises a task exception into the body, ``__exit__`` collapses the body copy with the group-recorded copy (same instance) — surfacing the failure exactly once.""" @@ -147,14 +114,14 @@ def bad(): raise Boom("nope") with pytest.raises(ExceptionGroup) as ei: - with TaskGroup() as tg: + with TaskGroup(executor) as tg: tg.create_task(bad).result(timeout=5) assert len(ei.value.exceptions) == 1 assert isinstance(ei.value.exceptions[0], Boom) -def test_distinct_body_and_task_exceptions_are_both_surfaced(): +def test_distinct_body_and_task_exceptions_are_both_surfaced(executor: WorkerExecutor): """Dedup is by identity — two different exception instances must both appear in the ExceptionGroup, even if they're the same type.""" @@ -165,9 +132,9 @@ def bad(): raise Boom("from-task") with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 - with TaskGroup() as tg: + with TaskGroup(executor) as tg: tg.start_soon(bad) - time.sleep(0.05) # let the task land in _errors + time.sleep(0.05) raise Boom("from-body") assert len(ei.value.exceptions) == 2 @@ -175,51 +142,18 @@ def bad(): assert messages == ["from-body", "from-task"] -def test_dedup_preserves_recorded_order_when_body_reraises_task_error(): - """If the body re-raises a task exception that wasn't first in - ``_errors``, dedup must keep it at its original recorded position - rather than promoting it to the front.""" - - class A(Exception): - pass - - class B(Exception): - pass - - def task_a(): - raise A("a") - - def task_b(): - raise B("b") - - with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 - with TaskGroup() as tg: - tg.start_soon(task_a) - time.sleep(0.05) # ensure A lands in _errors first - fut_b = tg.create_task(task_b) - # Pull B's exception (same instance now sitting in _errors) - # and re-raise it into the body. Without dedup-with-order, - # B would be moved to position 0. - b_exc = fut_b.exception(timeout=5) - assert b_exc is not None - raise b_exc - - types = [type(e) for e in ei.value.exceptions] - assert types == [A, B] # record order, not body-first - - -def test_body_exception_alone_is_wrapped(): +def test_body_exception_alone_is_wrapped(executor: WorkerExecutor): class BodyBoom(Exception): pass with pytest.raises(ExceptionGroup) as ei: - with TaskGroup(): + with TaskGroup(executor): raise BodyBoom("body") assert len(ei.value.exceptions) == 1 assert isinstance(ei.value.exceptions[0], BodyBoom) -def test_body_and_task_exceptions_are_merged(): +def test_body_and_task_exceptions_are_merged(executor: WorkerExecutor): class Body(Exception): pass @@ -230,20 +164,20 @@ def bad(): raise Task("task") with pytest.raises(ExceptionGroup) as ei: # noqa: PT012 - with TaskGroup() as tg: + with TaskGroup(executor) as tg: tg.start_soon(bad) - time.sleep(0.05) # let the task land + time.sleep(0.05) raise Body("body") types = {type(e) for e in ei.value.exceptions} assert types == {Body, Task} -def test_named_group_uses_name_in_exception_message(): +def test_named_group_uses_name_in_exception_message(executor: WorkerExecutor): def bad(): raise RuntimeError("x") with pytest.raises(ExceptionGroup, match="TaskGroup 'pool-x' failed"): - with TaskGroup(name="pool-x") as tg: + with TaskGroup(executor, name="pool-x") as tg: tg.start_soon(bad) @@ -252,7 +186,7 @@ def bad(): # --------------------------------------------------------------------------- -def test_exit_blocks_until_all_tasks_complete(): +def test_exit_blocks_until_all_tasks_complete(executor: WorkerExecutor): started = threading.Event() release = threading.Event() finished = threading.Event() @@ -262,16 +196,15 @@ def slow(): release.wait(timeout=5) finished.set() - with TaskGroup() as tg: + with TaskGroup(executor) as tg: tg.start_soon(slow) assert started.wait(timeout=2) assert not finished.is_set() release.set() - # Past the with block: drain has completed. assert finished.is_set() -def test_nested_start_soon_during_drain(): +def test_nested_create_task_during_drain(executor: WorkerExecutor): """A task can spawn more tasks; drain waits for the whole transitive set.""" counter = 0 counter_lock = threading.Lock() @@ -285,30 +218,29 @@ def branch(tg: TaskGroup, depth: int): if depth == 0: leaf() return - # Each branch spawns 2 children of depth-1, waits for them. f1 = tg.create_task(branch, tg, depth - 1) f2 = tg.create_task(branch, tg, depth - 1) f1.result(timeout=5) f2.result(timeout=5) leaf() - with TaskGroup() as tg: + with TaskGroup(executor) as tg: tg.start_soon(branch, tg, 3) # depth=3 → 1 + 2 + 4 + 8 = 15 leaves assert counter == 15 -def test_start_soon_after_close_raises(): - tg = TaskGroup() +def test_start_soon_after_close_raises(executor: WorkerExecutor): + tg = TaskGroup(executor) with tg: tg.create_task(lambda: None).result(timeout=5) with pytest.raises(RuntimeError, match="closed"): tg.start_soon(lambda: None) -def test_group_cannot_be_reused(): - tg = TaskGroup() +def test_group_cannot_be_reused(executor: WorkerExecutor): + tg = TaskGroup(executor) with tg: pass with pytest.raises(RuntimeError, match="cannot be reused"): @@ -317,91 +249,11 @@ def test_group_cannot_be_reused(): # --------------------------------------------------------------------------- -# Worker reuse / shared pool +# Cross-thread submission / stress # --------------------------------------------------------------------------- -def test_workers_are_shared_across_groups(pool: ThreadPool, fast_idle_timeout): - """A worker idle from one group should be reused by another.""" - seen: set[int] = set() - - def record(): - seen.add(threading.get_ident()) - - with TaskGroup() as tg: - for _ in range(4): - tg.create_task(record).result(timeout=5) - first_run = set(seen) - - # Second group sees at least some of the same threads (workers parked - # in the pool's idle deque). - seen.clear() - with TaskGroup() as tg: - for _ in range(4): - tg.create_task(record).result(timeout=5) - second_run = set(seen) - - assert first_run & second_run, "no worker reuse across groups" - _drain_idle(pool) - - -def test_idle_workers_self_exit_on_timeout(pool: ThreadPool, fast_idle_timeout): - # Drop any leftover workers from earlier tests — they were spawned before - # the monkeypatch and are stuck on their original 60 s ``inbox.get``. - # Orphaning them is safe (daemon threads, won't be reused). - pool.idle.clear() - - # Spawn a fresh worker and grab a reference to it. - with TaskGroup() as tg: - tg.create_task(lambda: None).result(timeout=5) - assert len(pool.idle) >= 1 - fresh = pool.idle[-1] # LIFO: most recently parked - - # Wait past the idle timeout. - time.sleep(fast_idle_timeout * 10) - assert fresh._alive is False - - # Submitting again skips the dead tombstone and spawns fresh. - with TaskGroup() as tg: - tg.create_task(lambda: None).result(timeout=5) - _drain_idle(pool) - - -# --------------------------------------------------------------------------- -# Race / stress -# --------------------------------------------------------------------------- - - -def test_claim_vs_idle_timeout_race(pool: ThreadPool, fast_idle_timeout): - """Stress the pop+claim vs self-die race. - - Idle workers may time out *exactly* as a dispatcher pops them. The - dispatcher must either (a) successfully claim a still-alive worker, - or (b) see a tombstone and try the next one / spawn fresh. No task - should be lost. - """ - n = 200 - counter = 0 - lock = threading.Lock() - - def tick(): - nonlocal counter - with lock: - counter += 1 - - for _ in range(5): - with TaskGroup() as tg: - futs = [tg.create_task(tick) for _ in range(n)] - for f in futs: - f.result(timeout=5) - # Sleep to let some workers idle-time-out, leaving tombstones. - time.sleep(fast_idle_timeout * 1.5) - - assert counter == n * 5 - _drain_idle(pool) - - -def test_start_soon_from_arbitrary_thread(): +def test_start_soon_from_arbitrary_thread(executor: WorkerExecutor): """``start_soon`` must be safe to call from any thread.""" results: list[int] = [] results_lock = threading.Lock() @@ -410,7 +262,7 @@ def producer(tg: TaskGroup): for i in range(10): tg.start_soon(_record, i, results, results_lock) - with TaskGroup() as tg: + with TaskGroup(executor) as tg: threads = [threading.Thread(target=producer, args=(tg,)) for _ in range(5)] for t in threads: t.start() @@ -426,91 +278,38 @@ def _record(i: int, sink: list[int], lock: threading.Lock) -> None: # --------------------------------------------------------------------------- -# warmup -# --------------------------------------------------------------------------- - - -def test_warmup_adds_workers_to_idle_pool(pool: ThreadPool, portal: BlockingPortal): - """``await pool.warmup(N)`` spawns N workers and parks them in the - pool's idle deque.""" - pool.idle.clear() - portal.call(pool.warmup, 4) - assert len(pool.idle) == 4 - workers = list(pool.idle) - assert all(w._alive for w in workers) - assert len({id(w) for w in workers}) == 4 - - -def test_warmup_workers_are_used_by_dispatch(pool: ThreadPool, portal: BlockingPortal): - """A subsequent ``start_soon`` reuses a pre-warmed worker rather than - spawning a fresh one.""" - pool.idle.clear() - portal.call(pool.warmup, 2) - pre = {id(w) for w in pool.idle} - - seen: set[int] = set() - seen_lock = threading.Lock() - - def record_self() -> None: - with seen_lock: - seen.add(threading.get_ident()) - - with TaskGroup() as tg: - tg.create_task(record_self).result(timeout=5) - - # The worker that ran is one of the pre-warmed set. - post = {id(w) for w in pool.idle} - assert pre & post # at least one of the warmed workers is still parked - - -def test_warmup_zero_is_noop(pool: ThreadPool, portal: BlockingPortal): - pool.idle.clear() - portal.call(pool.warmup, 0) - assert len(pool.idle) == 0 - - -def test_warmup_negative_raises(pool: ThreadPool, portal: BlockingPortal): - with pytest.raises(ValueError, match=">= 0"): - portal.call(pool.warmup, -1) - - -# --------------------------------------------------------------------------- -# ContextVar propagation +# ContextVar propagation (delegated to the executor) # --------------------------------------------------------------------------- -def test_task_sees_caller_context(): - """A ContextVar set in the caller is visible inside the task.""" +def test_task_sees_caller_context(executor: WorkerExecutor): var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var") var.set("caller-value") - with TaskGroup() as tg: + with TaskGroup(executor) as tg: fut = tg.create_task(var.get) assert fut.result(timeout=5) == "caller-value" -def test_task_mutation_does_not_leak_to_caller(): - """ContextVar.set inside a task is confined to the task's context copy.""" +def test_task_mutation_does_not_leak_to_caller(executor: WorkerExecutor): var: contextvars.ContextVar[str] = contextvars.ContextVar("test_var", default="original") def mutate(): var.set("task-mutated") return var.get() - with TaskGroup() as tg: + with TaskGroup(executor) as tg: fut = tg.create_task(mutate) assert fut.result(timeout=5) == "task-mutated" - assert var.get() == "original" # caller's view unchanged + assert var.get() == "original" -def test_each_start_soon_captures_independently(): - """Two ``start_soon`` calls see different values when the body mutates - the ContextVar between them — capture is per-call, not per-group.""" +def test_each_create_task_captures_independently(executor: WorkerExecutor): var: contextvars.ContextVar[int] = contextvars.ContextVar("test_var", default=0) - with TaskGroup() as tg: + with TaskGroup(executor) as tg: var.set(1) f1 = tg.create_task(var.get) var.set(2) @@ -520,58 +319,29 @@ def test_each_start_soon_captures_independently(): assert f2.result(timeout=5) == 2 -def test_concurrent_tasks_see_their_own_context(): - """N concurrent tasks each captured a different ContextVar value; - each must see its own, not another task's.""" - var: contextvars.ContextVar[int] = contextvars.ContextVar("test_var") - - barrier = threading.Barrier(10) - - def observe() -> int: - # Sync all tasks at the same wall-clock moment so they're guaranteed - # to be running concurrently when they read the ContextVar. - barrier.wait(timeout=5) - return var.get() - - with TaskGroup() as tg: - futs: list[Future[int]] = [] - for i in range(10): - var.set(i) - futs.append(tg.create_task(observe)) - - assert sorted(f.result(timeout=5) for f in futs) == list(range(10)) - - # --------------------------------------------------------------------------- -# Async callables (dispatched via the pool's portal) +# Custom executor isolation — TaskGroup uses whatever Executor it's given # --------------------------------------------------------------------------- -def test_async_callable_runs_via_pool_portal(): - """An async callable submitted via ``create_task`` is awaited on the - pool's portal event loop; the future resolves with the awaited result.""" - - async def add(a: int, b: int) -> int: - return a + b - - with TaskGroup() as tg: - fut = tg.create_task(add, 2, 3) - - assert fut.result(timeout=5) == 5 - - -def test_async_callable_exception_flows_through_future(): - """Exceptions raised inside an async callable surface on the future - (and into the TaskGroup's exception group).""" - - async def boom(): - raise ValueError("boom") - - tg = TaskGroup() - with pytest.raises(ExceptionGroup) as ei: - with tg: - fut = tg.create_task(boom) +def test_taskgroup_uses_passed_executor(): + """The same TaskGroup should dispatch to whatever executor it was + constructed with — not to any ambient or shared one.""" + seen_a: set[int] = set() + seen_b: set[int] = set() + seen_lock = threading.Lock() - with pytest.raises(ValueError, match="boom"): - fut.result(timeout=5) - assert any(isinstance(e, ValueError) for e in ei.value.exceptions) + def record(sink: set[int]): + with seen_lock: + sink.add(threading.get_ident()) + + with WorkerExecutor() as ex_a, WorkerExecutor() as ex_b: + with TaskGroup(ex_a) as tg: + for _ in range(4): + tg.create_task(record, seen_a).result(timeout=5) + with TaskGroup(ex_b) as tg: + for _ in range(4): + tg.create_task(record, seen_b).result(timeout=5) + + # Different executors → distinct worker thread sets. + assert seen_a.isdisjoint(seen_b) From b7a4cc4c7e545aad0566a5456efbe26ec5c61a0b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 22:25:28 +0400 Subject: [PATCH 253/286] feat(threadtools)!: AsyncWorkerExecutor / AsyncExecutor as async CMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed AnyIO…Executor -> Async…Executor; Task is now public. - Executor protocol shrunk to just submit (lifecycle is impl-specific: sync with for WorkerExecutor, async with for the async variants). - Async executors are real async context managers backed by an internal anyio.TaskGroup. They take a required, caller-owned BlockingPortal (no auto-open). stop() cancels the internal task group from a non-loop thread; submit likewise must be called from a non-loop thread. - max_concurrency: float = math.inf everywhere; AsyncExecutor always carries a CapacityLimiter (math.inf = no cap), removing the per-submit branch. - AsyncExecutor no longer keeps an _inflight list; the internal task group is the single source of truth for drain / cancel. - HTTP / openapi default executors switched from AnyIOWorkerExecutor (which auto-opened a portal) to WorkerExecutor (sync, standalone). Users who want loop-backed workers wire them explicitly. - Tests split: tests/threadtools/executor.py covers WorkerExecutor (sync); tests/threadtools/async_executor.py covers the async variants under both asyncio and trio, including a stop()-cancels-running-tasks scenario via from_thread.check_cancelled. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/http/__main__.py | 4 +- localpost/http/app.py | 6 +- localpost/openapi/app.py | 8 +- localpost/threadtools/__init__.py | 10 +- localpost/threadtools/_executor.py | 380 ++++++++++++++-------------- tests/threadtools/async_executor.py | 175 +++++++++++++ tests/threadtools/executor.py | 121 ++------- 7 files changed, 400 insertions(+), 304 deletions(-) create mode 100644 tests/threadtools/async_executor.py diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py index a89cdf3..89c0d60 100644 --- a/localpost/http/__main__.py +++ b/localpost/http/__main__.py @@ -10,7 +10,7 @@ from localpost import hosting from localpost.http import RequestHandler, ServerConfig, http_server, thread_pool_handler -from localpost.threadtools import AnyIOWorkerExecutor +from localpost.threadtools import WorkerExecutor def _load_handler(app_str: str) -> RequestHandler: @@ -48,7 +48,7 @@ def main(app: str, host: str, port: int, pool: bool, selectors: int, acceptor: b @hosting.service async def _serve(): if pool: - with AnyIOWorkerExecutor() as executor: + with WorkerExecutor() as executor: async with thread_pool_handler(handler, executor) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/http/app.py b/localpost/http/app.py index 30251f9..2b69add 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -63,7 +63,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from localpost.http._types import Response from localpost.http.config import ServerConfig from localpost.http.router import Routes, URITemplate, route_match -from localpost.threadtools import AnyIOWorkerExecutor, Executor +from localpost.threadtools import Executor, WorkerExecutor __all__ = ["HttpApp"] @@ -307,7 +307,7 @@ def service( ``pooled=True``; pass an already-open :class:`localpost.threadtools.Executor` to share one across services. When omitted (and ``pooled=True``), an - :class:`AnyIOWorkerExecutor` is opened for the lifetime of the + :class:`WorkerExecutor` is opened for the lifetime of the service. ``selectors`` and ``acceptor`` forward to :func:`http_server`. @@ -326,7 +326,7 @@ async def _app_service(): async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield return - with AnyIOWorkerExecutor() as own_executor: + with WorkerExecutor() as own_executor: async with thread_pool_handler(inner, own_executor) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 4796c09..f06cfe9 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -43,7 +43,7 @@ def hello(name: str) -> str: from localpost.openapi.middleware import OpMiddleware from localpost.openapi.operation import Operation from localpost.openapi.schemas import SchemaRegistry -from localpost.threadtools import AnyIOWorkerExecutor, Executor +from localpost.threadtools import Executor, WorkerExecutor __all__ = ["HttpApp"] @@ -203,8 +203,8 @@ def service( ``executor`` is the thread executor that runs handlers; pass an already-open :class:`localpost.threadtools.Executor` to share one - across services. When omitted, an :class:`AnyIOWorkerExecutor` is - opened for the lifetime of the service. + across services. When omitted, a :class:`WorkerExecutor` is opened + for the lifetime of the service. ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ @@ -217,7 +217,7 @@ async def _app_service(): async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield return - with AnyIOWorkerExecutor() as own_executor: + with WorkerExecutor() as own_executor: async with thread_pool_handler(router, own_executor) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index a7e7ccb..e1ed1b0 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,21 +1,23 @@ from ._channel import Channel, ReceiveChannel, SendChannel from ._executor import ( DEFAULT_IDLE_TIMEOUT, - AnyIOExecutor, - AnyIOWorkerExecutor, + AsyncExecutor, + AsyncWorkerExecutor, Executor, + Task, WorkerExecutor, ) from ._task_group import TaskGroup __all__ = [ "DEFAULT_IDLE_TIMEOUT", - "AnyIOExecutor", - "AnyIOWorkerExecutor", + "AsyncExecutor", + "AsyncWorkerExecutor", "Channel", "Executor", "ReceiveChannel", "SendChannel", + "Task", "TaskGroup", "WorkerExecutor", ] diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index ab8cfc2..11377f5 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -1,30 +1,41 @@ """Thread-management primitives for ``localpost.threadtools``. -Three flavors, all sync context managers exposing a ``submit`` method: - -* :class:`WorkerExecutor` — channel-backed pool of plain ``threading.Thread`` - workers. Lazy spawn (driven by ``put_nowait``/``WouldBlock``), idle-timeout - self-exit. Standalone — does not require AnyIO at runtime. -* :class:`AnyIOWorkerExecutor` — same channel/lazy-spawn shape, but workers - run inside ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` via - a :class:`anyio.from_thread.BlockingPortal`. That populates AnyIO's thread - locals so user code can call :func:`anyio.from_thread.check_cancelled`. -* :class:`AnyIOExecutor` — every :meth:`submit` schedules a fresh AnyIO task - on the portal that goes through ``to_thread.run_sync``. No worker reuse. - -All three propagate the caller's :class:`contextvars.Context` to the task, -matching the semantics of :func:`asyncio.to_thread` / Trio / AnyIO spawn. +Three flavors of executor, each with the same ``submit`` contract but +different lifecycle and cancellation properties: + +* :class:`WorkerExecutor` — sync context manager. Channel-backed pool of + plain ``threading.Thread`` workers. Lazy spawn (driven by + ``put_nowait`` / ``WouldBlock``), idle-timeout self-exit. Standalone — + no AnyIO loop required. +* :class:`AsyncWorkerExecutor` — async context manager. Same + channel / lazy-spawn shape, but workers run inside + ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` so user code + can call :func:`anyio.from_thread.check_cancelled`. The cancel scope of + each worker spans its whole lifetime (many tasks reuse the same worker), + so cancellation granularity is *per-worker*, not per-task. +* :class:`AsyncExecutor` — async context manager. Every :meth:`submit` + schedules a fresh AnyIO task on the executor's internal task group. + Cancel granularity is *per-task*: ``Future.cancel()`` propagates to the + underlying task's :func:`anyio.from_thread.check_cancelled`. + +All three propagate :class:`contextvars.Context` to the task, matching +:func:`asyncio.to_thread` / Trio / AnyIO spawn semantics. + +The async variants take a caller-owned :class:`anyio.from_thread.BlockingPortal` +and run their internal :class:`anyio.abc.TaskGroup` on the portal's loop. They +expose :meth:`stop` for explicit cooperative cancellation. """ from __future__ import annotations import contextvars -import dataclasses as dc import functools +import math import threading from collections.abc import Callable from concurrent.futures import Future -from contextlib import ExitStack +from contextlib import AsyncExitStack +from dataclasses import dataclass, field from types import TracebackType from typing import Any, Protocol, Self, cast, final, runtime_checkable @@ -33,9 +44,11 @@ ClosedResourceError, EndOfStream, WouldBlock, + create_task_group, to_thread, ) -from anyio.from_thread import BlockingPortal, start_blocking_portal +from anyio.abc import TaskGroup as AioTaskGroup +from anyio.from_thread import BlockingPortal from ._channel import Channel, ReceiveChannel, SendChannel @@ -45,16 +58,11 @@ @runtime_checkable class Executor(Protocol): - """Minimal executor contract — a sync context manager with ``submit``.""" + """Minimal executor contract: just :meth:`submit`. - def __enter__(self) -> Self: ... - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: ... + Lifecycle (sync ``with`` vs ``async with``) is implementation-specific — + callers depend only on the submit shape. + """ def submit[**P, R]( self, @@ -65,13 +73,19 @@ def submit[**P, R]( ) -> Future[R]: ... -@dc.dataclass(slots=True) -class _Envelope: - future: Future[Any] +@dataclass(eq=False, slots=True) +class Task: + """A single submission carried through the executor pipeline. + + The caller's :class:`contextvars.Context` is snapshotted at construction; + the task runs inside that copy so mutations don't leak back. + """ + fn: Callable[..., Any] args: tuple[Any, ...] kwargs: dict[str, Any] - context: contextvars.Context + context: contextvars.Context = field(default_factory=contextvars.copy_context) + future: Future[Any] = field(default_factory=Future) def run(self) -> None: if not self.future.set_running_or_notify_cancel(): @@ -84,16 +98,6 @@ def run(self) -> None: self.future.set_result(result) -def _make_envelope(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> _Envelope: - return _Envelope( - future=Future(), - fn=fn, - args=args, - kwargs=kwargs, - context=contextvars.copy_context(), - ) - - # -------------------------------------------------------------------------- # WorkerExecutor — channel + plain threads # -------------------------------------------------------------------------- @@ -115,35 +119,34 @@ class WorkerExecutor: def __init__( self, *, - max_concurrency: int | None = None, + max_concurrency: float = math.inf, backlog: int | None = 0, idle_timeout: float = DEFAULT_IDLE_TIMEOUT, thread_name_prefix: str = "lp-worker", ) -> None: - if max_concurrency is not None and max_concurrency <= 0: - raise ValueError("max_concurrency must be > 0 or None") + if max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0") if idle_timeout <= 0: raise ValueError("idle_timeout must be > 0") self._max_concurrency = max_concurrency self._idle_timeout = idle_timeout self._thread_name_prefix = thread_name_prefix - self._tx: SendChannel[_Envelope] # ``_rx_template`` is kept open for the lifetime of the executor so # ``open_receive_channels`` never drops to zero between worker spawns # — that would make the next ``put_nowait`` raise ClosedResourceError. - # Workers always run on a clone; when they retire they close the clone, - # never the template. - self._rx_template: ReceiveChannel[_Envelope] + # Workers always run on a clone they own. + self._tx: SendChannel[Task] + self._rx_template: ReceiveChannel[Task] self._tx, self._rx_template = Channel.create(capacity=backlog) self._lock = threading.Lock() - self._open_receivers: list[ReceiveChannel[_Envelope]] = [] + self._open_receivers: list[ReceiveChannel[Task]] = [] self._workers: list[threading.Thread] = [] self._opened = False self._closed = False self._next_worker_id = 0 @property - def open_receivers(self) -> list[ReceiveChannel[_Envelope]]: + def open_receivers(self) -> list[ReceiveChannel[Task]]: return self._open_receivers def __enter__(self) -> Self: @@ -167,7 +170,6 @@ def __exit__( self._tx.close() for t in list(self._workers): t.join() - # Drop the template once every worker is gone. self._rx_template.close() def submit[**P, R]( @@ -179,21 +181,21 @@ def submit[**P, R]( ) -> Future[R]: if not self._opened or self._closed: raise RuntimeError("WorkerExecutor is not open") - env = _make_envelope(fn, args, kwargs) + task = Task(fn, args, kwargs) try: - self._tx.put_nowait(env) + self._tx.put_nowait(task) except WouldBlock: with self._lock: if self._closed: raise RuntimeError("WorkerExecutor is closed") from None - if self._max_concurrency is None or len(self._open_receivers) < self._max_concurrency: + if len(self._open_receivers) < self._max_concurrency: rx = self._rx_template.clone() self._open_receivers.append(rx) self._spawn_worker(rx) - self._tx.put(env) # blocks once we're at the cap - return env.future + self._tx.put(task) # blocks once we're at the cap + return task.future - def _spawn_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + def _spawn_worker(self, rx: ReceiveChannel[Task]) -> None: wid = self._next_worker_id self._next_worker_id += 1 t = threading.Thread( @@ -205,27 +207,18 @@ def _spawn_worker(self, rx: ReceiveChannel[_Envelope]) -> None: self._workers.append(t) t.start() - def _retire(self, rx: ReceiveChannel[_Envelope]) -> None: - try: - rx.close() - except ClosedResourceError: - pass - with self._lock: - if rx in self._open_receivers: - self._open_receivers.remove(rx) - - def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + def _run_worker(self, rx: ReceiveChannel[Task]) -> None: try: while True: try: - env = rx.get(timeout=self._idle_timeout) + task = rx.get(timeout=self._idle_timeout) except TimeoutError: # Idle. Close the timeout-vs-submit race under the lock: # if a submitter slipped a task into the channel between # ``get`` returning and us deciding to die, take it. with self._lock: try: - env = rx.get_nowait() + task = rx.get_nowait() except (WouldBlock, EndOfStream, ClosedResourceError): if rx in self._open_receivers: self._open_receivers.remove(rx) @@ -236,7 +229,7 @@ def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: return except (EndOfStream, ClosedResourceError): return - env.run() + task.run() finally: try: rx.close() @@ -245,75 +238,70 @@ def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: # -------------------------------------------------------------------------- -# AnyIOWorkerExecutor — channel + AnyIO-backed worker threads +# AsyncWorkerExecutor — channel + AnyIO-backed worker threads # -------------------------------------------------------------------------- @final -class AnyIOWorkerExecutor: +class AsyncWorkerExecutor: """Like :class:`WorkerExecutor`, but workers run via ``anyio.to_thread.run_sync`` so user code can call :func:`anyio.from_thread.check_cancelled`. - Construction is sync; if no ``portal`` is passed the executor opens its - own via :func:`anyio.from_thread.start_blocking_portal` and tears it - down at exit. Pass an existing portal when integrating into an outer - AnyIO event loop. + Async context manager. The internal :class:`anyio.abc.TaskGroup` runs on + the loop the ``portal`` is attached to and hosts every worker's + ``host_task``. ``portal`` is required and caller-owned. + + Cancel granularity is per-worker (cancel scope spans the worker's whole + lifetime, which serves many tasks). Use :meth:`stop` for fast cooperative + shutdown — it cancels the internal task group and closes the channel so + idle workers wake immediately and active tasks' next ``check_cancelled`` + raises. """ def __init__( self, *, - max_concurrency: int | None = None, + portal: BlockingPortal, + max_concurrency: float = math.inf, backlog: int | None = 0, idle_timeout: float = DEFAULT_IDLE_TIMEOUT, - portal: BlockingPortal | None = None, - backend: str = "asyncio", ) -> None: - if max_concurrency is not None and max_concurrency <= 0: - raise ValueError("max_concurrency must be > 0 or None") + if max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0") if idle_timeout <= 0: raise ValueError("idle_timeout must be > 0") + self._portal = portal self._max_concurrency = max_concurrency self._idle_timeout = idle_timeout - self._tx: SendChannel[_Envelope] - self._rx_template: ReceiveChannel[_Envelope] + self._tx: SendChannel[Task] + self._rx_template: ReceiveChannel[Task] self._tx, self._rx_template = Channel.create(capacity=backlog) self._lock = threading.Lock() - # See ``WorkerExecutor`` — the template stays open for the executor's - # lifetime so the channel never sees ``open_receive_channels == 0``. - self._open_receivers: list[ReceiveChannel[_Envelope]] = [] - # ``Future``s returned by ``portal.start_task_soon`` — one per host task. - self._host_tasks: list[Future[None]] = [] - self._external_portal = portal - self._portal: BlockingPortal | None = None - self._backend = backend - self._stack: ExitStack | None = None + self._open_receivers: list[ReceiveChannel[Task]] = [] + self._stack: AsyncExitStack | None = None + self._tg: AioTaskGroup | None = None self._opened = False self._closed = False @property def portal(self) -> BlockingPortal: - if self._portal is None: - raise RuntimeError("AnyIOWorkerExecutor is not open") return self._portal @property - def open_receivers(self) -> list[ReceiveChannel[_Envelope]]: + def open_receivers(self) -> list[ReceiveChannel[Task]]: return self._open_receivers - def __enter__(self) -> Self: + async def __aenter__(self) -> Self: if self._closed: - raise RuntimeError("AnyIOWorkerExecutor cannot be reused") - self._stack = ExitStack().__enter__() - if self._external_portal is not None: - self._portal = self._external_portal - else: - self._portal = self._stack.enter_context(start_blocking_portal(backend=self._backend)) + raise RuntimeError("AsyncWorkerExecutor cannot be reused") + self._stack = AsyncExitStack() + await self._stack.__aenter__() + self._tg = await self._stack.enter_async_context(create_task_group()) self._opened = True return self - def __exit__( + async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, @@ -323,19 +311,36 @@ def __exit__( if self._closed: return self._closed = True + # Close the channel first — idle workers wake via EndOfStream. self._tx.close() - # Wait for every worker host task to finish (its ``to_thread.run_sync`` - # returns once the inner loop exits cleanly). - for f in list(self._host_tasks): - try: - f.result() - except BaseException: # noqa: BLE001, S110 - pass - self._rx_template.close() + # Exit the task group: on clean body it waits for host tasks to finish; + # on exception AnyIO cancels children automatically. if self._stack is not None: - self._stack.__exit__(exc_type, exc, tb) + await self._stack.__aexit__(exc_type, exc, tb) self._stack = None - self._portal = None + self._rx_template.close() + self._tg = None + + def stop(self) -> None: + """Cooperatively shut down: close the channel and cancel the internal + task group. Idempotent. + + Idle workers wake via the channel close; active tasks see their next + :func:`anyio.from_thread.check_cancelled` raise. Tasks that don't poll + run to natural completion. + + Like :meth:`submit`, this must be called from a non-loop thread — + from inside ``async`` code, wrap with ``await anyio.to_thread.run_sync(ex.stop)``. + """ + if self._tg is None: + return + # The channel uses thread locks; closing is safe from any thread. + try: + self._tx.close() + except ClosedResourceError: + pass + tg = self._tg + self._portal.call(tg.cancel_scope.cancel) def submit[**P, R]( self, @@ -345,40 +350,40 @@ def submit[**P, R]( **kwargs: P.kwargs, ) -> Future[R]: if not self._opened or self._closed: - raise RuntimeError("AnyIOWorkerExecutor is not open") - env = _make_envelope(fn, args, kwargs) + raise RuntimeError("AsyncWorkerExecutor is not open") + task = Task(fn, args, kwargs) try: - self._tx.put_nowait(env) + self._tx.put_nowait(task) except WouldBlock: with self._lock: if self._closed: - raise RuntimeError("AnyIOWorkerExecutor is closed") from None - if self._max_concurrency is None or len(self._open_receivers) < self._max_concurrency: + raise RuntimeError("AsyncWorkerExecutor is closed") from None + if len(self._open_receivers) < self._max_concurrency: rx = self._rx_template.clone() self._open_receivers.append(rx) self._spawn_worker(rx) - self._tx.put(env) - return env.future + self._tx.put(task) + return task.future - def _spawn_worker(self, rx: ReceiveChannel[_Envelope]) -> None: - portal = self._portal - assert portal is not None + def _spawn_worker(self, rx: ReceiveChannel[Task]) -> None: + tg = self._tg + assert tg is not None async def host_task() -> None: await to_thread.run_sync(self._run_worker, rx, abandon_on_cancel=False) - f = portal.start_task_soon(host_task) - self._host_tasks.append(f) + # Schedule the host task into our internal task group from any thread. + self._portal.call(tg.start_soon, host_task) - def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: + def _run_worker(self, rx: ReceiveChannel[Task]) -> None: try: while True: try: - env = rx.get(timeout=self._idle_timeout) + task = rx.get(timeout=self._idle_timeout) except TimeoutError: with self._lock: try: - env = rx.get_nowait() + task = rx.get_nowait() except (WouldBlock, EndOfStream, ClosedResourceError): if rx in self._open_receivers: self._open_receivers.remove(rx) @@ -389,7 +394,7 @@ def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: return except (EndOfStream, ClosedResourceError): return - env.run() + task.run() finally: try: rx.close() @@ -398,60 +403,57 @@ def _run_worker(self, rx: ReceiveChannel[_Envelope]) -> None: # -------------------------------------------------------------------------- -# AnyIOExecutor — fresh AnyIO task per submit +# AsyncExecutor — fresh AnyIO task per submit # -------------------------------------------------------------------------- @final -class AnyIOExecutor: - """One AnyIO task per :meth:`submit`, executed via - ``anyio.to_thread.run_sync``. - - Useful when you want every task to be its own AnyIO scope rather than - reusing a long-lived worker. ``max_concurrency`` is enforced by an - :class:`anyio.CapacityLimiter`. +class AsyncExecutor: + """Async context manager. One AnyIO task per :meth:`submit`, executed via + ``anyio.to_thread.run_sync`` and gated by an + :class:`anyio.CapacityLimiter` (``math.inf`` = no cap). + + Cancel granularity is *per-task*: calling ``Future.cancel()`` on the + returned :class:`~concurrent.futures.Future` cancels just that submit's + AnyIO task; the cancel propagates through ``to_thread.run_sync(..., + abandon_on_cancel=False)`` into the worker thread's threadlocals so the + task's next :func:`anyio.from_thread.check_cancelled` raises. + + :meth:`stop` cancels the internal task group, signalling every in-flight + submit at once. """ def __init__( self, *, - max_concurrency: int | None = None, - portal: BlockingPortal | None = None, - backend: str = "asyncio", + portal: BlockingPortal, + max_concurrency: float = math.inf, ) -> None: - if max_concurrency is not None and max_concurrency <= 0: - raise ValueError("max_concurrency must be > 0 or None") + if max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0") + self._portal = portal self._max_concurrency = max_concurrency - self._external_portal = portal - self._portal: BlockingPortal | None = None - self._backend = backend - self._stack: ExitStack | None = None + self._stack: AsyncExitStack | None = None + self._tg: AioTaskGroup | None = None self._limiter: CapacityLimiter | None = None self._opened = False self._closed = False - self._inflight: list[Future[Any]] = [] - self._inflight_lock = threading.Lock() @property def portal(self) -> BlockingPortal: - if self._portal is None: - raise RuntimeError("AnyIOExecutor is not open") return self._portal - def __enter__(self) -> Self: + async def __aenter__(self) -> Self: if self._closed: - raise RuntimeError("AnyIOExecutor cannot be reused") - self._stack = ExitStack().__enter__() - if self._external_portal is not None: - self._portal = self._external_portal - else: - self._portal = self._stack.enter_context(start_blocking_portal(backend=self._backend)) - if self._max_concurrency is not None: - self._limiter = self._portal.call(CapacityLimiter, self._max_concurrency) + raise RuntimeError("AsyncExecutor cannot be reused") + self._stack = AsyncExitStack() + await self._stack.__aenter__() + self._tg = await self._stack.enter_async_context(create_task_group()) + self._limiter = CapacityLimiter(self._max_concurrency) self._opened = True return self - def __exit__( + async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, @@ -460,19 +462,23 @@ def __exit__( if self._closed: return self._closed = True - # Drain in-flight submissions before closing the portal — otherwise the - # portal's task group will cancel them on shutdown. - for f in list(self._inflight): - try: - f.result() - except BaseException: # noqa: BLE001, S110 - pass + # Exit the internal task group: clean body waits for in-flight submits; + # exception body cancels them. No manual tracking needed. if self._stack is not None: - self._stack.__exit__(exc_type, exc, tb) + await self._stack.__aexit__(exc_type, exc, tb) self._stack = None - self._portal = None + self._tg = None self._limiter = None + def stop(self) -> None: + """Cancel every in-flight submit by cancelling the internal task group. + Safe from any thread. Idempotent. + """ + if self._tg is None: + return + tg = self._tg + self._portal.call(tg.cancel_scope.cancel) + def submit[**P, R]( self, fn: Callable[P, R], @@ -481,27 +487,23 @@ def submit[**P, R]( **kwargs: P.kwargs, ) -> Future[R]: if not self._opened or self._closed: - raise RuntimeError("AnyIOExecutor is not open") - portal = self._portal - assert portal is not None - ctx = contextvars.copy_context() - bound = functools.partial(ctx.run, fn, *args, **kwargs) + raise RuntimeError("AsyncExecutor is not open") + tg = self._tg limiter = self._limiter + assert tg is not None + assert limiter is not None + task = Task(fn, args, kwargs) - async def task() -> R: - if limiter is not None: - return cast("R", await to_thread.run_sync(bound, limiter=limiter, abandon_on_cancel=False)) - return cast("R", await to_thread.run_sync(bound, abandon_on_cancel=False)) - - fut = portal.start_task_soon(task) - with self._inflight_lock: - self._inflight.append(fut) - fut.add_done_callback(self._on_done) - return fut - - def _on_done(self, fut: Future[Any]) -> None: - with self._inflight_lock: + async def runner() -> None: + if not task.future.set_running_or_notify_cancel(): + return + bound = functools.partial(task.context.run, task.fn, *task.args, **task.kwargs) try: - self._inflight.remove(fut) - except ValueError: - pass + result = await to_thread.run_sync(bound, limiter=limiter, abandon_on_cancel=False) + except BaseException as exc: # noqa: BLE001 + task.future.set_exception(exc) + else: + task.future.set_result(cast("Any", result)) + + self._portal.call(tg.start_soon, runner) + return task.future diff --git a/tests/threadtools/async_executor.py b/tests/threadtools/async_executor.py new file mode 100644 index 0000000..5e2cfc7 --- /dev/null +++ b/tests/threadtools/async_executor.py @@ -0,0 +1,175 @@ +"""Tests for the AnyIO-backed executors. + +Both :class:`AsyncWorkerExecutor` and :class:`AsyncExecutor` are async +context managers and require a caller-owned +:class:`anyio.from_thread.BlockingPortal` running on the test's loop. + +We pin both backends (``asyncio`` and ``trio``) for the cross-backend +``check_cancelled`` story; ``stop()`` must work the same on each. +""" + +from __future__ import annotations + +import threading +import time +from collections.abc import AsyncIterator +from concurrent.futures import Future + +import anyio +import anyio.from_thread +import pytest +from anyio.from_thread import BlockingPortal + +from localpost.threadtools import AsyncExecutor, AsyncWorkerExecutor + +# Both backends, on every test in this module. +pytestmark = pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]) + + +@pytest.fixture +async def portal() -> AsyncIterator[BlockingPortal]: + """A :class:`BlockingPortal` bound to the test's running event loop. + + Submits to async executors are sync calls that schedule onto the + portal's loop; they must be invoked from a non-loop thread (the test + body uses ``to_thread.run_sync`` to do that). + """ + async with anyio.from_thread.BlockingPortal() as p: + yield p + + +# --------------------------------------------------------------------------- +# AsyncWorkerExecutor +# --------------------------------------------------------------------------- + + +async def test_async_worker_executor_basic_submit(anyio_backend, portal: BlockingPortal): + async with AsyncWorkerExecutor(portal=portal) as ex: + # ``submit`` and ``Future.result`` must both run off the loop thread. + fut: Future[int] = await anyio.to_thread.run_sync(ex.submit, lambda: 42) + result = await anyio.to_thread.run_sync(fut.result, 5) + assert result == 42 + + +async def test_async_worker_executor_check_cancelled_callable_in_task(anyio_backend, portal: BlockingPortal): + """``from_thread.check_cancelled`` is callable inside a task running on an + :class:`AsyncWorkerExecutor` worker — i.e. AnyIO's threadlocals are wired up. + """ + polled = threading.Event() + + def task() -> str: + anyio.from_thread.check_cancelled() # raises if not wired + polled.set() + return "ok" + + async with AsyncWorkerExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, task) + result = await anyio.to_thread.run_sync(fut.result, 5) + assert result == "ok" + assert polled.is_set() + + +async def test_async_worker_executor_stop_propagates_to_running_task(anyio_backend, portal: BlockingPortal): + """:meth:`stop` cancels the internal task group; running tasks polling + ``check_cancelled`` raise on their next checkpoint.""" + saw_cancel = threading.Event() + started = threading.Event() + + def slow_task() -> None: + started.set() + try: + for _ in range(500): + anyio.from_thread.check_cancelled() + time.sleep(0.005) + except BaseException: + saw_cancel.set() + raise + + async with AsyncWorkerExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, slow_task) + await anyio.to_thread.run_sync(started.wait, 2.0) + await anyio.to_thread.run_sync(ex.stop) + # __aexit__ awaits the cancellation-driven drain. + + assert saw_cancel.is_set() + # The future ends up with a cancellation exception (backend-specific type). + assert fut.done() + assert fut.exception() is not None + + +# --------------------------------------------------------------------------- +# AsyncExecutor +# --------------------------------------------------------------------------- + + +async def test_async_executor_basic_submit(anyio_backend, portal: BlockingPortal): + async with AsyncExecutor(portal=portal) as ex: + fut: Future[int] = await anyio.to_thread.run_sync(ex.submit, lambda: 7 * 6) + result = await anyio.to_thread.run_sync(fut.result, 5) + assert result == 42 + + +async def test_async_executor_propagates_exception(anyio_backend, portal: BlockingPortal): + class Boom(Exception): + pass + + def bad() -> None: + raise Boom("nope") + + async with AsyncExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, bad) + + def get_result() -> object: + return fut.result(timeout=5) + + with pytest.raises(Boom): + await anyio.to_thread.run_sync(get_result) + + +async def test_async_executor_capacity_limiter(anyio_backend, portal: BlockingPortal): + """``max_concurrency`` is enforced via :class:`anyio.CapacityLimiter`.""" + n_concurrent = 0 + max_seen = 0 + lock = threading.Lock() + + def hold() -> None: + nonlocal n_concurrent, max_seen + with lock: + n_concurrent += 1 + max_seen = max(max_seen, n_concurrent) + time.sleep(0.05) + with lock: + n_concurrent -= 1 + + async with AsyncExecutor(portal=portal, max_concurrency=2) as ex: + futs = [await anyio.to_thread.run_sync(ex.submit, hold) for _ in range(8)] + for f in futs: + await anyio.to_thread.run_sync(f.result, 5) + assert max_seen <= 2 + + +async def test_async_executor_stop_cancels_all_inflight(anyio_backend, portal: BlockingPortal): + cancelled_count = 0 + cancelled_lock = threading.Lock() + started = threading.Event() + n = 4 + + def slow_task() -> None: + nonlocal cancelled_count + started.set() + try: + for _ in range(500): + anyio.from_thread.check_cancelled() + time.sleep(0.005) + except BaseException: + with cancelled_lock: + cancelled_count += 1 + raise + + async with AsyncExecutor(portal=portal, max_concurrency=n) as ex: + for _ in range(n): + await anyio.to_thread.run_sync(ex.submit, slow_task) + await anyio.to_thread.run_sync(started.wait, 2.0) + await anyio.to_thread.run_sync(ex.stop) + + assert cancelled_count == n diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py index be0e052..2ad48ef 100644 --- a/tests/threadtools/executor.py +++ b/tests/threadtools/executor.py @@ -1,31 +1,20 @@ -"""Tests for the ``localpost.threadtools`` executors. +"""Tests for :class:`localpost.threadtools.WorkerExecutor` (sync, no AnyIO). -Three executors are exercised here: - -- :class:`WorkerExecutor` — channel-backed pool of plain ``threading.Thread`` - workers. Most tests target this one — it has no external loop dependency. -- :class:`AnyIOWorkerExecutor` — same shape but workers spawn through a - ``BlockingPortal`` + ``to_thread.run_sync``. The cross-backend - ``check_cancelled`` test lives here. -- :class:`AnyIOExecutor` — one AnyIO task per submit. Shorter coverage — - enough to confirm submit/result/exception flow. +The Async variants (``AsyncWorkerExecutor`` / ``AsyncExecutor``) live in +``async_executor.py`` since they're async context managers and need a +:class:`anyio.from_thread.BlockingPortal`. """ from __future__ import annotations import contextvars +import math import threading import time -import anyio -import anyio.from_thread import pytest -from localpost.threadtools import ( - AnyIOExecutor, - AnyIOWorkerExecutor, - WorkerExecutor, -) +from localpost.threadtools import WorkerExecutor from localpost.threadtools import _executor as _ex @@ -37,7 +26,7 @@ def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch) -> float: # --------------------------------------------------------------------------- -# WorkerExecutor — submit / result +# Submit / result # --------------------------------------------------------------------------- @@ -97,20 +86,21 @@ def record(): def test_unlimited_concurrency_spawns_per_pending_task(): - """No cap → every concurrent submission gets its own worker thread.""" + """Default (math.inf) → every concurrent submission gets its own worker thread.""" n = 8 seen: set[int] = set() barrier = threading.Barrier(n) seen_lock = threading.Lock() def hold(): - # Sync at a barrier so all tasks must be running concurrently — - # they cannot be served by a single reused worker. + # Sync at a barrier so all tasks must be running concurrently. barrier.wait(timeout=5) with seen_lock: seen.add(threading.get_ident()) with WorkerExecutor() as ex: + # Default max_concurrency is math.inf. + assert math.isinf(ex._max_concurrency) futs = [ex.submit(hold) for _ in range(n)] for f in futs: f.result(timeout=5) @@ -120,14 +110,11 @@ def hold(): def test_idle_workers_self_exit_on_timeout(fast_idle_timeout: float): """A worker that sees no work for ``idle_timeout`` should retire.""" with WorkerExecutor(idle_timeout=fast_idle_timeout) as ex: - # The constructor seeds one worker; a single submit + result keeps it warm. ex.submit(lambda: None).result(timeout=5) - # The seed receiver is always present in open_receivers; what we want to - # confirm is that no *additional* worker hangs around past the idle window. time.sleep(fast_idle_timeout * 5) - # At least one receiver remains (the seed); none should exceed it. - assert len(ex.open_receivers) <= 1 - # Submitting again still works — a fresh worker is spawned for the WouldBlock. + # Workers self-exit and remove themselves from open_receivers. + assert ex.open_receivers == [] + # Submitting again still works — a fresh worker spawns. ex.submit(lambda: None).result(timeout=5) @@ -148,6 +135,11 @@ def test_executor_cannot_be_reused(): pass +def test_max_concurrency_must_be_positive(): + with pytest.raises(ValueError, match="max_concurrency"): + WorkerExecutor(max_concurrency=0) + + # --------------------------------------------------------------------------- # ContextVar propagation # --------------------------------------------------------------------------- @@ -180,78 +172,3 @@ def test_each_submit_captures_independently(executor: WorkerExecutor): f2 = executor.submit(var.get) assert f1.result(timeout=5) == 1 assert f2.result(timeout=5) == 2 - - -# --------------------------------------------------------------------------- -# AnyIOWorkerExecutor — cross-backend check_cancelled inside workers -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("backend", ["asyncio", "trio"]) -def test_anyio_worker_executor_check_cancelled_callable_in_task(backend: str): - """``from_thread.check_cancelled`` is callable inside a task running on an - :class:`AnyIOWorkerExecutor` worker — i.e. AnyIO's threadlocals are wired - up. This is the structural property the executor exists to provide; the - actual cancel propagation is the user's outer-scope concern. - """ - polled = threading.Event() - - def task() -> str: - # If the threadlocals weren't set, this would raise RuntimeError. - anyio.from_thread.check_cancelled() - polled.set() - return "ok" - - with AnyIOWorkerExecutor(backend=backend) as ex: - fut = ex.submit(task) - assert fut.result(timeout=5) == "ok" - assert polled.is_set() - - -def test_anyio_worker_executor_basic_submit(): - with AnyIOWorkerExecutor() as ex: - fut = ex.submit(lambda x: x + 1, 41) - assert fut.result(timeout=5) == 42 - - -# --------------------------------------------------------------------------- -# AnyIOExecutor — fresh AnyIO task per submit -# --------------------------------------------------------------------------- - - -def test_anyio_executor_basic_submit(): - with AnyIOExecutor() as ex: - fut = ex.submit(lambda x: x * 3, 14) - assert fut.result(timeout=5) == 42 - - -def test_anyio_executor_capacity_limiter(): - """Tasks beyond ``max_concurrency`` queue rather than running.""" - n_concurrent = 0 - max_seen = 0 - lock = threading.Lock() - - def hold(): - nonlocal n_concurrent, max_seen - with lock: - n_concurrent += 1 - max_seen = max(max_seen, n_concurrent) - time.sleep(0.05) - with lock: - n_concurrent -= 1 - - with AnyIOExecutor(max_concurrency=2) as ex: - futs = [ex.submit(hold) for _ in range(8)] - for f in futs: - f.result(timeout=5) - assert max_seen <= 2 - - -def test_anyio_executor_propagates_exception(): - class Boom(Exception): - pass - - with AnyIOExecutor() as ex: - fut = ex.submit(lambda: (_ for _ in ()).throw(Boom("nope"))) - with pytest.raises(Boom): - fut.result(timeout=5) From 408efdaeaf0bb87c7ce929d9ffb3b2382bdac589 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 22:36:33 +0400 Subject: [PATCH 254/286] feat(hosting,http,openapi)!: expose hosting portal; default to AsyncWorkerExecutor - ServiceLifetimeView.portal exposes the BlockingPortal that hosting._serve_root opens once per app. Service authors can compose AsyncWorkerExecutor / AsyncExecutor against the same loop without opening a redundant portal. - HttpApp.service in localpost.http.app and localpost.openapi.app: when the user does not pass executor=, default to opening an AsyncWorkerExecutor on the hosting portal (was: WorkerExecutor). Handlers get anyio.from_thread.check_cancelled support for free; the worker pool shares the loop the rest of hosting runs on. - Add localpost/threadtools/README.md covering Channel / Executor variants / TaskGroup and the hosting-portal composition pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/hosting/_host.py | 10 +++ localpost/http/app.py | 11 ++-- localpost/openapi/app.py | 12 ++-- localpost/threadtools/README.md | 110 ++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 localpost/threadtools/README.md diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index e5b74e0..71dc117 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -112,6 +112,16 @@ def exit_code(self) -> int: def state(self) -> ServiceState: return self._state.state + @property + def portal(self) -> BlockingPortal: + """The hosting layer's :class:`BlockingPortal` (one per app, shared + with every nested service). Use it to compose + :class:`localpost.threadtools.AsyncWorkerExecutor` / + :class:`localpost.threadtools.AsyncExecutor` against the same loop + that hosts the service. + """ + return self._state.portal + def wait_started(self) -> None: """Helper for sync code, to wait in a thread.""" if self._state.same_thread: diff --git a/localpost/http/app.py b/localpost/http/app.py index 2b69add..09f461b 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -63,7 +63,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): from localpost.http._types import Response from localpost.http.config import ServerConfig from localpost.http.router import Routes, URITemplate, route_match -from localpost.threadtools import Executor, WorkerExecutor +from localpost.threadtools import AsyncWorkerExecutor, Executor __all__ = ["HttpApp"] @@ -307,8 +307,10 @@ def service( ``pooled=True``; pass an already-open :class:`localpost.threadtools.Executor` to share one across services. When omitted (and ``pooled=True``), an - :class:`WorkerExecutor` is opened for the lifetime of the - service. + :class:`AsyncWorkerExecutor` is opened on the hosting layer's + :class:`anyio.from_thread.BlockingPortal` for the lifetime of the + service — handlers can call + :func:`anyio.from_thread.check_cancelled` for free. ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ @@ -326,7 +328,8 @@ async def _app_service(): async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield return - with WorkerExecutor() as own_executor: + portal = hosting.current_service().portal + async with AsyncWorkerExecutor(portal=portal) as own_executor: async with thread_pool_handler(inner, own_executor) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index f06cfe9..3dc7242 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -43,7 +43,7 @@ def hello(name: str) -> str: from localpost.openapi.middleware import OpMiddleware from localpost.openapi.operation import Operation from localpost.openapi.schemas import SchemaRegistry -from localpost.threadtools import Executor, WorkerExecutor +from localpost.threadtools import AsyncWorkerExecutor, Executor __all__ = ["HttpApp"] @@ -203,8 +203,11 @@ def service( ``executor`` is the thread executor that runs handlers; pass an already-open :class:`localpost.threadtools.Executor` to share one - across services. When omitted, a :class:`WorkerExecutor` is opened - for the lifetime of the service. + across services. When omitted, an :class:`AsyncWorkerExecutor` is + opened on the hosting layer's + :class:`anyio.from_thread.BlockingPortal` for the lifetime of the + service — handlers can call + :func:`anyio.from_thread.check_cancelled` for free. ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ @@ -217,7 +220,8 @@ async def _app_service(): async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield return - with WorkerExecutor() as own_executor: + portal = hosting.current_service().portal + async with AsyncWorkerExecutor(portal=portal) as own_executor: async with thread_pool_handler(router, own_executor) as h: async with http_server(config, h, selectors=selectors, acceptor=acceptor): yield diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md new file mode 100644 index 0000000..98182f5 --- /dev/null +++ b/localpost/threadtools/README.md @@ -0,0 +1,110 @@ +# `localpost.threadtools` + +Thread-friendly building blocks for moving work off the event loop: + +- [`Channel`](#channel) — a typed, thread-safe queue with `timeout` on `put` / `get` and broadcast-on-close. +- [`Executor`](#executors) — three implementations, one `submit` contract. +- [`TaskGroup`](#taskgroup) — Trio-style structured concurrency over an `Executor`. + +`localpost.threadtools` is built on plain locks; the AnyIO loop is needed only for the `Async…Executor` variants. + +## Channel + +```python +from localpost.threadtools import Channel + +tx, rx = Channel.create(capacity=8) +tx.put(item, timeout=1.0) # raises TimeoutError on expiry +got = rx.get(timeout=1.0) +got = rx.get_nowait() # raises WouldBlock / EndOfStream +``` + +`capacity=None` is unbounded; `0` is rendezvous (put waits until a receiver consumes); `N>0` is bounded. Both ends can be cloned (`tx.clone()`, `rx.clone()`); closing either side broadcasts to every waiter so cloned receivers all observe `EndOfStream` / `ClosedResourceError`. + +## Executors + +Three implementations share the same `submit(fn, *args, **kwargs) -> Future` contract. Lifecycle and cancellation differ: + +| Executor | CM | Cancellation | Loop required | +|-------------------------|--------------|-------------------------------------|---------------| +| `WorkerExecutor` | `with` | None — workers finish naturally | No | +| `AsyncWorkerExecutor` | `async with` | Per-worker (`stop()` / scope cancel)| Yes | +| `AsyncExecutor` | `async with` | Per-task (`Future.cancel()`) | Yes | + +All three propagate `contextvars.Context` to the task, matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. + +### `WorkerExecutor` — sync, channel-backed + +Plain `threading.Thread` workers. Lazy spawn, idle-timeout self-exit. No event loop. + +```python +from localpost.threadtools import WorkerExecutor + +with WorkerExecutor(max_concurrency=8) as ex: + fut = ex.submit(work, x) + result = fut.result() +``` + +`submit` tries `put_nowait` first; on `WouldBlock` it spawns a new worker (up to `max_concurrency`) and falls through to a blocking `put` (which is what backpressures the caller when at the cap). + +### `AsyncWorkerExecutor` — channel + AnyIO threadlocals + +Same channel / lazy-spawn shape as `WorkerExecutor`, but workers run inside `anyio.to_thread.run_sync(..., abandon_on_cancel=False)` so user code can call `anyio.from_thread.check_cancelled`. + +The worker's cancel scope spans its whole lifetime (one worker handles many tasks), so cancellation granularity is **per-worker**, not per-task. Use `stop()` for fast cooperative shutdown. + +```python +async with anyio.from_thread.BlockingPortal() as portal: + async with AsyncWorkerExecutor(portal=portal) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, work, x) + # … + await anyio.to_thread.run_sync(ex.stop) # cancel everything +``` + +`submit` and `stop` must be invoked from a non-loop thread (use `await anyio.to_thread.run_sync(…)` from inside async code). + +### `AsyncExecutor` — fresh AnyIO task per submit + +Every `submit` schedules a fresh AnyIO task on the executor's internal task group; concurrency is gated by an `anyio.CapacityLimiter` (`math.inf` = no cap). Cancellation is **per-task**: `Future.cancel()` propagates to the underlying task's `check_cancelled`. + +```python +async with anyio.from_thread.BlockingPortal() as portal: + async with AsyncExecutor(portal=portal, max_concurrency=4) as ex: + fut = await anyio.to_thread.run_sync(ex.submit, work, x) + # cancel just this task: + fut.cancel() +``` + +## TaskGroup + +A thin sync bookkeeping layer over an `Executor`. Tracks `Future`s; on `__exit__` waits for every submitted task and re-raises failures as a `BaseExceptionGroup` (Trio `strict_exception_groups=True` semantics — body and task exceptions are merged into one group). + +```python +from localpost.threadtools import TaskGroup, WorkerExecutor + +with WorkerExecutor() as ex: + with TaskGroup(ex) as tg: + tg.start_soon(do_work, arg) # fire-and-forget + fut = tg.create_task(other_work) # observe via Future + # On exit: drain in-flight tasks; raise BaseExceptionGroup if any failed. +``` + +`TaskGroup` is **collect-and-raise only** — running tasks are not interrupted on the first failure. If you want cooperative cancel, give it an `AsyncWorkerExecutor` / `AsyncExecutor` so tasks can poll `check_cancelled`. + +## Composing with `localpost.hosting` + +`localpost.hosting._serve_root` already runs a single `BlockingPortal` for the whole app. It's exposed on the service lifetime view so you can layer an executor on top of it without opening a second portal: + +```python +from localpost import hosting +from localpost.threadtools import AsyncWorkerExecutor + +@hosting.service +async def my_service(): + portal = hosting.current_service().portal + async with AsyncWorkerExecutor(portal=portal) as ex: + # … use ex.submit from worker threads … + yield +``` + +This is how `localpost.http.HttpApp.service` and `localpost.openapi.HttpApp.service` default their internal pool when no `executor=` is passed — handlers automatically get `from_thread.check_cancelled` support. From 1fb1ee3be17a6ad36c42c60e23abb5b38f3c580c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 22:40:26 +0400 Subject: [PATCH 255/286] docs(changelog): refresh 0.6.0 entry with the threadtools rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the four-commit threadtools rebuild into the existing 0.6.0 draft (unreleased — current version is 0.6.0.dev0): - Replace the threadtools Added bullet with the new shape: Channel with timeout=/get_nowait/broadcast-on-close; the Executor protocol and three implementations (WorkerExecutor, AsyncWorkerExecutor, AsyncExecutor) with the per-worker vs per-task cancel distinction; TaskGroup over an Executor. - Add ServiceLifetimeView.portal to Added. - Note HttpApp.service(executor=...) on http and openapi to Added. - thread_pool_handler / streaming_pool_handler signature change (executor required, caller-owned) added to Changed (BREAKING). - threadtools entry in Changed (BREAKING) expanded to cover removal of CancellableLock / cancellable_condition / cancellable_semaphore / ThreadPool / thread_pool, the new TaskGroup constructor, and the channel API change. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 74 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734cbb3..3ab5481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,15 @@ Python 3.12+ is now required (was 3.10+). - `read_body` / `aread_body` body helpers, `static_handler`, `compress_handler` (gzip stdlib; brotli via the `[http-compress]` extra). - - `thread_pool_handler` / `streaming_pool_handler` — opt-in worker-pool - offload of handlers and streaming uploads. + - `thread_pool_handler(handler, executor)` / + `streaming_pool_handler(handler, executor)` — opt-in worker-pool + offload of handlers and streaming uploads. The executor is + caller-owned; the wrapper holds an internal `TaskGroup` for drain + semantics but does not own the executor lifecycle. + - `HttpApp.service(executor=...)` and `openapi.HttpApp.service(executor=...)` + accept an open `Executor`. When omitted, the service opens an + `AsyncWorkerExecutor` on the hosting portal so handlers get + `from_thread.check_cancelled` support automatically. - `HTTPReqCtx.attrs` — mutable per-request state for cross-cutting concerns (auth, tracing, rate-limit, body cache). - **Free-threaded CPython 3.14t support** — verified end-to-end. ~3x @@ -59,18 +66,45 @@ Python 3.12+ is now required (was 3.10+). `AsyncRequestHandler` type. `to_asgi` / `to_rsgi` adapters expose the same handler shape over async transports, so the same `HttpApp` can run under Granian or Uvicorn or the in-tree sync server. -- **`localpost.threadtools`** — sync primitives for thread-bridging code: - `TaskGroup` (a portal-backed task group with `start_soon(...)` for - fire-and-forget and `create_task(...) -> Future` for awaitable spawn), - `Channel` (with separate `SendChannel` / `ReceiveChannel` halves), and - `cancellable_semaphore` / `CancellableLock` with per-primitive - `check_cancelled`. +- **`localpost.threadtools`** — primitives for thread-bridging code, built + on plain locks (no AnyIO loop required for the sync pieces): + - `Channel` — typed, thread-safe queue with separate `SendChannel` / + `ReceiveChannel` halves, `timeout=` on `put` / `get`, `get_nowait`, + and broadcast-on-close so cloned receivers all observe `EndOfStream` + / `ClosedResourceError`. Capacity modes: unbounded (`None`), + rendezvous (`0`), bounded (`N>0`). + - `Executor` protocol — single `submit(fn, *args, **kwargs) -> Future` + contract. Three implementations: + - `WorkerExecutor` — sync `with`, channel-backed pool of plain + `threading.Thread` workers, lazy spawn with idle-timeout + self-exit, no event loop needed. + - `AsyncWorkerExecutor` — `async with`, same channel/lazy-spawn + shape but workers run via `anyio.to_thread.run_sync(..., + abandon_on_cancel=False)` so user code can call + `anyio.from_thread.check_cancelled`. Cancel granularity is + *per-worker* (one worker handles many tasks). + - `AsyncExecutor` — `async with`, fresh AnyIO task per submit gated + by an always-on `CapacityLimiter` (`math.inf` = no cap). Cancel + granularity is *per-task* (`Future.cancel()` propagates). + The async variants take a caller-owned `BlockingPortal` and hold an + internal `anyio.TaskGroup`; `stop()` cancels every in-flight task. + - `TaskGroup` — Trio-style structured concurrency over an `Executor`. + Tracks `Future`s, drains in `__exit__`, surfaces failures as a + `BaseExceptionGroup` (body + task exceptions merged, deduplicated + by identity). + - All three executors snapshot `contextvars.Context` per submit, + matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. - **`localpost.di`** — `.NET`-style scoped IoC container (`ServiceRegistry`, `ServiceProvider`, `AppContext`) with a Flask integration that scopes services per request. - **Hosting middleware** — `shutdown_on_signal()` and `start_timeout(...)`, composable around any `ServiceF`. New `+` and `>>` operators for combining and wrapping services. +- **`ServiceLifetimeView.portal`** exposes the hosting layer's per-app + `BlockingPortal`. Lets services compose `AsyncWorkerExecutor` / + `AsyncExecutor` against the same loop without opening a redundant + portal — the pattern HTTP / openapi `HttpApp.service(...)` use to + default their internal pool. - **`HostRSGIApp`** — host an RSGI app under `localpost.hosting` so it participates in the same lifecycle / signals as everything else. - **`localpost.debug`** — context manager to attach AnyIO-aware debug @@ -114,8 +148,28 @@ Python 3.12+ is now required (was 3.10+). `ScheduledTaskTemplate` / `Task` / `Scheduler`, declarative triggers (`every`, `after`, `after_all`, `cron`); the sync/async-handler duality is preserved. -- **`threadtools` namespace** — `ThreadTaskGroup` is now `TaskGroup`; the - module is now a package (`localpost/threadtools/`). +- **`localpost.threadtools` reshaped.** The module is now a package + (`localpost/threadtools/`); `ThreadTaskGroup` was renamed to + `TaskGroup`. The rest of the surface diverges from any previous + drafts: + - `Channel.create(...)` no longer takes `check_cancelled`; instead + `put` / `get` take an explicit `timeout=`. `close()` broadcasts to + every cloned waiter. + - `CancellableLock`, `cancellable_condition`, `cancellable_semaphore` + are removed — cancellation moves up a layer (executor / task group + / caller polls timeouts). + - `TaskGroup` no longer relies on an ambient `thread_pool()` + contextvar. It takes an `Executor` explicitly: + `TaskGroup(executor)`. Spawning runs through `executor.submit`; + drain and error-folding semantics are unchanged. + - The old `ThreadPool` / `thread_pool()` async context manager is + gone, replaced by the three explicit `Executor` implementations + (see the Added section). +- **`localpost.http.thread_pool_handler` / `streaming_pool_handler` + signature.** Both now require an `executor` argument: + `thread_pool_handler(handler, executor)`. Caller owns the executor + lifetime. The previous version auto-opened an ambient pool via the + removed `thread_pool()` contextvar. - **HTTP server** does not leak parser types into the public API. `HTTPReqCtx.request` is `localpost.http.Request` (was `h11.Request`); `HTTPReqCtx.start_response` and `complete` accept From 86573a7084fc65c0c571dbfd0455c5c195a422a8 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 23:14:10 +0400 Subject: [PATCH 256/286] refactor(threadtools)!: SendChannel / ReceiveChannel are now Protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the public/private split for the channel surface. The names exported from `localpost.threadtools` are unchanged (`Channel`, `SendChannel`, `ReceiveChannel`), but the latter two are now the Protocols (formerly `BaseSendChannel` / `BaseReceiveChannel`). The concrete implementations move to `_SendChannel` / `_ReceiveChannel` and stay inside `_channel.py`. - `Channel.create` keeps returning `tuple[SendChannel[T], ReceiveChannel[T]]`; the runtime values still satisfy the Protocols, so every consumer (`_executor`, `_pool`, `_task_group`) needs no edit. - `@final` is dropped on the now-private impls — they don't need it. - Tests that read internal channel state (`_state.waiting_receivers`, `_state.pending_handoffs`) reach into the concrete impl explicitly: `channel_props.py` narrows once via `assert isinstance(s, _SendChannel)` in the state machine `__init__`; `channels.py` adds a small `_state_of(s)` cast helper that's used at every white-box site. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools/_channel.py | 32 +++++++++++++++++++----------- tests/threadtools/channel_props.py | 5 +++++ tests/threadtools/channels.py | 31 ++++++++++++++++++++--------- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py index ecdad1a..e4b4684 100644 --- a/localpost/threadtools/_channel.py +++ b/localpost/threadtools/_channel.py @@ -27,13 +27,18 @@ def create(capacity: int | None = None) -> tuple[SendChannel[T], ReceiveChannel[ """ with ChannelState[T](capacity) as state: state.open_send_channels += 1 - tx = SendChannel(state) + tx = _SendChannel(state) state.open_receive_channels += 1 - rx = ReceiveChannel(state) + rx = _ReceiveChannel(state) return tx, rx -class BaseReceiveChannel[T](Protocol): +class ReceiveChannel[T](Protocol): + """Public receive-side of a :class:`Channel`. Concrete instances are produced + by :meth:`Channel.create` and the clone methods; users program against this + Protocol. + """ + def __enter__(self) -> Self: return self @@ -66,7 +71,12 @@ def get_nowait(self) -> T: ... def close(self) -> None: ... -class BaseSendChannel[T](Protocol): +class SendChannel[T](Protocol): + """Public send-side of a :class:`Channel`. Concrete instances are produced + by :meth:`Channel.create` and the clone methods; users program against this + Protocol. + """ + def __enter__(self) -> Self: return self @@ -171,8 +181,7 @@ def _remaining(deadline: float | None) -> float | None: return deadline - time.monotonic() -@final -class SendChannel[T](BaseSendChannel[T]): +class _SendChannel[T](SendChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False @@ -181,12 +190,12 @@ def __repr__(self) -> str: return f"" @override - def clone(self) -> SendChannel[T]: + def clone(self) -> _SendChannel[T]: with self._state as state: if self._closed: raise ClosedResourceError("send channel is already closed") state.open_send_channels += 1 - return SendChannel(state) + return _SendChannel(state) @override def put_nowait(self, item: T, /) -> None: @@ -256,8 +265,7 @@ def close(self) -> None: state.not_full.notify_all() -@final -class ReceiveChannel[T](BaseReceiveChannel[T]): +class _ReceiveChannel[T](ReceiveChannel[T]): def __init__(self, state: ChannelState[T]) -> None: self._state = state self._closed = False @@ -266,12 +274,12 @@ def __repr__(self) -> str: return f"" @override - def clone(self) -> ReceiveChannel[T]: + def clone(self) -> _ReceiveChannel[T]: with self._state as state: if self._closed: raise ClosedResourceError("receive channel is already closed") state.open_receive_channels += 1 - return ReceiveChannel(state) + return _ReceiveChannel(state) def _take(self, state: ChannelState[T]) -> T: item = state.buffer.popleft() diff --git a/tests/threadtools/channel_props.py b/tests/threadtools/channel_props.py index eba2882..3172a98 100644 --- a/tests/threadtools/channel_props.py +++ b/tests/threadtools/channel_props.py @@ -33,6 +33,7 @@ ) from localpost.threadtools import Channel +from localpost.threadtools._channel import _SendChannel class _ChannelMachine(RuleBasedStateMachine): @@ -55,6 +56,10 @@ def __init__(self) -> None: s, r = Channel.create(capacity=self.capacity) self._first_sender = s self._first_receiver = r + # Property tests check invariants against the channel's internal state + # directly. ``Channel.create`` returns the public Protocol type; + # narrow to the concrete impl so the type checker accepts ``_state``. + assert isinstance(s, _SendChannel) self._state = s._state self.model_buffer: deque = deque() self.model_open_senders = 1 diff --git a/tests/threadtools/channels.py b/tests/threadtools/channels.py index 284a4bf..222b5c6 100644 --- a/tests/threadtools/channels.py +++ b/tests/threadtools/channels.py @@ -1,10 +1,23 @@ import threading import time +from typing import cast import pytest from anyio import ClosedResourceError, EndOfStream, WouldBlock from localpost.threadtools import Channel, SendChannel +from localpost.threadtools._channel import ChannelState, _SendChannel + + +def _state_of[T](s: SendChannel[T]) -> ChannelState[T]: + """Reach into a sender's :class:`ChannelState` for white-box assertions. + + ``Channel.create`` returns the public ``SendChannel`` Protocol; tests + that need to observe internal counters (``waiting_receivers``, + ``pending_handoffs``) cast to the concrete impl through this helper so + the access is explicit in one place. + """ + return cast("_SendChannel[T]", s)._state def test_basic_send_receive(): @@ -222,11 +235,11 @@ def receive() -> None: receiver_thread.start() deadline = time.monotonic() + 1.0 - while sender._state.waiting_receivers == 0 and time.monotonic() < deadline: + while _state_of(sender).waiting_receivers == 0 and time.monotonic() < deadline: time.sleep(0.001) try: - assert sender._state.waiting_receivers == 1 + assert _state_of(sender).waiting_receivers == 1 sender.put_nowait("ready") receiver_thread.join(timeout=1.0) assert not receiver_thread.is_alive() @@ -289,8 +302,8 @@ def receive(r): t.start() try: - assert _wait_for(lambda: sender._state.waiting_receivers == n), ( - f"only {sender._state.waiting_receivers} receivers waiting" + assert _wait_for(lambda: _state_of(sender).waiting_receivers == n), ( + f"only {_state_of(sender).waiting_receivers} receivers waiting" ) for i in range(n): @@ -319,13 +332,13 @@ def receive_one(r, sink: list[str]) -> None: received: list[str] = [] t = threading.Thread(target=receive_one, args=(receiver, received)) t.start() - assert _wait_for(lambda: sender._state.waiting_receivers == 1) + assert _wait_for(lambda: _state_of(sender).waiting_receivers == 1) sender.put_nowait(f"msg-{round_idx}") t.join(timeout=1.0) assert not t.is_alive() assert received == [f"msg-{round_idx}"] - assert sender._state.pending_handoffs == 0 + assert _state_of(sender).pending_handoffs == 0 finally: sender.close() receiver.close() @@ -368,7 +381,7 @@ def send(s, value: int): t.start() try: - assert _wait_for(lambda: sender._state.waiting_receivers == n) + assert _wait_for(lambda: _state_of(sender).waiting_receivers == n) sender_threads = [threading.Thread(target=send, args=(s, i)) for i, s in enumerate(senders)] for t in sender_threads: @@ -405,7 +418,7 @@ def receive_one(): receiver_thread.start() try: - assert _wait_for(lambda: sender._state.waiting_receivers == 1) + assert _wait_for(lambda: _state_of(sender).waiting_receivers == 1) # "a" claims the only waiting receiver. sender.put_nowait("a") @@ -618,7 +631,7 @@ def test_channel_cleanup(): sender, receiver = Channel.create() sender.put(42) assert receiver.get() == 42 - state = sender._state + state = _state_of(sender) sender.close() receiver.close() From 4c8c947e38608d2eadfa950b62c8523d15392e22 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 23:35:40 +0400 Subject: [PATCH 257/286] fix(examples,tests): migrate to post-BodyHandler API; align ty ignore codes `localpost.http` removed `BodyHandler` and `HTTPReqCtx.body` in e868361 (RequestHandler is now `Callable[[HTTPReqCtx], None]`; body is read on demand via `ctx.receive(...)` / `read_body(ctx)`). Production code migrated then; example files were missed. Catch them up: - examples/http/{compressed_api, middleware_basic_auth, middleware_rate_limit, multithread_server, sentry_router_server, static_files}.py: drop `BodyHandler` import; flatten handler return type to `None`; drop trailing `return None`. - examples/http/app_server.py: `json.loads(ctx.body)` -> `json.loads(read_body(ctx))`. Also: - tests/http/body.py: existing `# type: ignore[arg-type]` comments paired `_SyncStubCtx` / `_AsyncStubCtx` against the `HTTPReqCtx` Protocol, but ty's diagnostic code is `invalid-argument-type` and bare ty was still flagging them. Add `# ty: ignore[invalid-argument-type]` alongside, matching the existing convention in `localpost/http/server_h11.py`. - tests/threadtools/async_executor.py: `anyio.to_thread` was resolved by ty as `type[BrokenWorkerInterpreter]` because the submodule isn't auto-imported by `import anyio`. Add `import anyio.to_thread` so attribute access lands on the right module. `just types-all` is down from 47 -> 17 diagnostics; the rest are unrelated (openapi tests, hosting tests, optional `fast_depends`). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/http/app_server.py | 4 ++-- examples/http/compressed_api.py | 4 +--- examples/http/middleware_basic_auth.py | 10 ++++------ examples/http/middleware_rate_limit.py | 10 ++++------ examples/http/multithread_server.py | 10 +++------- examples/http/sentry_router_server.py | 7 ++----- examples/http/static_files.py | 8 +++----- examples/http/wsgi_app_server.py | 1 + tests/http/body.py | 14 +++++++------- tests/threadtools/async_executor.py | 1 + 10 files changed, 28 insertions(+), 41 deletions(-) diff --git a/examples/http/app_server.py b/examples/http/app_server.py index cf32120..d23adfd 100644 --- a/examples/http/app_server.py +++ b/examples/http/app_server.py @@ -2,7 +2,7 @@ import sys from localpost import hosting -from localpost.http import HTTPReqCtx, ServerConfig +from localpost.http import HTTPReqCtx, ServerConfig, read_body from localpost.http.app import HttpApp app = HttpApp() @@ -15,7 +15,7 @@ def hello(name: str): @app.post("/{name}/profile") def update_user_profile(ctx: HTTPReqCtx, name: str): - profile = json.loads(ctx.body) + profile = json.loads(read_body(ctx)) return {"updated_for": name, "profile": profile} diff --git a/examples/http/compressed_api.py b/examples/http/compressed_api.py index 46f14b3..1d958dc 100644 --- a/examples/http/compressed_api.py +++ b/examples/http/compressed_api.py @@ -26,7 +26,6 @@ from localpost.hosting import run_app, service from localpost.http import ( - BodyHandler, HTTPReqCtx, Response, Routes, @@ -38,7 +37,7 @@ from localpost.threadtools import WorkerExecutor -def _items(ctx: HTTPReqCtx) -> BodyHandler | None: +def _items(ctx: HTTPReqCtx) -> None: # Big-ish JSON so the response crosses the default ``min_size=1024``. payload = {"items": [{"id": i, "name": f"item-{i}", "tag": "x" * 32} for i in range(64)]} body = json.dumps(payload).encode("utf-8") @@ -52,7 +51,6 @@ def _items(ctx: HTTPReqCtx) -> BodyHandler | None: ), body if ctx.request.method != b"HEAD" else None, ) - return None def build_router(): diff --git a/examples/http/middleware_basic_auth.py b/examples/http/middleware_basic_auth.py index 188e47b..8098772 100644 --- a/examples/http/middleware_basic_auth.py +++ b/examples/http/middleware_basic_auth.py @@ -20,7 +20,6 @@ from localpost.hosting import run_app, service from localpost.http import ( - BodyHandler, HTTPReqCtx, RequestHandler, Response, @@ -60,15 +59,15 @@ def _check_basic_auth(authorization: bytes | None) -> bool: def basic_auth(inner: RequestHandler) -> RequestHandler: """Reject requests without valid Basic credentials before dispatching.""" - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + def wrapped(ctx: HTTPReqCtx) -> None: auth_header = next( (v for k, v in ctx.request.headers if k == b"authorization"), None, ) if not _check_basic_auth(auth_header): ctx.complete(_UNAUTHORIZED, b"") - return None - return inner(ctx) + return + inner(ctx) return wrapped @@ -76,7 +75,7 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: # ----------- routes ------------------------------------------------------- -def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: +def _hello(ctx: HTTPReqCtx) -> None: name = route_match(ctx).path_args.get("name", "world") body = f"Hello, {name}!\n".encode() ctx.complete( @@ -86,7 +85,6 @@ def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: ), body, ) - return None def build_router() -> RequestHandler: diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py index 5cc4a1e..a204a1c 100644 --- a/examples/http/middleware_rate_limit.py +++ b/examples/http/middleware_rate_limit.py @@ -23,7 +23,6 @@ from localpost.hosting import run_app, service from localpost.http import ( - BodyHandler, HTTPReqCtx, Middleware, RequestHandler, @@ -74,12 +73,12 @@ def rate_limit(max_requests: int = 10, window_seconds: float = 1.0) -> Middlewar counter = _SlidingWindowCounter(max_requests, window_seconds) def middleware(inner: RequestHandler) -> RequestHandler: - def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: + def wrapped(ctx: HTTPReqCtx) -> None: client_ip = (ctx.remote_addr or "").rpartition(":")[0] or "unknown" if not counter.is_allowed(client_ip): ctx.complete(_TOO_MANY, b"") - return None - return inner(ctx) + return + inner(ctx) return wrapped @@ -89,7 +88,7 @@ def wrapped(ctx: HTTPReqCtx) -> BodyHandler | None: # ----------- routes ------------------------------------------------------- -def _root(ctx: HTTPReqCtx) -> BodyHandler | None: +def _root(ctx: HTTPReqCtx) -> None: body = b"ok\n" ctx.complete( Response( @@ -98,7 +97,6 @@ def _root(ctx: HTTPReqCtx) -> BodyHandler | None: ), body, ) - return None def build_handler() -> RequestHandler: diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index e32d7e2..d3a1942 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -24,7 +24,6 @@ from localpost.hosting import run_app, service from localpost.http import ( - BodyHandler, HTTPReqCtx, Response, Router, @@ -47,21 +46,18 @@ def _emit(ctx: HTTPReqCtx, body: bytes) -> None: ) -def _root(ctx: HTTPReqCtx) -> BodyHandler | None: +def _root(ctx: HTTPReqCtx) -> None: _emit(ctx, b"hello from localpost\n") - return None -def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: +def _hello(ctx: HTTPReqCtx) -> None: name = route_match(ctx).path_args["name"] _emit(ctx, f"Hello, {name}! (thread={threading.current_thread().name})\n".encode()) - return None -def _slow(ctx: HTTPReqCtx) -> BodyHandler | None: +def _slow(ctx: HTTPReqCtx) -> None: time.sleep(1.0) # exercises concurrency: several of these run in parallel _emit(ctx, f"done on thread={threading.current_thread().name}\n".encode()) - return None def build_router() -> Router: diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index c76cb95..e90348d 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -23,7 +23,6 @@ from localpost.hosting import run_app, service from localpost.http import ( - BodyHandler, HTTPReqCtx, Response, Routes, @@ -46,18 +45,16 @@ def _emit(ctx: HTTPReqCtx, body: bytes) -> None: ) -def _root(ctx: HTTPReqCtx) -> BodyHandler | None: +def _root(ctx: HTTPReqCtx) -> None: _emit(ctx, b"hello\n") - return None -def _get_book(ctx: HTTPReqCtx) -> BodyHandler | None: +def _get_book(ctx: HTTPReqCtx) -> None: book_id = route_match(ctx).path_args["id"] # Spans inside the handler land on the request transaction. with sentry_sdk.start_span(op="db.query", name="select book"): time.sleep(0.01) _emit(ctx, f"book={book_id}\n".encode()) - return None def build_router(): diff --git a/examples/http/static_files.py b/examples/http/static_files.py index 45ed693..1057742 100644 --- a/examples/http/static_files.py +++ b/examples/http/static_files.py @@ -23,7 +23,6 @@ from localpost.hosting import run_app, service from localpost.http import ( - BodyHandler, HTTPReqCtx, RequestHandler, Response, @@ -36,7 +35,7 @@ from localpost.threadtools import WorkerExecutor -def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: +def _hello(ctx: HTTPReqCtx) -> None: body = b"hello from the API\n" ctx.complete( Response( @@ -48,7 +47,6 @@ def _hello(ctx: HTTPReqCtx) -> BodyHandler | None: ), body, ) - return None def build_api() -> RequestHandler: @@ -69,8 +67,8 @@ async def app(): ) api = build_api() - def dispatch(ctx: HTTPReqCtx) -> BodyHandler | None: - return (static if ctx.request.path.startswith(b"/static/") else api)(ctx) + def dispatch(ctx: HTTPReqCtx) -> None: + (static if ctx.request.path.startswith(b"/static/") else api)(ctx) with WorkerExecutor() as ex: async with thread_pool_handler(dispatch, ex) as wrapped: diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py index f9df3f2..1f6a089 100644 --- a/examples/http/wsgi_app_server.py +++ b/examples/http/wsgi_app_server.py @@ -53,6 +53,7 @@ async def wsgi_app_service(): async with http_server(config, wrapped): yield + def main() -> int: logging.basicConfig(level=logging.INFO) return run_app(wsgi_app_service()) diff --git a/tests/http/body.py b/tests/http/body.py index c2b34b1..d67956a 100644 --- a/tests/http/body.py +++ b/tests/http/body.py @@ -24,20 +24,20 @@ def receive(self, _size: int = 0, /) -> bytes: class TestReadBody: def test_drains_to_eof(self): ctx = _SyncStubCtx([b"hello ", b"world"]) - assert read_body(ctx) == b"hello world" # type: ignore[arg-type] + assert read_body(ctx) == b"hello world" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] def test_empty_body(self): ctx = _SyncStubCtx([]) - assert read_body(ctx) == b"" # type: ignore[arg-type] + assert read_body(ctx) == b"" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] def test_max_size_exceeded(self): ctx = _SyncStubCtx([b"a" * 5, b"b" * 5]) with pytest.raises(BodyTooLarge): - read_body(ctx, max_size=8) # type: ignore[arg-type] + read_body(ctx, max_size=8) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] def test_max_size_exact_fit(self): ctx = _SyncStubCtx([b"a" * 4, b"b" * 4]) - assert read_body(ctx, max_size=8) == b"aaaabbbb" # type: ignore[arg-type] + assert read_body(ctx, max_size=8) == b"aaaabbbb" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] # --- Async --------------------------------------------------------------- @@ -56,13 +56,13 @@ async def receive(self, _size: int = 0, /) -> bytes: class TestAreadBody: async def test_drains_to_eof(self): ctx = _AsyncStubCtx([b"hello ", b"world"]) - assert await aread_body(ctx) == b"hello world" # type: ignore[arg-type] + assert await aread_body(ctx) == b"hello world" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] async def test_empty_body(self): ctx = _AsyncStubCtx([]) - assert await aread_body(ctx) == b"" # type: ignore[arg-type] + assert await aread_body(ctx) == b"" # ty: ignore[invalid-argument-type] # type: ignore[arg-type] async def test_max_size_exceeded(self): ctx = _AsyncStubCtx([b"a" * 5, b"b" * 5]) with pytest.raises(BodyTooLarge): - await aread_body(ctx, max_size=8) # type: ignore[arg-type] + await aread_body(ctx, max_size=8) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] diff --git a/tests/threadtools/async_executor.py b/tests/threadtools/async_executor.py index 5e2cfc7..e7d9530 100644 --- a/tests/threadtools/async_executor.py +++ b/tests/threadtools/async_executor.py @@ -17,6 +17,7 @@ import anyio import anyio.from_thread +import anyio.to_thread import pytest from anyio.from_thread import BlockingPortal From 184493bd8cb0c4339f08faec9298dc9390fa5d53 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 23:41:20 +0400 Subject: [PATCH 258/286] test(threadtools): drive channel property tests through the public protocol only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four `_state`-based invariants in channel_props.py were either redundant with the existing behavioral rules or single-thread tautologies: - `open_send/receive_channels == model` — the counters' effects are already observed via `put_nowait_no_receivers` / `get_drained_raises_eos` / the closed-handle rules. - `len(buffer) == len(model_buffer)` — the model deque is the test's own bookkeeping; the impl's buffer is observed indirectly via `get_nonempty` returning the popped item. - `len(buffer) <= capacity` — already guarded by `put_nowait_full_blocks`. - `waiting_receivers == 0` — single-thread tautology; if violated, the test would deadlock anyway. Drop them all. Drop the `_SendChannel` import and the `assert isinstance` narrowing along with them. The property test now exercises only the public `SendChannel` / `ReceiveChannel` protocol — i.e. it tests the contract, not the implementation. Future channel re-implementations would need to satisfy the same properties without modification. `channels.py` keeps its `_state_of(sender)` helper unchanged: those threaded tests need "a background receiver thread is parked in get() right now" and the public API has no signal for that. Different use case; legitimate white-box. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/threadtools/channel_props.py | 33 +++++------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/tests/threadtools/channel_props.py b/tests/threadtools/channel_props.py index 3172a98..945f89d 100644 --- a/tests/threadtools/channel_props.py +++ b/tests/threadtools/channel_props.py @@ -5,9 +5,11 @@ Scope: Single-threaded ``RuleBasedStateMachine`` driving sequences of ``put_nowait`` / ``get`` / ``close`` / ``clone`` against a small reference - model (deque + counters). Catches state-tracking regressions — - ``open_send_channels``, ``open_receive_channels``, buffer length, capacity - bound — under arbitrary op orderings. + model (deque + counters). Every assertion goes through the public + ``SendChannel`` / ``ReceiveChannel`` Protocol — no white-box access. The + machine catches state-tracking regressions only via observable behavior: + EOS after senders close, ``WouldBlock`` at capacity, ``ClosedResourceError`` + on closed handles, FIFO order under a single producer. Out of scope: Rendezvous (``capacity=0``) and any property that depends on thread @@ -33,7 +35,6 @@ ) from localpost.threadtools import Channel -from localpost.threadtools._channel import _SendChannel class _ChannelMachine(RuleBasedStateMachine): @@ -56,11 +57,6 @@ def __init__(self) -> None: s, r = Channel.create(capacity=self.capacity) self._first_sender = s self._first_receiver = r - # Property tests check invariants against the channel's internal state - # directly. ``Channel.create`` returns the public Protocol type; - # narrow to the concrete impl so the type checker accepts ``_state``. - assert isinstance(s, _SendChannel) - self._state = s._state self.model_buffer: deque = deque() self.model_open_senders = 1 self.model_open_receivers = 1 @@ -176,25 +172,6 @@ def clone_closed_receiver_raises(self, r): # --- invariants --------------------------------------------------------- - @invariant() - def open_handle_counters_match_model(self): - assert self._state.open_send_channels == self.model_open_senders - assert self._state.open_receive_channels == self.model_open_receivers - - @invariant() - def buffer_length_matches_model(self): - assert len(self._state.buffer) == len(self.model_buffer) - - @invariant() - def buffer_within_capacity(self): - if self.capacity is not None: - assert len(self._state.buffer) <= self.capacity - - @invariant() - def waiting_receivers_zero_in_single_thread(self): - # No thread ever blocks in get() here, so this should never grow. - assert self._state.waiting_receivers == 0 - @invariant() def fifo_under_single_producer(self): if not self.single_producer: From 13c9cdc54f62c50154e9d181b8e2ac70319c3d37 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 00:19:38 +0400 Subject: [PATCH 259/286] feat(hosting,threadtools)!: run_app raises SystemExit; add threadtools.run_async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_app() now exits the process via SystemExit with the resulting status code instead of returning it, so __main__ entrypoints don't need a sys.exit(...) wrapper. Examples, tests, and docs updated accordingly. threadtools.run_async() is the sync→async counterpart to anyio.to_thread.run_sync(): from a worker thread it dispatches a coroutine onto the current service's portal and blocks for the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +- docs/getting-started.md | 5 +- docs/modules/hosting.md | 5 +- docs/modules/http.md | 7 +- docs/modules/openapi.md | 3 +- docs/modules/scheduler.md | 3 +- examples/host/channel.py | 4 +- examples/host/click_cli.py | 4 +- examples/host/finite_service.py | 3 +- examples/http/app_server.py | 3 +- examples/http/compressed_api.py | 7 +- examples/http/flask_app_server.py | 7 +- examples/http/middleware_basic_auth.py | 7 +- examples/http/middleware_rate_limit.py | 7 +- examples/http/multithread_server.py | 7 +- examples/http/sentry_router_server.py | 7 +- examples/http/sse_compressed.py | 7 +- examples/http/static_files.py | 7 +- examples/http/wsgi_app_server.py | 7 +- examples/openapi/app.py | 3 +- examples/scheduler/app.py | 3 +- examples/scheduler/cancellation.py | 3 +- examples/scheduler/cron.py | 3 +- examples/scheduler/failing_tasks.py | 3 +- examples/scheduler/fastdepends.py | 3 +- examples/scheduler/finite_task.py | 3 +- localpost/hosting/README.md | 7 +- localpost/hosting/__init__.py | 15 ++- localpost/http/README.md | 3 +- localpost/http/__main__.py | 3 +- localpost/http/app.py | 2 +- localpost/openapi/README.md | 3 +- localpost/openapi/__init__.py | 2 +- localpost/openapi/app.py | 2 +- localpost/scheduler/README.md | 4 +- localpost/threadtools/README.md | 20 ++++ localpost/threadtools/__init__.py | 2 + localpost/threadtools/_run_async.py | 39 ++++++++ pypi_readme.md | 3 +- tests/hosting/_signal_app.py | 4 +- tests/hosting/run_app.py | 26 ++++-- tests/threadtools/run_async.py | 123 +++++++++++++++++++++++++ 42 files changed, 276 insertions(+), 109 deletions(-) create mode 100644 localpost/threadtools/_run_async.py create mode 100644 tests/threadtools/run_async.py diff --git a/README.md b/README.md index 21a9432..b89ce30 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ Optional extras: ```python import random -import sys from localpost.hosting import run_app from localpost.scheduler import after, delay, every, scheduled_task, take_first @@ -56,11 +55,12 @@ async def task2(task1_result: int): if __name__ == "__main__": - sys.exit(run_app(task1, task2)) + run_app(task1, task2) ``` `run_app` wires signal handling (SIGINT / SIGTERM), starts every service in -parallel, and exits cleanly when they all stop. +parallel, exits cleanly when they all stop, and raises ``SystemExit`` with +the resulting status code — no ``sys.exit(...)`` wrapper needed. ## Modules diff --git a/docs/getting-started.md b/docs/getting-started.md index f8a49be..f34d83e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,7 +22,6 @@ module wires up signal handling and runs everything until SIGINT / SIGTERM. ```python import random -import sys from localpost.hosting import run_app from localpost.scheduler import after, delay, every, scheduled_task, take_first @@ -39,7 +38,7 @@ async def task2(task1_result: int): if __name__ == "__main__": - sys.exit(run_app(task1, task2)) + run_app(task1, task2) ``` What's happening: @@ -48,7 +47,7 @@ What's happening: - `after(task1) // take_first(3)` — fire when `task1` completes, take only its first 3 emissions. - `run_app(...)` — start every service in parallel, exit cleanly when they all - stop. + stop, then raise `SystemExit` with the resulting status code. ## A sync function works too diff --git a/docs/modules/hosting.md b/docs/modules/hosting.md index 09c626a..540b8e9 100644 --- a/docs/modules/hosting.md +++ b/docs/modules/hosting.md @@ -8,7 +8,6 @@ services in the same task group. ## Quick start ```python -import sys import time from localpost.hosting import ServiceLifetime, run_app, service @@ -25,12 +24,12 @@ def a_sync_service(): if __name__ == "__main__": - sys.exit(run_app(a_sync_service())) + run_app(a_sync_service()) ``` `run_app()` wires `shutdown_on_signal()` for you (SIGINT / SIGTERM), runs the service with AnyIO (picking asyncio or Trio via `choose_anyio_backend`), and -returns an exit code. +raises `SystemExit` with the resulting status code. See [`examples/host/finite_service.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/host/finite_service.py), [`examples/host/channel.py`](https://github.com/alexeyshockov/localpost.py/blob/main/examples/host/channel.py). diff --git a/docs/modules/http.md b/docs/modules/http.md index 2b9d091..9a90795 100644 --- a/docs/modules/http.md +++ b/docs/modules/http.md @@ -43,7 +43,6 @@ pip install localpost[http-server,http-fast] # also adds the httptools backend The recommended path is `HttpApp`: ```python -import sys from localpost.hosting import run_app from localpost.http import HTTPReqCtx, ServerConfig from localpost.http.app import HttpApp @@ -65,7 +64,7 @@ def update_profile(ctx: HTTPReqCtx, name: str): return {"updated": name, "profile": profile} -sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) +run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) ``` Or stay close to the wire — `start_http_server` directly: @@ -90,13 +89,11 @@ with start_http_server(ServerConfig(), simple_app) as server: Running under hosting: ```python -import sys - from localpost.hosting import run_app from localpost.http import http_server, ServerConfig # `simple_app` from the Quick start above -sys.exit(run_app(http_server(ServerConfig(), simple_app))) +run_app(http_server(ServerConfig(), simple_app)) ``` See [`examples/http/`](https://github.com/alexeyshockov/localpost.py/tree/main/examples/http/). diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index 8caa3dd..32a1a11 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -42,7 +42,6 @@ pip install attrs cattrs ## Quick start ```python -import sys from dataclasses import dataclass from localpost import hosting @@ -80,7 +79,7 @@ def create_book(book: Book) -> Created[Book]: if __name__ == "__main__": - sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) + hosting.run_app(app.service(ServerConfig(port=8000))) ``` ```bash diff --git a/docs/modules/scheduler.md b/docs/modules/scheduler.md index edeb899..d666b2b 100644 --- a/docs/modules/scheduler.md +++ b/docs/modules/scheduler.md @@ -21,7 +21,6 @@ pip install localpost[scheduler,cron] # also the cron() trigger (croniter) ```python import random -import sys from localpost.hosting import run_app from localpost.scheduler import after, delay, every, scheduled_task, take_first @@ -37,7 +36,7 @@ async def task2(task1_result: int): if __name__ == "__main__": - sys.exit(run_app(task1, task2)) + run_app(task1, task2) ``` Cron: diff --git a/examples/host/channel.py b/examples/host/channel.py index 456d6d5..c73aefe 100755 --- a/examples/host/channel.py +++ b/examples/host/channel.py @@ -1,7 +1,5 @@ #!/usr/bin/env python -import sys - import anyio from localpost.hosting import ServiceLifetime, run_app, service @@ -38,4 +36,4 @@ async def writer(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(channel_example())) + run_app(channel_example()) diff --git a/examples/host/click_cli.py b/examples/host/click_cli.py index cc17133..e0f1aa4 100755 --- a/examples/host/click_cli.py +++ b/examples/host/click_cli.py @@ -6,8 +6,6 @@ services (a background worker, a metrics endpoint, ...) under one host. """ -import sys - import click from localpost.hosting import run_app @@ -21,4 +19,4 @@ def hello(name: str) -> None: if __name__ == "__main__": - sys.exit(run_app(click_cmd(hello))) + run_app(click_cmd(hello)) diff --git a/examples/host/finite_service.py b/examples/host/finite_service.py index caf8fc6..3edcd8e 100755 --- a/examples/host/finite_service.py +++ b/examples/host/finite_service.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -import sys import time from localpost.hosting import ServiceLifetime, run_app, service @@ -25,4 +24,4 @@ def svc(lt: ServiceLifetime): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(a_sync_service())) + run_app(a_sync_service()) diff --git a/examples/http/app_server.py b/examples/http/app_server.py index d23adfd..7b088ed 100644 --- a/examples/http/app_server.py +++ b/examples/http/app_server.py @@ -1,5 +1,4 @@ import json -import sys from localpost import hosting from localpost.http import HTTPReqCtx, ServerConfig, read_body @@ -20,4 +19,4 @@ def update_user_profile(ctx: HTTPReqCtx, name: str): if __name__ == "__main__": - sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) + hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) diff --git a/examples/http/compressed_api.py b/examples/http/compressed_api.py index 1d958dc..70fd62f 100644 --- a/examples/http/compressed_api.py +++ b/examples/http/compressed_api.py @@ -22,7 +22,6 @@ import json import logging -import sys from localpost.hosting import run_app, service from localpost.http import ( @@ -74,10 +73,10 @@ async def app(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/flask_app_server.py b/examples/http/flask_app_server.py index bfc31ea..0062870 100644 --- a/examples/http/flask_app_server.py +++ b/examples/http/flask_app_server.py @@ -17,7 +17,6 @@ from __future__ import annotations import logging -import sys from flask import Flask, Response from flask import request as flask_request @@ -62,10 +61,10 @@ async def app_svc(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app_svc()) + run_app(app_svc()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/middleware_basic_auth.py b/examples/http/middleware_basic_auth.py index 8098772..9401eeb 100644 --- a/examples/http/middleware_basic_auth.py +++ b/examples/http/middleware_basic_auth.py @@ -16,7 +16,6 @@ import base64 import logging -import sys from localpost.hosting import run_app, service from localpost.http import ( @@ -107,10 +106,10 @@ async def app(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/middleware_rate_limit.py b/examples/http/middleware_rate_limit.py index a204a1c..16ac637 100644 --- a/examples/http/middleware_rate_limit.py +++ b/examples/http/middleware_rate_limit.py @@ -16,7 +16,6 @@ from __future__ import annotations import logging -import sys import threading import time from collections import defaultdict, deque @@ -117,10 +116,10 @@ async def app(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/multithread_server.py b/examples/http/multithread_server.py index d3a1942..c5a5f4c 100644 --- a/examples/http/multithread_server.py +++ b/examples/http/multithread_server.py @@ -18,7 +18,6 @@ from __future__ import annotations import logging -import sys import threading import time @@ -77,10 +76,10 @@ async def app(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/sentry_router_server.py b/examples/http/sentry_router_server.py index e90348d..a5a34ff 100644 --- a/examples/http/sentry_router_server.py +++ b/examples/http/sentry_router_server.py @@ -16,7 +16,6 @@ import logging import os -import sys import time import sentry_sdk @@ -74,14 +73,14 @@ async def app(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) sentry_sdk.init( dsn=os.environ.get("SENTRY_DSN"), # None → Sentry runs in disabled mode traces_sample_rate=1.0, ) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/sse_compressed.py b/examples/http/sse_compressed.py index 791a9fd..e0613b3 100644 --- a/examples/http/sse_compressed.py +++ b/examples/http/sse_compressed.py @@ -28,7 +28,6 @@ from __future__ import annotations import logging -import sys import time from collections.abc import Iterator @@ -83,10 +82,10 @@ async def app(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/static_files.py b/examples/http/static_files.py index 1057742..47eef32 100644 --- a/examples/http/static_files.py +++ b/examples/http/static_files.py @@ -18,7 +18,6 @@ import logging import os -import sys from pathlib import Path from localpost.hosting import run_app, service @@ -76,10 +75,10 @@ def dispatch(ctx: HTTPReqCtx) -> None: yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(app()) + run_app(app()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/http/wsgi_app_server.py b/examples/http/wsgi_app_server.py index 1f6a089..37fd3cd 100644 --- a/examples/http/wsgi_app_server.py +++ b/examples/http/wsgi_app_server.py @@ -11,7 +11,6 @@ from __future__ import annotations import logging -import sys from flask import Flask from flask import request as flask_request @@ -54,10 +53,10 @@ async def wsgi_app_service(): yield -def main() -> int: +def main() -> None: logging.basicConfig(level=logging.INFO) - return run_app(wsgi_app_service()) + run_app(wsgi_app_service()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/examples/openapi/app.py b/examples/openapi/app.py index 98651f5..bbf97d4 100644 --- a/examples/openapi/app.py +++ b/examples/openapi/app.py @@ -19,7 +19,6 @@ http://localhost:8000/docs/scalar (Scalar) """ -import sys import time from collections.abc import Generator from dataclasses import dataclass @@ -142,4 +141,4 @@ def stream_pages(book_id: str) -> Generator[Event[BookPage]]: if __name__ == "__main__": - sys.exit(hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) + hosting.run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) diff --git a/examples/scheduler/app.py b/examples/scheduler/app.py index 12e2cfa..6ad904c 100755 --- a/examples/scheduler/app.py +++ b/examples/scheduler/app.py @@ -1,7 +1,6 @@ #!/usr/bin/env python import logging import random -import sys from localpost.hosting import run_app from localpost.scheduler import after, delay, every, scheduled_task, take_first @@ -29,4 +28,4 @@ async def task2(task1_result: int): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(task1, task2)) + run_app(task1, task2) diff --git a/examples/scheduler/cancellation.py b/examples/scheduler/cancellation.py index 46059d1..eac1547 100755 --- a/examples/scheduler/cancellation.py +++ b/examples/scheduler/cancellation.py @@ -1,6 +1,5 @@ #!/usr/bin/env python import logging -import sys from asyncio import CancelledError from datetime import timedelta @@ -31,4 +30,4 @@ async def long_async_task(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(long_async_task)) + run_app(long_async_task) diff --git a/examples/scheduler/cron.py b/examples/scheduler/cron.py index 3d5ca7f..250a175 100755 --- a/examples/scheduler/cron.py +++ b/examples/scheduler/cron.py @@ -1,7 +1,6 @@ #!/usr/bin/env python import logging -import sys from localpost.hosting import run_app from localpost.scheduler import delay, scheduled_task @@ -19,4 +18,4 @@ async def cron_job(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(cron_job)) + run_app(cron_job) diff --git a/examples/scheduler/failing_tasks.py b/examples/scheduler/failing_tasks.py index dfd3e81..f9232ad 100755 --- a/examples/scheduler/failing_tasks.py +++ b/examples/scheduler/failing_tasks.py @@ -1,7 +1,6 @@ #!/usr/bin/env python import logging -import sys from datetime import timedelta from localpost.hosting import run_app @@ -27,4 +26,4 @@ def a_sync_task(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(scheduler)) + run_app(scheduler) diff --git a/examples/scheduler/fastdepends.py b/examples/scheduler/fastdepends.py index 5112078..f2e3838 100755 --- a/examples/scheduler/fastdepends.py +++ b/examples/scheduler/fastdepends.py @@ -2,7 +2,6 @@ import logging import random -import sys from datetime import timedelta from fast_depends import Depends, inject @@ -30,4 +29,4 @@ def print_task( logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(print_task)) + run_app(print_task) diff --git a/examples/scheduler/finite_task.py b/examples/scheduler/finite_task.py index a7cd363..61f020a 100755 --- a/examples/scheduler/finite_task.py +++ b/examples/scheduler/finite_task.py @@ -1,7 +1,6 @@ #!/usr/bin/env python import logging import random -import sys from datetime import timedelta from localpost.hosting import run_app @@ -22,4 +21,4 @@ def task1(): logging.getLogger().setLevel(logging.INFO) logging.getLogger("localpost").setLevel(logging.DEBUG) - sys.exit(run_app(task1)) + run_app(task1) diff --git a/localpost/hosting/README.md b/localpost/hosting/README.md index d68c0ce..36e1a8c 100644 --- a/localpost/hosting/README.md +++ b/localpost/hosting/README.md @@ -6,7 +6,7 @@ Running → ShuttingDown → Stopped`, reacts to signals, and can spawn child services in the same task group. ```python -import sys, time +import time from localpost.hosting import ServiceLifetime, run_app, service @@ -19,11 +19,12 @@ def my_service(): if __name__ == "__main__": - sys.exit(run_app(my_service())) + run_app(my_service()) ``` `run_app()` wires `shutdown_on_signal()` (SIGINT / SIGTERM), runs services -under AnyIO (asyncio or Trio), and returns an exit code. +under AnyIO (asyncio or Trio), and raises `SystemExit` with the resulting +status code. **Full reference:** diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index 6c7fc40..87caf0a 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -1,3 +1,5 @@ +from typing import NoReturn + import anyio from .._utils import choose_anyio_backend @@ -38,9 +40,16 @@ ] -def run_app(*svcs: ServiceF) -> int: - """Run the target app until it stops or is interrupted by a signal.""" +def run_app(*svcs: ServiceF) -> NoReturn: + """Run the target app until it stops or is interrupted by a signal, then + exit the process via :class:`SystemExit` with the resulting status code + (``0`` on clean shutdown, ``1`` on failure). + + Intended as the program's ``__main__`` entrypoint — call directly, + no ``sys.exit(...)`` wrapper needed. + """ root_svc = svcs[0] if len(svcs) == 1 else _run_many(*svcs) app = shutdown_on_signal()(root_svc) - return anyio.run(run, app, None, **choose_anyio_backend()) + exit_code = anyio.run(run, app, None, **choose_anyio_backend()) + raise SystemExit(exit_code) diff --git a/localpost/http/README.md b/localpost/http/README.md index cf21585..af3f810 100644 --- a/localpost/http/README.md +++ b/localpost/http/README.md @@ -17,7 +17,6 @@ pip install localpost[http-server,http-fast] # also adds the httptools backend ``` ```python -import sys from localpost.hosting import run_app from localpost.http import ServerConfig from localpost.http.app import HttpApp @@ -31,7 +30,7 @@ def hello(name: str): return f"Hello, {name}!" -sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) +run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) ``` **Full reference:** diff --git a/localpost/http/__main__.py b/localpost/http/__main__.py index 89c0d60..626b619 100644 --- a/localpost/http/__main__.py +++ b/localpost/http/__main__.py @@ -4,7 +4,6 @@ import importlib import logging -import sys import click @@ -56,7 +55,7 @@ async def _serve(): async with http_server(config, handler, selectors=selectors, acceptor=acceptor): yield - sys.exit(hosting.run_app(_serve())) + hosting.run_app(_serve()) if __name__ == "__main__": diff --git a/localpost/http/app.py b/localpost/http/app.py index 09f461b..b330e3b 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -44,7 +44,7 @@ def upload_avatar(ctx: HTTPReqCtx, name: str): return Response(status_code=204, headers=[(b"content-length", b"0")]) - sys.exit(run_app(app.service(ServerConfig(host="127.0.0.1", port=8000)))) + run_app(app.service(ServerConfig(host="127.0.0.1", port=8000))) """ from __future__ import annotations diff --git a/localpost/openapi/README.md b/localpost/openapi/README.md index 1e1d66f..76367c0 100644 --- a/localpost/openapi/README.md +++ b/localpost/openapi/README.md @@ -12,7 +12,6 @@ pip install 'localpost[http,openapi]' ``` ```python -import sys from dataclasses import dataclass from localpost import hosting from localpost.http import ServerConfig @@ -35,7 +34,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: if __name__ == "__main__": - sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) + hosting.run_app(app.service(ServerConfig(port=8000))) ``` `/openapi.json` plus `/docs` (Swagger UI), `/docs/redoc`, `/docs/scalar` are diff --git a/localpost/openapi/__init__.py b/localpost/openapi/__init__.py index 9b6c8d2..e758c48 100644 --- a/localpost/openapi/__init__.py +++ b/localpost/openapi/__init__.py @@ -29,7 +29,7 @@ def get_book(book_id: str) -> Book | NotFound[str]: return Book(id=book_id, title="The Hitchhiker's Guide") - sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) + hosting.run_app(app.service(ServerConfig(port=8000))) Pydantic models are recognised automatically when pydantic is installed. To plug in another schema library, supply a custom diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 3dc7242..fef3f93 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -19,7 +19,7 @@ def hello(name: str) -> str: return f"Hello, {name}!" - sys.exit(hosting.run_app(app.service(ServerConfig(port=8000)))) + hosting.run_app(app.service(ServerConfig(port=8000))) """ from __future__ import annotations diff --git a/localpost/scheduler/README.md b/localpost/scheduler/README.md index a53fabb..bc6d79b 100644 --- a/localpost/scheduler/README.md +++ b/localpost/scheduler/README.md @@ -12,7 +12,7 @@ pip install localpost[scheduler,cron] # also the cron() trigger ``` ```python -import sys, random +import random from localpost.hosting import run_app from localpost.scheduler import every, scheduled_task @@ -23,7 +23,7 @@ async def task1(): if __name__ == "__main__": - sys.exit(run_app(task1)) + run_app(task1) ``` **Full reference:** diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md index 98182f5..bde5f19 100644 --- a/localpost/threadtools/README.md +++ b/localpost/threadtools/README.md @@ -5,6 +5,7 @@ Thread-friendly building blocks for moving work off the event loop: - [`Channel`](#channel) — a typed, thread-safe queue with `timeout` on `put` / `get` and broadcast-on-close. - [`Executor`](#executors) — three implementations, one `submit` contract. - [`TaskGroup`](#taskgroup) — Trio-style structured concurrency over an `Executor`. +- [`run_async`](#run_async) — sync→async bridge: dispatch a coroutine onto the current service's loop from a worker thread. `localpost.threadtools` is built on plain locks; the AnyIO loop is needed only for the `Async…Executor` variants. @@ -91,6 +92,25 @@ with WorkerExecutor() as ex: `TaskGroup` is **collect-and-raise only** — running tasks are not interrupted on the first failure. If you want cooperative cancel, give it an `AsyncWorkerExecutor` / `AsyncExecutor` so tasks can poll `check_cancelled`. +## `run_async` + +The reverse of `anyio.to_thread.run_sync` — call from a worker thread to dispatch an async function back onto the loop and wait for its result. + +```python +from localpost.threadtools import run_async + + +async def fetch_user(user_id: int) -> User: + ... + + +def worker(user_id: int) -> str: + user = run_async(fetch_user, user_id) # blocks the worker thread + return user.name +``` + +Resolves the portal via `localpost.hosting.current_service`, so the calling thread must inherit the hosting context (true for any thread spawned through AnyIO or the executors above). Must be called from a non-loop thread; on the loop thread the underlying `BlockingPortal.call` raises `RuntimeError`. + ## Composing with `localpost.hosting` `localpost.hosting._serve_root` already runs a single `BlockingPortal` for the whole app. It's exposed on the service lifetime view so you can layer an executor on top of it without opening a second portal: diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index e1ed1b0..f3fe3f0 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -7,6 +7,7 @@ Task, WorkerExecutor, ) +from ._run_async import run_async from ._task_group import TaskGroup __all__ = [ @@ -20,4 +21,5 @@ "Task", "TaskGroup", "WorkerExecutor", + "run_async", ] diff --git a/localpost/threadtools/_run_async.py b/localpost/threadtools/_run_async.py new file mode 100644 index 0000000..2a97b87 --- /dev/null +++ b/localpost/threadtools/_run_async.py @@ -0,0 +1,39 @@ +"""``run_async`` — bridge from a sync (worker) thread back into the loop. + +Mirrors :func:`anyio.to_thread.run_sync` in reverse. The dispatch goes to the +current service's :class:`anyio.from_thread.BlockingPortal`, so the loop is +the one that hosts the running service — no need to thread the portal through +manually. +""" + +from __future__ import annotations + +import functools +from collections.abc import Awaitable, Callable + + +def run_async[**P, R]( + func: Callable[P, Awaitable[R]], + /, + *args: P.args, + **kwargs: P.kwargs, +) -> R: + """Run an async ``func`` on the current service's loop and return its result. + + Counterpart to :func:`anyio.to_thread.run_sync`: call from a worker thread + to dispatch back into the event loop. Resolves the portal via + :func:`localpost.hosting.current_service`, so the caller's + :class:`contextvars.Context` must carry the hosting context (true for any + thread spawned through AnyIO / the threadtools executors). + + Must be called from a non-loop thread; the underlying + :meth:`anyio.from_thread.BlockingPortal.call` raises ``RuntimeError`` if + invoked from the loop thread. + """ + # Local import: ``localpost.hosting`` pulls in ``localpost.http``, which in + # turn imports ``localpost.threadtools`` — a top-level import here would + # close the cycle. + from localpost.hosting import current_service # noqa: PLC0415 + + portal = current_service().portal + return portal.call(functools.partial(func, *args, **kwargs)) diff --git a/pypi_readme.md b/pypi_readme.md index a67b4e9..84eace0 100644 --- a/pypi_readme.md +++ b/pypi_readme.md @@ -23,7 +23,6 @@ Python 3.12+ required. ```python import random -import sys from localpost.hosting import run_app from localpost.scheduler import after, every, scheduled_task @@ -39,7 +38,7 @@ async def task2(x: int): if __name__ == "__main__": - sys.exit(run_app(task1, task2)) + run_app(task1, task2) ``` ## Docs diff --git a/tests/hosting/_signal_app.py b/tests/hosting/_signal_app.py index adc6078..54ec136 100644 --- a/tests/hosting/_signal_app.py +++ b/tests/hosting/_signal_app.py @@ -7,8 +7,6 @@ from __future__ import annotations -import sys - from localpost.hosting import ServiceLifetime, run_app @@ -20,4 +18,4 @@ async def long_running(lt: ServiceLifetime) -> None: if __name__ == "__main__": - sys.exit(run_app(long_running)) + run_app(long_running) diff --git a/tests/hosting/run_app.py b/tests/hosting/run_app.py index 04bee01..bfba6cd 100644 --- a/tests/hosting/run_app.py +++ b/tests/hosting/run_app.py @@ -1,8 +1,9 @@ """Tests for ``localpost.hosting.run_app`` — the documented sync entrypoint. ``run_app`` is the headline API users call from ``__main__``. It composes -``shutdown_on_signal`` middleware with the supplied services and drives them -under ``anyio.run`` with the platform-default backend. +``shutdown_on_signal`` middleware with the supplied services, drives them +under ``anyio.run`` with the platform-default backend, and exits the process +via ``SystemExit`` with the resulting status code. These tests run synchronously (not under pytest-anyio) because ``run_app`` itself takes care of starting an event loop. @@ -13,28 +14,33 @@ import threading import anyio +import pytest from localpost.hosting import ServiceLifetime, run_app -def test_run_app_with_single_service_returns_zero(): - """A finite single service runs to completion and ``run_app`` returns 0.""" +def test_run_app_with_single_service_exits_zero(): + """A finite single service runs to completion and ``run_app`` exits 0.""" async def finite_svc(lt: ServiceLifetime) -> None: lt.set_started() await anyio.sleep(0.05) - assert run_app(finite_svc) == 0 + with pytest.raises(SystemExit) as exc_info: + run_app(finite_svc) + assert exc_info.value.code == 0 -def test_run_app_with_failing_service_returns_nonzero(): - """If the service raises, exit_code is 1.""" +def test_run_app_with_failing_service_exits_nonzero(): + """If the service raises, the process exits with code 1.""" async def boom(lt: ServiceLifetime) -> None: lt.set_started() raise RuntimeError("boom") - assert run_app(boom) == 1 + with pytest.raises(SystemExit) as exc_info: + run_app(boom) + assert exc_info.value.code == 1 def test_run_app_with_multiple_services_runs_all(): @@ -51,5 +57,7 @@ async def svc(lt: ServiceLifetime) -> None: return svc - assert run_app(make_svc("a"), make_svc("b"), make_svc("c")) == 0 + with pytest.raises(SystemExit) as exc_info: + run_app(make_svc("a"), make_svc("b"), make_svc("c")) + assert exc_info.value.code == 0 assert set(finished) == {"a", "b", "c"} diff --git a/tests/threadtools/run_async.py b/tests/threadtools/run_async.py new file mode 100644 index 0000000..1ed9307 --- /dev/null +++ b/tests/threadtools/run_async.py @@ -0,0 +1,123 @@ +"""Tests for :func:`localpost.threadtools.run_async`. + +``run_async`` is the sync→async bridge: from a worker thread it dispatches a +coroutine onto the current service's loop and blocks for the result. The +portal it uses is resolved via :func:`localpost.hosting.current_service`, so +each test runs inside a hosted service. +""" + +from __future__ import annotations + +import threading + +import anyio +import pytest + +from localpost.hosting import ServiceLifetime, serve +from localpost.threadtools import run_async + +pytestmark = pytest.mark.anyio + + +async def test_run_async_returns_result_from_worker_thread(): + """A worker thread dispatches an async function back onto the loop and + receives its return value.""" + seen: dict = {} + + async def add(a: int, b: int) -> int: + seen["loop_thread"] = threading.get_ident() + return a + b + + def worker() -> int: + seen["worker_thread"] = threading.get_ident() + return run_async(add, 2, 3) + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + seen["result"] = await anyio.to_thread.run_sync(worker) + + async with serve(svc) as lt: + await lt.stopped + + assert seen["result"] == 5 + assert seen["worker_thread"] != seen["loop_thread"] + + +async def test_run_async_propagates_exception(): + """Exceptions raised by the coroutine surface in the calling thread.""" + + class Boom(Exception): + pass + + async def explode() -> None: + raise Boom("nope") + + captured: dict = {} + + def worker() -> None: + try: + run_async(explode) + except Boom as e: + captured["exc"] = e + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.to_thread.run_sync(worker) + + async with serve(svc) as lt: + await lt.stopped + + assert isinstance(captured.get("exc"), Boom) + + +async def test_run_async_supports_keyword_arguments(): + """``run_async`` forwards keyword arguments via :class:`functools.partial`.""" + + async def greet(name: str, *, greeting: str) -> str: + return f"{greeting}, {name}!" + + seen: dict = {} + + def worker() -> None: + seen["result"] = run_async(greet, "world", greeting="hi") + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + await anyio.to_thread.run_sync(worker) + + async with serve(svc) as lt: + await lt.stopped + + assert seen["result"] == "hi, world!" + + +async def test_run_async_from_loop_thread_raises(): + """Calling :func:`run_async` from the loop thread is a deadlock; the + underlying portal raises ``RuntimeError``.""" + + async def noop() -> None: + return None + + seen: dict = {} + + async def svc(lt: ServiceLifetime) -> None: + lt.set_started() + try: + run_async(noop) + except RuntimeError as e: + seen["exc"] = e + + async with serve(svc) as lt: + await lt.stopped + + assert isinstance(seen.get("exc"), RuntimeError) + + +def test_run_async_outside_hosting_raises(): + """No hosting context → :func:`current_service` raises ``RuntimeError``.""" + + async def noop() -> None: + return None + + with pytest.raises(RuntimeError, match="Not in hosting context"): + run_async(noop) From 52803d68191872085c66d47252e6636a999b2bc7 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 00:29:09 +0400 Subject: [PATCH 260/286] refactor(hosting)!: drop HostRSGIApp re-export from hosting/__init__.py Importing HostRSGIApp at package init pulled localpost.http (and transitively localpost.threadtools) into the hosting bootstrap, closing a cycle the moment any non-hosting module needed current_service. Move HostRSGIApp behind localpost.hosting.rsgi so the hosting package init no longer depends on http; update tests, docs, and the docstring example. threadtools._run_async can now import current_service at the top level. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/deployment-topologies.md | 4 +++- docs/modules/hosting.md | 6 +++--- docs/modules/http.md | 2 +- docs/modules/openapi.md | 6 +++--- localpost/hosting/__init__.py | 2 -- localpost/hosting/rsgi.py | 4 ++-- localpost/threadtools/_run_async.py | 7 ++----- tests/hosting/rsgi.py | 21 +++++++++++---------- tests/openapi/aio_rsgi_integration.py | 3 ++- 9 files changed, 27 insertions(+), 28 deletions(-) diff --git a/docs/design/deployment-topologies.md b/docs/design/deployment-topologies.md index 6399ad4..bd5d727 100644 --- a/docs/design/deployment-topologies.md +++ b/docs/design/deployment-topologies.md @@ -72,7 +72,9 @@ via Granian's RSGI hook. API: ```python -rsgi_app = hosting.HostRSGIApp( +from localpost.hosting.rsgi import HostRSGIApp + +rsgi_app = HostRSGIApp( services=[scheduler.service(), other_service.service()], rsgi_handler=app, # HttpAsyncApp or AsyncRequestHandler ) diff --git a/docs/modules/hosting.md b/docs/modules/hosting.md index 540b8e9..24cbc80 100644 --- a/docs/modules/hosting.md +++ b/docs/modules/hosting.md @@ -91,13 +91,13 @@ other service. ## Host as RSGI for Granian -`localpost.hosting.HostRSGIApp` runs the full hosting lifecycle (multiple +`localpost.hosting.rsgi.HostRSGIApp` runs the full hosting lifecycle (multiple services + an HTTP handler) inside each Granian worker. Granian is a process supervisor that spawns workers and loads our app via its RSGI interface, so the topology flips: the host *itself* implements RSGI. ```python -from localpost import hosting +from localpost.hosting.rsgi import HostRSGIApp from localpost.openapi import HttpAsyncApp from localpost.scheduler import every, scheduled_task @@ -114,7 +114,7 @@ async def root() -> str: async def heartbeat() -> None: ... -rsgi_app = hosting.HostRSGIApp( +rsgi_app = HostRSGIApp( services=[heartbeat.service()], rsgi_handler=app, ) diff --git a/docs/modules/http.md b/docs/modules/http.md index 9a90795..5178264 100644 --- a/docs/modules/http.md +++ b/docs/modules/http.md @@ -211,7 +211,7 @@ For framework-flavoured deployment, see [`localpost.openapi.HttpAsyncApp.as_rsgi()`](openapi.md#async-flavour-httpasyncapp). For deployments where the HTTP app shares a worker process with **other hosted services** (scheduler / gRPC / custom workers), -[`localpost.hosting.HostRSGIApp`](hosting.md#host-as-rsgi-for-granian) +[`localpost.hosting.rsgi.HostRSGIApp`](hosting.md#host-as-rsgi-for-granian) runs the full hosting lifecycle inside each Granian worker. The asymmetry between uvicorn-as-a-hosted-service and Granian-as-a-supervisor is covered in diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md index 32a1a11..657cdaf 100644 --- a/docs/modules/openapi.md +++ b/docs/modules/openapi.md @@ -427,18 +427,18 @@ mode. Requires the `[rsgi]` extra (`pip install 'localpost[rsgi]'`). **Hosted apps under Granian** — when the HTTP app shares its worker process with other hosted services (scheduler, gRPC, custom workers), -deploy through `localpost.hosting.HostRSGIApp` instead, which runs the +deploy through `localpost.hosting.rsgi.HostRSGIApp` instead, which runs the full hosting lifecycle inside each Granian worker: ```python -from localpost import hosting +from localpost.hosting.rsgi import HostRSGIApp from localpost.scheduler import every, scheduled_task @scheduled_task(every(seconds=5)) async def heartbeat(): ... -rsgi_app = hosting.HostRSGIApp( +rsgi_app = HostRSGIApp( services=[heartbeat.service()], rsgi_handler=app, ) diff --git a/localpost/hosting/__init__.py b/localpost/hosting/__init__.py index 87caf0a..ae5f03d 100644 --- a/localpost/hosting/__init__.py +++ b/localpost/hosting/__init__.py @@ -20,7 +20,6 @@ service, ) from .middleware import shutdown_on_signal -from .rsgi import HostRSGIApp __all__ = [ "service", @@ -36,7 +35,6 @@ "ServiceLifetime", "ServiceLifetimeView", "Stopped", - "HostRSGIApp", ] diff --git a/localpost/hosting/rsgi.py b/localpost/hosting/rsgi.py index fb6c595..b481c62 100644 --- a/localpost/hosting/rsgi.py +++ b/localpost/hosting/rsgi.py @@ -57,7 +57,7 @@ class HostRSGIApp: Example:: - from localpost import hosting + from localpost.hosting.rsgi import HostRSGIApp from localpost.openapi import HttpAsyncApp from localpost.scheduler import every, scheduled_task @@ -74,7 +74,7 @@ async def root() -> str: async def heartbeat() -> None: ... - rsgi_app = hosting.HostRSGIApp( + rsgi_app = HostRSGIApp( services=[heartbeat.service()], rsgi_handler=app, ) diff --git a/localpost/threadtools/_run_async.py b/localpost/threadtools/_run_async.py index 2a97b87..85087b2 100644 --- a/localpost/threadtools/_run_async.py +++ b/localpost/threadtools/_run_async.py @@ -11,6 +11,8 @@ import functools from collections.abc import Awaitable, Callable +from localpost.hosting import current_service + def run_async[**P, R]( func: Callable[P, Awaitable[R]], @@ -30,10 +32,5 @@ def run_async[**P, R]( :meth:`anyio.from_thread.BlockingPortal.call` raises ``RuntimeError`` if invoked from the loop thread. """ - # Local import: ``localpost.hosting`` pulls in ``localpost.http``, which in - # turn imports ``localpost.threadtools`` — a top-level import here would - # close the cycle. - from localpost.hosting import current_service # noqa: PLC0415 - portal = current_service().portal return portal.call(functools.partial(func, *args, **kwargs)) diff --git a/tests/hosting/rsgi.py b/tests/hosting/rsgi.py index fe32c92..03c7931 100644 --- a/tests/hosting/rsgi.py +++ b/tests/hosting/rsgi.py @@ -1,4 +1,4 @@ -"""Tests for ``localpost.hosting.HostRSGIApp`` — host-as-RSGI for +"""Tests for ``localpost.hosting.rsgi.HostRSGIApp`` — host-as-RSGI for hosted apps under Granian. Drives the lifecycle hooks (``__rsgi_init__`` / ``__rsgi__`` / @@ -22,6 +22,7 @@ import pytest from localpost import hosting +from localpost.hosting.rsgi import HostRSGIApp from localpost.http import AsyncHTTPReqCtx, Response # All tests in this file run under asyncio (HostRSGIApp uses asyncio @@ -105,7 +106,7 @@ async def svc(sl: hosting.ServiceLifetime) -> None: # --- Helpers ------------------------------------------------------------ -async def _drive_startup(app: hosting.HostRSGIApp) -> None: +async def _drive_startup(app: HostRSGIApp) -> None: """Trigger ``__rsgi_init__`` then yield to the loop until services are started — Granian's real flow is async, so we mimic that here.""" loop = asyncio.get_running_loop() @@ -114,7 +115,7 @@ async def _drive_startup(app: hosting.HostRSGIApp) -> None: await app._ready.wait() -async def _drive_shutdown(app: hosting.HostRSGIApp) -> None: +async def _drive_shutdown(app: HostRSGIApp) -> None: """Trigger ``__rsgi_del__`` and let the loop drain the lifecycle task.""" loop = asyncio.get_running_loop() app.__rsgi_del__(loop) @@ -137,7 +138,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: log.append("request") await ctx.complete(Response(200), b"ok") - app = hosting.HostRSGIApp( + app = HostRSGIApp( services=[_tracking_service("svc", log)], rsgi_handler=handler, ) @@ -158,7 +159,7 @@ async def test_request_dispatch_works(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: await ctx.complete(Response(200), b"hello-from-host") - app = hosting.HostRSGIApp(services=[], rsgi_handler=handler) + app = HostRSGIApp(services=[], rsgi_handler=handler) await _drive_startup(app) try: proto = _FakeProto() @@ -175,7 +176,7 @@ async def test_no_services_no_lifecycle_overhead(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: await ctx.complete(Response(200), b"ok") - app = hosting.HostRSGIApp(services=[], rsgi_handler=handler) + app = HostRSGIApp(services=[], rsgi_handler=handler) await _drive_startup(app) await _drive_shutdown(app) # ``_ready`` stays ``None`` when there are no services — dispatch @@ -188,7 +189,7 @@ async def test_multiple_services_all_run(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: await ctx.complete(Response(200), b"ok") - app = hosting.HostRSGIApp( + app = HostRSGIApp( services=[ _tracking_service("a", log), _tracking_service("b", log), @@ -211,7 +212,7 @@ async def test_accepts_async_request_handler(self) -> None: async def handler(ctx: AsyncHTTPReqCtx) -> None: await ctx.complete(Response(200), b"raw") - app = hosting.HostRSGIApp(services=[], rsgi_handler=handler) + app = HostRSGIApp(services=[], rsgi_handler=handler) await _drive_startup(app) try: proto = _FakeProto() @@ -230,7 +231,7 @@ async def hello() -> str: return "world" _ = hello - app = hosting.HostRSGIApp(services=[], rsgi_handler=oapi_app) + app = HostRSGIApp(services=[], rsgi_handler=oapi_app) await _drive_startup(app) try: proto = _FakeProto() @@ -259,7 +260,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: log.append("request") await ctx.complete(Response(200), b"ok") - app = hosting.HostRSGIApp(services=[slow_starter()], rsgi_handler=handler) + app = HostRSGIApp(services=[slow_starter()], rsgi_handler=handler) loop = asyncio.get_running_loop() app.__rsgi_init__(loop) # Fire the request immediately — must not be served until diff --git a/tests/openapi/aio_rsgi_integration.py b/tests/openapi/aio_rsgi_integration.py index 317fa55..7819d4e 100644 --- a/tests/openapi/aio_rsgi_integration.py +++ b/tests/openapi/aio_rsgi_integration.py @@ -26,6 +26,7 @@ from granian.server.embed import Server from localpost import hosting +from localpost.hosting.rsgi import HostRSGIApp from localpost.openapi import ( BadRequest, Created, @@ -214,7 +215,7 @@ async def heartbeat(sl: hosting.ServiceLifetime) -> None: await asyncio.sleep(0.05) app = _library_app() - rsgi_app = hosting.HostRSGIApp( + rsgi_app = HostRSGIApp( services=[heartbeat()], rsgi_handler=app, ) From ea749ee89dfc40b90e708578ec540bee20b78b2d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sat, 9 May 2026 20:34:12 +0000 Subject: [PATCH 261/286] cleanup --- localpost/threadtools/__init__.py | 30 +++++++++++++++++++++--- localpost/threadtools/_run_async.py | 36 ----------------------------- 2 files changed, 27 insertions(+), 39 deletions(-) delete mode 100644 localpost/threadtools/_run_async.py diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index f3fe3f0..3b96ad8 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,13 +1,16 @@ +import functools +from collections.abc import Awaitable, Callable + +from localpost.hosting import current_service + from ._channel import Channel, ReceiveChannel, SendChannel from ._executor import ( DEFAULT_IDLE_TIMEOUT, AsyncExecutor, AsyncWorkerExecutor, Executor, - Task, WorkerExecutor, ) -from ._run_async import run_async from ._task_group import TaskGroup __all__ = [ @@ -18,8 +21,29 @@ "Executor", "ReceiveChannel", "SendChannel", - "Task", "TaskGroup", "WorkerExecutor", "run_async", ] + + +def run_async[**P, R]( + func: Callable[P, Awaitable[R]], + /, + *args: P.args, + **kwargs: P.kwargs, +) -> R: + """Run an async ``func`` on the current service's loop and return its result. + + Counterpart to :func:`anyio.to_thread.run_sync`: call from a worker thread + to dispatch back into the event loop. Resolves the portal via + :func:`localpost.hosting.current_service`, so the caller's + :class:`contextvars.Context` must carry the hosting context (true for any + thread spawned through AnyIO / the threadtools executors). + + Must be called from a non-loop thread; the underlying + :meth:`anyio.from_thread.BlockingPortal.call` raises ``RuntimeError`` if + invoked from the loop thread. + """ + portal = current_service().portal + return portal.start_task_soon(functools.partial(func, **kwargs), *args).result() diff --git a/localpost/threadtools/_run_async.py b/localpost/threadtools/_run_async.py deleted file mode 100644 index 85087b2..0000000 --- a/localpost/threadtools/_run_async.py +++ /dev/null @@ -1,36 +0,0 @@ -"""``run_async`` — bridge from a sync (worker) thread back into the loop. - -Mirrors :func:`anyio.to_thread.run_sync` in reverse. The dispatch goes to the -current service's :class:`anyio.from_thread.BlockingPortal`, so the loop is -the one that hosts the running service — no need to thread the portal through -manually. -""" - -from __future__ import annotations - -import functools -from collections.abc import Awaitable, Callable - -from localpost.hosting import current_service - - -def run_async[**P, R]( - func: Callable[P, Awaitable[R]], - /, - *args: P.args, - **kwargs: P.kwargs, -) -> R: - """Run an async ``func`` on the current service's loop and return its result. - - Counterpart to :func:`anyio.to_thread.run_sync`: call from a worker thread - to dispatch back into the event loop. Resolves the portal via - :func:`localpost.hosting.current_service`, so the caller's - :class:`contextvars.Context` must carry the hosting context (true for any - thread spawned through AnyIO / the threadtools executors). - - Must be called from a non-loop thread; the underlying - :meth:`anyio.from_thread.BlockingPortal.call` raises ``RuntimeError`` if - invoked from the loop thread. - """ - portal = current_service().portal - return portal.call(functools.partial(func, *args, **kwargs)) From 6d23d339f4ddaab08413a487fa998ddefc85dce4 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 01:22:58 +0400 Subject: [PATCH 262/286] feat(portal)!: add Portal wrapper for thread-aware loop dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlockingPortal.call() raises if invoked from the loop thread, so every caller had to know its calling context up-front. AsyncExecutor.stop(), AsyncWorkerExecutor.stop(), and friends carried "must be called from a non-loop thread" caveats; hosting/_host.py duplicated the pattern with a hand-rolled thread_id field, same_thread property, and in_host_thread helper. Introduce localpost.Portal: a wrapper that snapshots the loop thread's ident at construction and exposes run_sync (direct call on-loop, portal.call off-loop) and run_async (start_task_soon().result(), with a clean RuntimeError on the loop thread instead of a deadlock). Public API changes: - ServiceLifetimeView.portal, AsyncExecutor(portal=), and AsyncWorkerExecutor(portal=) now use Portal instead of BlockingPortal. - AsyncExecutor.stop() / AsyncWorkerExecutor.stop() are safe from any thread; their docstring caveats go away. - Drop ServiceLifetime.thread_id, ServiceLifetime.same_thread, hosting._host.in_host_thread — replaced by Portal.same_thread / Portal.run_sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/__init__.py | 3 +- localpost/_portal.py | 77 +++++++++++++++++++++++++++++ localpost/hosting/_host.py | 51 +++++++------------ localpost/http/app.py | 6 +-- localpost/openapi/app.py | 5 +- localpost/threadtools/README.md | 34 ++++++++++--- localpost/threadtools/__init__.py | 11 ++--- localpost/threadtools/_executor.py | 37 ++++++-------- tests/threadtools/async_executor.py | 33 +++++++------ tests/threadtools/executor.py | 2 +- 10 files changed, 168 insertions(+), 91 deletions(-) create mode 100644 localpost/_portal.py diff --git a/localpost/__init__.py b/localpost/__init__.py index 05898b3..7de81b8 100644 --- a/localpost/__init__.py +++ b/localpost/__init__.py @@ -2,6 +2,7 @@ from importlib.metadata import PackageNotFoundError, version from ._debug import debug +from ._portal import Portal from ._utils import Result try: @@ -10,7 +11,7 @@ __version__ = "dev" -__all__ = ["Result", "__version__", "debug"] +__all__ = ["Portal", "Result", "__version__", "debug"] # Set up logging according to the best practices: diff --git a/localpost/_portal.py b/localpost/_portal.py new file mode 100644 index 0000000..60f854f --- /dev/null +++ b/localpost/_portal.py @@ -0,0 +1,77 @@ +"""Thread-aware view over :class:`anyio.from_thread.BlockingPortal`. + +Holds the loop thread's ident, captured at construction (the call site of +``Portal(...)`` *must* be on the loop thread). Lets callers schedule work on +the portal's loop without having to know whether they're already on it — +``run_sync`` does the right dispatch in either direction, and ``run_async`` +adds a clean deadlock guard for blocking awaits. +""" + +from __future__ import annotations + +import functools +import threading +from collections.abc import Awaitable, Callable +from typing import final + +from anyio.from_thread import BlockingPortal + + +@final +class Portal: + """Wraps :class:`BlockingPortal` with loop-thread awareness. + + Construct on the loop thread (``Portal(portal)`` snapshots the current + thread's ident as the loop thread). + """ + + def __init__(self, portal: BlockingPortal) -> None: + self._p = portal + self._loop_tid = threading.get_ident() + + @property + def raw(self) -> BlockingPortal: + """The underlying :class:`BlockingPortal`. Escape hatch for AnyIO + ecosystem methods (``wrap_async_context_manager``, etc.). + """ + return self._p + + @property + def same_thread(self) -> bool: + """``True`` iff the current thread is the portal's loop thread.""" + return threading.get_ident() == self._loop_tid + + def run_sync[**P, R]( + self, + fn: Callable[P, R], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run a sync ``fn`` on the portal's loop and return its result. + + On the loop thread the call is direct; off-loop it routes through + :meth:`BlockingPortal.call`. Either way the call completes + synchronously from the caller's POV. + """ + if self.same_thread: + return fn(*args, **kwargs) + return self._p.call(functools.partial(fn, **kwargs), *args) + + def run_async[**P, R]( + self, + fn: Callable[P, Awaitable[R]], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run an async ``fn`` on the portal's loop and block this thread + until it returns. + + Off-loop only: from the loop thread we'd be blocking the loop while + waiting for itself to make progress, so :class:`RuntimeError` is + raised instead of deadlocking. + """ + if self.same_thread: + raise RuntimeError("Deadlock: synchronous wait on the portal's loop thread") + return self._p.start_task_soon(functools.partial(fn, **kwargs), *args).result() diff --git a/localpost/hosting/_host.py b/localpost/hosting/_host.py index 71dc117..522061e 100644 --- a/localpost/hosting/_host.py +++ b/localpost/hosting/_host.py @@ -2,7 +2,6 @@ import inspect import logging -import threading from _contextvars import ContextVar from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable from contextlib import ( @@ -20,6 +19,7 @@ from anyio.abc import TaskGroup from anyio.from_thread import BlockingPortal +from localpost._portal import Portal from localpost._utils import ( Event, EventView, @@ -113,9 +113,9 @@ def state(self) -> ServiceState: return self._state.state @property - def portal(self) -> BlockingPortal: - """The hosting layer's :class:`BlockingPortal` (one per app, shared - with every nested service). Use it to compose + def portal(self) -> Portal: + """The hosting layer's :class:`Portal` (one per app, shared with + every nested service). Use it to compose :class:`localpost.threadtools.AsyncWorkerExecutor` / :class:`localpost.threadtools.AsyncExecutor` against the same loop that hosts the service. @@ -124,17 +124,11 @@ def portal(self) -> BlockingPortal: def wait_started(self) -> None: """Helper for sync code, to wait in a thread.""" - if self._state.same_thread: - raise RuntimeError("Deadlock: synchronous wait in the async thread") - # noinspection PyTypeChecker - return self._state.portal.start_task_soon(self.started.wait).result() + self._state.portal.run_async(self.started.wait) def wait_shutting_down(self) -> None: """Helper for sync code, to wait in a thread.""" - if self._state.same_thread: - raise RuntimeError("Deadlock: synchronous wait in the async thread") - # noinspection PyTypeChecker - return self._state.portal.start_task_soon(self.shutting_down.wait).result() + self._state.portal.run_async(self.shutting_down.wait) @overload def cancel_on_shutdown[F: Callable[..., Any]](self) -> Callable[[F], F]: ... @@ -163,20 +157,18 @@ def do_shutdown(): self._state.shutdown_reason = reason self._state.shutting_down.set() - in_host_thread(self._state, do_shutdown) + self._state.portal.run_sync(do_shutdown) def stop(self) -> None: - in_host_thread(self._state, self._state.run_scope.cancel) + self._state.portal.run_sync(self._state.run_scope.cancel) @define(eq=False, unsafe_hash=True) class ServiceLifetime: - portal: BlockingPortal + portal: Portal tg: TaskGroup = field(default_factory=create_task_group) scope: AsyncExitStack = field(default_factory=AsyncExitStack) - thread_id: int = field(default_factory=threading.get_ident) - started: Event = field(default_factory=Event) shutting_down: Event = field(default_factory=Event) stopped: Event = field(default_factory=Event) @@ -240,16 +232,12 @@ def state(self) -> ServiceState: return Running() return Starting() - @property - def same_thread(self) -> bool: - return threading.get_ident() == self.thread_id - def set_started(self) -> None: def do_set(): assert not self.stopped, "Cannot mark already stopped service as started" self.started.set() - in_host_thread(self, do_set) + self.portal.run_sync(do_set) def set_shutting_down(self, reason: BaseException | str | None = None) -> None: def do_set(): @@ -258,7 +246,7 @@ def do_set(): self.shutdown_reason = reason self.shutting_down.set() - in_host_thread(self, do_set) + self.portal.run_sync(do_set) def start(self, svc_f: ServiceF, /) -> ServiceLifetimeView: """Start a child service from the given function.""" @@ -268,14 +256,7 @@ def do_start() -> ServiceLifetimeView: self.tg.start_soon(_run, svc_f, child_lt) return child_lt.view - return in_host_thread(self, do_start) - - -def in_host_thread[R](h: ServiceLifetime, func: Callable[..., R], *args) -> R: - if h.same_thread: - return func(*args) - # noinspection PyTypeChecker - return h.portal.start_task_soon(func).result() + return self.portal.run_sync(do_start) ServiceF = Callable[[ServiceLifetime], Awaitable[None]] @@ -445,11 +426,12 @@ async def wait_stopped(): await child_lt.stopped wait_tg.cancel_scope.cancel() - async with BlockingPortal() as portal: + async with BlockingPortal() as raw_portal: + portal = Portal(raw_portal) child_lt = ServiceLifetime(portal) app_token = _app_lt.set(child_lt) try: - tg = portal._task_group + tg = raw_portal._task_group tg.start_soon(_run, svc, child_lt) # Wait for either started or stopped (service may fail before starting) async with create_task_group() as wait_tg: @@ -500,7 +482,8 @@ async def run(svc_f: ServiceF, /, parent: ServiceLifetime | None = None) -> int: lt = ServiceLifetime(parent.portal) await _run(svc_f, lt) return lt.exit_code - async with BlockingPortal() as portal: + async with BlockingPortal() as raw_portal: + portal = Portal(raw_portal) lt = ServiceLifetime(portal) app_token = _app_lt.set(lt) try: diff --git a/localpost/http/app.py b/localpost/http/app.py index b330e3b..b595e75 100644 --- a/localpost/http/app.py +++ b/localpost/http/app.py @@ -308,9 +308,9 @@ def service( :class:`localpost.threadtools.Executor` to share one across services. When omitted (and ``pooled=True``), an :class:`AsyncWorkerExecutor` is opened on the hosting layer's - :class:`anyio.from_thread.BlockingPortal` for the lifetime of the - service — handlers can call - :func:`anyio.from_thread.check_cancelled` for free. + :class:`localpost.Portal` for the lifetime of the service — + handlers can call :func:`anyio.from_thread.check_cancelled` for + free. ``selectors`` and ``acceptor`` forward to :func:`http_server`. """ diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index fef3f93..2cb341d 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -204,9 +204,8 @@ def service( ``executor`` is the thread executor that runs handlers; pass an already-open :class:`localpost.threadtools.Executor` to share one across services. When omitted, an :class:`AsyncWorkerExecutor` is - opened on the hosting layer's - :class:`anyio.from_thread.BlockingPortal` for the lifetime of the - service — handlers can call + opened on the hosting layer's :class:`localpost.Portal` for the + lifetime of the service — handlers can call :func:`anyio.from_thread.check_cancelled` for free. ``selectors`` and ``acceptor`` forward to :func:`http_server`. diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md index bde5f19..f19393f 100644 --- a/localpost/threadtools/README.md +++ b/localpost/threadtools/README.md @@ -4,6 +4,7 @@ Thread-friendly building blocks for moving work off the event loop: - [`Channel`](#channel) — a typed, thread-safe queue with `timeout` on `put` / `get` and broadcast-on-close. - [`Executor`](#executors) — three implementations, one `submit` contract. +- [`Portal`](#portal) — thread-aware view over `anyio.from_thread.BlockingPortal`. - [`TaskGroup`](#taskgroup) — Trio-style structured concurrency over an `Executor`. - [`run_async`](#run_async) — sync→async bridge: dispatch a coroutine onto the current service's loop from a worker thread. @@ -55,21 +56,27 @@ Same channel / lazy-spawn shape as `WorkerExecutor`, but workers run inside `any The worker's cancel scope spans its whole lifetime (one worker handles many tasks), so cancellation granularity is **per-worker**, not per-task. Use `stop()` for fast cooperative shutdown. ```python -async with anyio.from_thread.BlockingPortal() as portal: +from localpost import Portal + +async with anyio.from_thread.BlockingPortal() as raw_portal: + portal = Portal(raw_portal) async with AsyncWorkerExecutor(portal=portal) as ex: fut = await anyio.to_thread.run_sync(ex.submit, work, x) # … - await anyio.to_thread.run_sync(ex.stop) # cancel everything + ex.stop() # safe from any thread ``` -`submit` and `stop` must be invoked from a non-loop thread (use `await anyio.to_thread.run_sync(…)` from inside async code). +`submit` and `stop` are safe to call from any thread — the wrapping `Portal` does the on-loop / off-loop dispatch internally. ### `AsyncExecutor` — fresh AnyIO task per submit Every `submit` schedules a fresh AnyIO task on the executor's internal task group; concurrency is gated by an `anyio.CapacityLimiter` (`math.inf` = no cap). Cancellation is **per-task**: `Future.cancel()` propagates to the underlying task's `check_cancelled`. ```python -async with anyio.from_thread.BlockingPortal() as portal: +from localpost import Portal + +async with anyio.from_thread.BlockingPortal() as raw_portal: + portal = Portal(raw_portal) async with AsyncExecutor(portal=portal, max_concurrency=4) as ex: fut = await anyio.to_thread.run_sync(ex.submit, work, x) # cancel just this task: @@ -109,11 +116,26 @@ def worker(user_id: int) -> str: return user.name ``` -Resolves the portal via `localpost.hosting.current_service`, so the calling thread must inherit the hosting context (true for any thread spawned through AnyIO or the executors above). Must be called from a non-loop thread; on the loop thread the underlying `BlockingPortal.call` raises `RuntimeError`. +Resolves the portal via `localpost.hosting.current_service`, so the calling thread must inherit the hosting context (true for any thread spawned through AnyIO or the executors above). Must be called from a non-loop thread; raises `RuntimeError` otherwise (would deadlock the loop). + +## Portal + +`Portal` wraps `anyio.from_thread.BlockingPortal` with loop-thread awareness. Construct it on the loop thread (it snapshots `threading.get_ident()` at creation), then pass it to `AsyncWorkerExecutor` / `AsyncExecutor` or any code that needs to schedule onto the loop without caring about the calling thread. + +```python +from localpost import Portal + +portal.same_thread # is the current thread the loop thread? +portal.run_sync(fn, *args) # call sync fn on the loop, return its result +portal.run_async(coro, *args) # await coro on the loop, off-loop only +portal.raw # underlying BlockingPortal (escape hatch) +``` + +`run_sync` does the right thing in either direction: direct call on-loop, `BlockingPortal.call` off-loop. `run_async` raises `RuntimeError` on the loop thread instead of deadlocking. ## Composing with `localpost.hosting` -`localpost.hosting._serve_root` already runs a single `BlockingPortal` for the whole app. It's exposed on the service lifetime view so you can layer an executor on top of it without opening a second portal: +`localpost.hosting._serve_root` already runs a single `Portal` for the whole app. It's exposed on the service lifetime view so you can layer an executor on top of it without opening a second portal: ```python from localpost import hosting diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index 3b96ad8..880f5cd 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,6 +1,6 @@ -import functools from collections.abc import Awaitable, Callable +from localpost._portal import Portal from localpost.hosting import current_service from ._channel import Channel, ReceiveChannel, SendChannel @@ -19,6 +19,7 @@ "AsyncWorkerExecutor", "Channel", "Executor", + "Portal", "ReceiveChannel", "SendChannel", "TaskGroup", @@ -41,9 +42,7 @@ def run_async[**P, R]( :class:`contextvars.Context` must carry the hosting context (true for any thread spawned through AnyIO / the threadtools executors). - Must be called from a non-loop thread; the underlying - :meth:`anyio.from_thread.BlockingPortal.call` raises ``RuntimeError`` if - invoked from the loop thread. + Must be called from a non-loop thread; raises :class:`RuntimeError` + otherwise (would deadlock the loop). """ - portal = current_service().portal - return portal.start_task_soon(functools.partial(func, **kwargs), *args).result() + return current_service().portal.run_async(func, *args, **kwargs) diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index 11377f5..14c5450 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -21,9 +21,9 @@ All three propagate :class:`contextvars.Context` to the task, matching :func:`asyncio.to_thread` / Trio / AnyIO spawn semantics. -The async variants take a caller-owned :class:`anyio.from_thread.BlockingPortal` -and run their internal :class:`anyio.abc.TaskGroup` on the portal's loop. They -expose :meth:`stop` for explicit cooperative cancellation. +The async variants take a caller-owned :class:`localpost.Portal` and run +their internal :class:`anyio.abc.TaskGroup` on its loop. They expose +:meth:`stop` (safe from any thread) for cooperative cancellation. """ from __future__ import annotations @@ -48,7 +48,8 @@ to_thread, ) from anyio.abc import TaskGroup as AioTaskGroup -from anyio.from_thread import BlockingPortal + +from localpost._portal import Portal from ._channel import Channel, ReceiveChannel, SendChannel @@ -262,7 +263,7 @@ class AsyncWorkerExecutor: def __init__( self, *, - portal: BlockingPortal, + portal: Portal, max_concurrency: float = math.inf, backlog: int | None = 0, idle_timeout: float = DEFAULT_IDLE_TIMEOUT, @@ -285,7 +286,7 @@ def __init__( self._closed = False @property - def portal(self) -> BlockingPortal: + def portal(self) -> Portal: return self._portal @property @@ -323,24 +324,20 @@ async def __aexit__( def stop(self) -> None: """Cooperatively shut down: close the channel and cancel the internal - task group. Idempotent. + task group. Safe from any thread. Idempotent. Idle workers wake via the channel close; active tasks see their next :func:`anyio.from_thread.check_cancelled` raise. Tasks that don't poll run to natural completion. - - Like :meth:`submit`, this must be called from a non-loop thread — - from inside ``async`` code, wrap with ``await anyio.to_thread.run_sync(ex.stop)``. """ - if self._tg is None: + if (tg := self._tg) is None: return # The channel uses thread locks; closing is safe from any thread. try: self._tx.close() except ClosedResourceError: pass - tg = self._tg - self._portal.call(tg.cancel_scope.cancel) + self._portal.run_sync(tg.cancel_scope.cancel) def submit[**P, R]( self, @@ -373,7 +370,7 @@ async def host_task() -> None: await to_thread.run_sync(self._run_worker, rx, abandon_on_cancel=False) # Schedule the host task into our internal task group from any thread. - self._portal.call(tg.start_soon, host_task) + self._portal.run_sync(tg.start_soon, host_task) def _run_worker(self, rx: ReceiveChannel[Task]) -> None: try: @@ -426,7 +423,7 @@ class AsyncExecutor: def __init__( self, *, - portal: BlockingPortal, + portal: Portal, max_concurrency: float = math.inf, ) -> None: if max_concurrency <= 0: @@ -440,7 +437,7 @@ def __init__( self._closed = False @property - def portal(self) -> BlockingPortal: + def portal(self) -> Portal: return self._portal async def __aenter__(self) -> Self: @@ -474,10 +471,8 @@ def stop(self) -> None: """Cancel every in-flight submit by cancelling the internal task group. Safe from any thread. Idempotent. """ - if self._tg is None: - return - tg = self._tg - self._portal.call(tg.cancel_scope.cancel) + if (tg := self._tg) is not None: + self._portal.run_sync(tg.cancel_scope.cancel) def submit[**P, R]( self, @@ -505,5 +500,5 @@ async def runner() -> None: else: task.future.set_result(cast("Any", result)) - self._portal.call(tg.start_soon, runner) + self._portal.run_sync(tg.start_soon, runner) return task.future diff --git a/tests/threadtools/async_executor.py b/tests/threadtools/async_executor.py index e7d9530..85ffda2 100644 --- a/tests/threadtools/async_executor.py +++ b/tests/threadtools/async_executor.py @@ -1,8 +1,8 @@ """Tests for the AnyIO-backed executors. Both :class:`AsyncWorkerExecutor` and :class:`AsyncExecutor` are async -context managers and require a caller-owned -:class:`anyio.from_thread.BlockingPortal` running on the test's loop. +context managers and require a caller-owned :class:`localpost.Portal` +running on the test's loop. We pin both backends (``asyncio`` and ``trio``) for the cross-backend ``check_cancelled`` story; ``stop()`` must work the same on each. @@ -19,8 +19,8 @@ import anyio.from_thread import anyio.to_thread import pytest -from anyio.from_thread import BlockingPortal +from localpost import Portal from localpost.threadtools import AsyncExecutor, AsyncWorkerExecutor # Both backends, on every test in this module. @@ -28,15 +28,16 @@ @pytest.fixture -async def portal() -> AsyncIterator[BlockingPortal]: - """A :class:`BlockingPortal` bound to the test's running event loop. +async def portal() -> AsyncIterator[Portal]: + """A :class:`Portal` bound to the test's running event loop. Submits to async executors are sync calls that schedule onto the - portal's loop; they must be invoked from a non-loop thread (the test - body uses ``to_thread.run_sync`` to do that). + portal's loop. With :class:`Portal`, calls are safe from any thread — + on-loop they're direct, off-loop they hop through the underlying + :class:`BlockingPortal`. """ - async with anyio.from_thread.BlockingPortal() as p: - yield p + async with anyio.from_thread.BlockingPortal() as raw: + yield Portal(raw) # --------------------------------------------------------------------------- @@ -44,7 +45,7 @@ async def portal() -> AsyncIterator[BlockingPortal]: # --------------------------------------------------------------------------- -async def test_async_worker_executor_basic_submit(anyio_backend, portal: BlockingPortal): +async def test_async_worker_executor_basic_submit(anyio_backend, portal: Portal): async with AsyncWorkerExecutor(portal=portal) as ex: # ``submit`` and ``Future.result`` must both run off the loop thread. fut: Future[int] = await anyio.to_thread.run_sync(ex.submit, lambda: 42) @@ -52,7 +53,7 @@ async def test_async_worker_executor_basic_submit(anyio_backend, portal: Blockin assert result == 42 -async def test_async_worker_executor_check_cancelled_callable_in_task(anyio_backend, portal: BlockingPortal): +async def test_async_worker_executor_check_cancelled_callable_in_task(anyio_backend, portal: Portal): """``from_thread.check_cancelled`` is callable inside a task running on an :class:`AsyncWorkerExecutor` worker — i.e. AnyIO's threadlocals are wired up. """ @@ -70,7 +71,7 @@ def task() -> str: assert polled.is_set() -async def test_async_worker_executor_stop_propagates_to_running_task(anyio_backend, portal: BlockingPortal): +async def test_async_worker_executor_stop_propagates_to_running_task(anyio_backend, portal: Portal): """:meth:`stop` cancels the internal task group; running tasks polling ``check_cancelled`` raise on their next checkpoint.""" saw_cancel = threading.Event() @@ -103,14 +104,14 @@ def slow_task() -> None: # --------------------------------------------------------------------------- -async def test_async_executor_basic_submit(anyio_backend, portal: BlockingPortal): +async def test_async_executor_basic_submit(anyio_backend, portal: Portal): async with AsyncExecutor(portal=portal) as ex: fut: Future[int] = await anyio.to_thread.run_sync(ex.submit, lambda: 7 * 6) result = await anyio.to_thread.run_sync(fut.result, 5) assert result == 42 -async def test_async_executor_propagates_exception(anyio_backend, portal: BlockingPortal): +async def test_async_executor_propagates_exception(anyio_backend, portal: Portal): class Boom(Exception): pass @@ -127,7 +128,7 @@ def get_result() -> object: await anyio.to_thread.run_sync(get_result) -async def test_async_executor_capacity_limiter(anyio_backend, portal: BlockingPortal): +async def test_async_executor_capacity_limiter(anyio_backend, portal: Portal): """``max_concurrency`` is enforced via :class:`anyio.CapacityLimiter`.""" n_concurrent = 0 max_seen = 0 @@ -149,7 +150,7 @@ def hold() -> None: assert max_seen <= 2 -async def test_async_executor_stop_cancels_all_inflight(anyio_backend, portal: BlockingPortal): +async def test_async_executor_stop_cancels_all_inflight(anyio_backend, portal: Portal): cancelled_count = 0 cancelled_lock = threading.Lock() started = threading.Event() diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py index 2ad48ef..d4a1585 100644 --- a/tests/threadtools/executor.py +++ b/tests/threadtools/executor.py @@ -2,7 +2,7 @@ The Async variants (``AsyncWorkerExecutor`` / ``AsyncExecutor``) live in ``async_executor.py`` since they're async context managers and need a -:class:`anyio.from_thread.BlockingPortal`. +:class:`localpost.Portal`. """ from __future__ import annotations From dd8fb2821c5c06252f17dc6435f4feba6b15f197 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 09:41:04 +0400 Subject: [PATCH 263/286] feat(threadtools): add as_sync_cm helper for worker-thread access to async CMs Counterpart to run_async for context managers: lets a worker thread enter an async CM via the current service's portal, with a same-thread guard to prevent loop deadlocks. Co-Authored-By: Claude Opus 4.7 (1M context) --- localpost/threadtools/__init__.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index 880f5cd..4da3693 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -1,4 +1,5 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator +from contextlib import AbstractAsyncContextManager, contextmanager from localpost._portal import Portal from localpost.hosting import current_service @@ -24,6 +25,7 @@ "SendChannel", "TaskGroup", "WorkerExecutor", + "as_sync_cm", "run_async", ] @@ -46,3 +48,29 @@ def run_async[**P, R]( otherwise (would deadlock the loop). """ return current_service().portal.run_async(func, *args, **kwargs) + + +@contextmanager +def as_sync_cm[T]( + cm: AbstractAsyncContextManager[T], + /, + *, + portal: Portal | None = None, +) -> Generator[T]: + """Sync view over an async context manager. + + Counterpart to :func:`run_async` for context managers: lets a worker + thread enter an async CM via a portal, transparently dispatching + ``__aenter__`` / ``__aexit__`` onto the loop. Resolves the portal via + :func:`localpost.hosting.current_service` when omitted, so the caller's + :class:`contextvars.Context` must carry the hosting context (true for any + thread spawned through AnyIO / the threadtools executors). + + Must be entered from a non-loop thread; raises :class:`RuntimeError` + otherwise (would deadlock the loop). + """ + p = portal if portal is not None else current_service().portal + if p.same_thread: + raise RuntimeError("Deadlock: synchronous wait on the portal's loop thread") + with p.raw.wrap_async_context_manager(cm) as result: + yield result From ba87218ec8c6125eddf6b4aa7c5d06b9fa8ef8df Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 10:26:30 +0400 Subject: [PATCH 264/286] refactor(threadtools)!: drop idle-timeout self-exit; share one receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers in WorkerExecutor / AsyncWorkerExecutor now live for the executor's lifetime — no per-worker `idle_timeout`, no `DEFAULT_IDLE_TIMEOUT` constant. With workers no longer exiting independently, switch to a single shared receiver: drop `_rx_template`, `_open_receivers`, the per-worker `rx.clone()` / `rx.close()`, and the `get_nowait` recheck under lock that resolved the producer-vs-timeout race. The worker loop reduces to `for task in self._rx: task.run()`. Rationale, alternatives, and the cross-language survey behind this choice are captured in ADR-0005. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 +- .../0005-no-idle-timeout-for-worker-pools.md | 147 +++++++++++++ docs/adr/index.md | 1 + localpost/threadtools/README.md | 4 +- localpost/threadtools/__init__.py | 2 - localpost/threadtools/_executor.py | 199 ++++++------------ tests/threadtools/executor.py | 29 ++- 7 files changed, 224 insertions(+), 162 deletions(-) create mode 100644 docs/adr/0005-no-idle-timeout-for-worker-pools.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab5481..41d64f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,8 +76,8 @@ Python 3.12+ is now required (was 3.10+). - `Executor` protocol — single `submit(fn, *args, **kwargs) -> Future` contract. Three implementations: - `WorkerExecutor` — sync `with`, channel-backed pool of plain - `threading.Thread` workers, lazy spawn with idle-timeout - self-exit, no event loop needed. + `threading.Thread` workers, lazy spawn; workers live until the + executor closes (see ADR-0005). No event loop needed. - `AsyncWorkerExecutor` — `async with`, same channel/lazy-spawn shape but workers run via `anyio.to_thread.run_sync(..., abandon_on_cancel=False)` so user code can call diff --git a/docs/adr/0005-no-idle-timeout-for-worker-pools.md b/docs/adr/0005-no-idle-timeout-for-worker-pools.md new file mode 100644 index 0000000..5a8a60f --- /dev/null +++ b/docs/adr/0005-no-idle-timeout-for-worker-pools.md @@ -0,0 +1,147 @@ +# 0005 — No idle-timeout self-exit in `WorkerExecutor` / `AsyncWorkerExecutor` + +- **Status:** Accepted +- **Date:** 2026-05-10 + +## Context + +`localpost.threadtools.WorkerExecutor` and `AsyncWorkerExecutor` are +channel-backed worker pools with lazy spawn (driven by +`put_nowait` / `WouldBlock`) up to `max_concurrency`. The original +design also had **idle-timeout self-exit**: a worker that saw no work +for `idle_timeout` seconds would close its receiver clone and exit, +shrinking the live worker count. + +That feature interacted badly with the rest of the design: + +- Each worker held its own cloned `rx`. The executor kept a long-lived + `_rx_template` open so that, after the last worker exited, the next + `put_nowait` wouldn't raise `ClosedResourceError`. The template was a + load-bearing placeholder, not a real consumer — present in the + channel's `open_receive_channels` count but unable to receive. +- The combination created a race between worker timeout and a producer + whose `put_nowait` failed (rendezvous full, no waiting receiver). With + `_rx_template` keeping the channel "open", the channel could never + signal "no consumers" to the blocking `put` Phase 2 — so an item + buffered by a producer right as the last worker exited would sit in + the buffer forever, and `put` would block forever waiting for + `items_consumed` to advance. +- The fix was a `get_nowait` recheck under the executor's lock in the + worker's `TimeoutError` handler: if a producer slipped a task in + between the worker's wait timeout and its decision to die, the worker + would still take it. This worked, but the lifecycle bookkeeping + (`_open_receivers` list, per-worker `rx.clone()`, defensive + `rx in self._open_receivers` / `rx.close()` on every exit path) was + duplicated across both executors. + +Forces in play when reconsidering: + +- **The race is fundamental to lazy-spawn-with-idle-timeout.** A survey + of channel libraries (Rust `mpsc` / `crossbeam` / `flume` / `tokio`, + Go built-in channels, Java `BlockingQueue`, AnyIO `MemoryObjectStream`) + confirms that no mainstream channel library absorbs the race into the + channel API — channels are dumb queues; pools manage lifecycle. The + one mainstream system with the same shape, Java + `ThreadPoolExecutor`, resolves the race in the pool too (packed + `(runState, workerCount)` atomic + `addWorker(null, false)` recheck + from `execute()`). Tokio's blocking pool resolves it the same way + with a coarse `Mutex`. So the lock-dance is in good company — *if* + we keep the feature. +- **The savings idle scale-down promises are largely illusory for + in-process Python worker pools.** A worker parked in + `condvar.wait` consumes <100 KB RSS (kernel commits only touched + pages of the 8 MB virtual stack), is not scheduled, and costs the + next burst a fresh `pthread_create` (hundreds of µs to a few ms) to + rebuild. The mechanism complexity (the race, the placeholder + receiver, the duplicated handlers) buys back a small amount of RSS + that gets immediately re-spent on the next workload spike. +- **Reference implementations vote with their defaults.** Python's + stdlib `concurrent.futures.ThreadPoolExecutor` has no idle timeout + at all — workers live until `shutdown()`. Java's + `ThreadPoolExecutor` defaults `allowCoreThreadTimeOut` to `false`. + Idle scale-down is a feature people *can* opt into in those + ecosystems, not the baseline behavior. +- **Where idle scale-down genuinely matters** (per-thread DB + connections, GPU contexts, large per-thread caches), none of those + apply to `WorkerExecutor`'s contract — workers just run callables + with a propagated `contextvars.Context`. Per-task expensive + resources belong inside the task, not pinned to a long-lived worker. + +## Decision + +Workers in `WorkerExecutor` and `AsyncWorkerExecutor` live for the +**executor's lifetime**. There is no idle-timeout self-exit, and no +`idle_timeout=` parameter on either constructor. The +`DEFAULT_IDLE_TIMEOUT` module constant is removed. + +Two structural simplifications follow naturally and are also part of +this decision: + +1. **One shared receiver** instead of per-worker `rx.clone()`. Workers + all pull from the executor's single `self._rx`; the channel's state + lock already serializes concurrent `get` calls correctly. This + matches Tokio's blocking pool design. +2. **No `_rx_template` placeholder.** With workers staying alive until + close, there is no inter-worker generation gap to bridge. The + executor holds `tx` and `rx` directly for its whole lifetime. + +The worker loop reduces to: + +```python +def _run_worker(self) -> None: + for task in self._rx: + task.run() +``` + +Submit keeps its existing two-phase shape (`put_nowait` → spawn → +blocking `put`), but the cap check uses `len(self._workers)` / +`self._worker_count` and the spawn path no longer manages cloned +receivers. + +Alternatives considered: + +- **Keep idle timeout, keep the lock dance.** Correct, in good company + with Java / Tokio. Rejected because the savings don't justify the + ongoing cost — the duplicated race-handling sits across two classes + and is the easiest part of the file to break in a refactor without + noticing. +- **Push the race resolution into the channel API** (e.g. a + `BrokenChannel` signal when the last receiver dies, paired with a + receiver factory that the producer can use to spawn a new consumer + on retry). Rejected after the cross-language survey: no mainstream + channel library does this, because channels and pools are different + layers and conflating them leaks pool concerns into the channel. + We'd be inventing a new abstraction with no peers. + +## Consequences + +- `WorkerExecutor(...)` and `AsyncWorkerExecutor(...)` no longer + accept `idle_timeout=`. This is a **breaking API change**; given + v-next is pre-1.0 and the parameter never had a non-default use in + this repo, no deprecation path is provided. +- `localpost.threadtools.DEFAULT_IDLE_TIMEOUT` is removed from the + module's public surface. +- `WorkerExecutor.open_receivers` and `AsyncWorkerExecutor.open_receivers` + are replaced by `WorkerExecutor.workers` (the `Thread` list) and + `AsyncWorkerExecutor.worker_count` (an int). These were primarily + used by tests; downstream code that introspected them needs to + switch. +- The `_run_worker` method on both executors collapses to a 2-line + `for task in self._rx: task.run()` loop. The `_open_receivers` list, + the per-worker `rx.clone()` / `rx.close()` bookkeeping, and the + `_rx_template` placeholder are gone. +- The producer-vs-worker-timeout race no longer exists, so the + `get_nowait` recheck under `self._lock` in the worker's + `TimeoutError` handler is gone too. The submit-side lock still + guards the cap check + spawn (cap accounting is the executor's + concern; the channel doesn't track that). +- Once at the high-water mark, the pool keeps that many threads alive + until `__exit__`. For a long-running hosted service this is the + intended behavior — the pool sizes itself to peak concurrency and + stays there. Callers that need bursty work without a sticky pool + should use `AsyncExecutor` (per-task spawn gated by a + `CapacityLimiter`), which already exists for exactly this case. +- Memory expectation in production: ~100 KB RSS per parked worker. + At a `max_concurrency` of, say, 64, that's ~6 MB held — noise next + to the Python interpreter and cheaper than re-paying + `pthread_create` on every burst. diff --git a/docs/adr/index.md b/docs/adr/index.md index 22c4dc8..35b0f60 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -49,3 +49,4 @@ title in the filename: `0001-anyio-as-async-runtime.md`. | 0002 | [h11 and httptools coexist](0002-h11-httptools-coexist.md) | Accepted | | 0003 | [Sync-only native HTTP server](0003-sync-native-http-server.md) | Accepted | | 0004 | [Pull-based client-disconnect detection](0004-pull-based-disconnect-detection.md) | Accepted | +| 0005 | [No idle-timeout self-exit in worker pools](0005-no-idle-timeout-for-worker-pools.md) | Accepted | diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md index f19393f..db53f13 100644 --- a/localpost/threadtools/README.md +++ b/localpost/threadtools/README.md @@ -37,7 +37,7 @@ All three propagate `contextvars.Context` to the task, matching `asyncio.to_thre ### `WorkerExecutor` — sync, channel-backed -Plain `threading.Thread` workers. Lazy spawn, idle-timeout self-exit. No event loop. +Plain `threading.Thread` workers. Lazy spawn; workers live until the executor closes (no idle-timeout self-exit — see [ADR-0005](../../docs/adr/0005-no-idle-timeout-for-worker-pools.md)). No event loop. ```python from localpost.threadtools import WorkerExecutor @@ -47,7 +47,7 @@ with WorkerExecutor(max_concurrency=8) as ex: result = fut.result() ``` -`submit` tries `put_nowait` first; on `WouldBlock` it spawns a new worker (up to `max_concurrency`) and falls through to a blocking `put` (which is what backpressures the caller when at the cap). +`submit` tries `put_nowait` first; on `WouldBlock` it spawns a new worker (up to `max_concurrency`) and falls through to a blocking `put` (which is what backpressures the caller when at the cap). All workers pull from a single shared receiver. ### `AsyncWorkerExecutor` — channel + AnyIO threadlocals diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index 4da3693..5d76d1e 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -6,7 +6,6 @@ from ._channel import Channel, ReceiveChannel, SendChannel from ._executor import ( - DEFAULT_IDLE_TIMEOUT, AsyncExecutor, AsyncWorkerExecutor, Executor, @@ -15,7 +14,6 @@ from ._task_group import TaskGroup __all__ = [ - "DEFAULT_IDLE_TIMEOUT", "AsyncExecutor", "AsyncWorkerExecutor", "Channel", diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index 14c5450..884bee9 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -5,8 +5,8 @@ * :class:`WorkerExecutor` — sync context manager. Channel-backed pool of plain ``threading.Thread`` workers. Lazy spawn (driven by - ``put_nowait`` / ``WouldBlock``), idle-timeout self-exit. Standalone — - no AnyIO loop required. + ``put_nowait`` / ``WouldBlock``); workers live until the executor + closes. Standalone — no AnyIO loop required. * :class:`AsyncWorkerExecutor` — async context manager. Same channel / lazy-spawn shape, but workers run inside ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` so user code @@ -24,6 +24,10 @@ The async variants take a caller-owned :class:`localpost.Portal` and run their internal :class:`anyio.abc.TaskGroup` on its loop. They expose :meth:`stop` (safe from any thread) for cooperative cancellation. + +There is no idle-timeout self-exit for workers in either pool variant — +once spawned, a worker lives until the executor closes. See +``docs/adr/0005-no-idle-timeout-for-worker-pools.md`` for the rationale. """ from __future__ import annotations @@ -34,7 +38,6 @@ import threading from collections.abc import Callable from concurrent.futures import Future -from contextlib import AsyncExitStack from dataclasses import dataclass, field from types import TracebackType from typing import Any, Protocol, Self, cast, final, runtime_checkable @@ -42,20 +45,15 @@ from anyio import ( CapacityLimiter, ClosedResourceError, - EndOfStream, WouldBlock, create_task_group, to_thread, ) -from anyio.abc import TaskGroup as AioTaskGroup from localpost._portal import Portal from ._channel import Channel, ReceiveChannel, SendChannel -DEFAULT_IDLE_TIMEOUT: float = 60.0 -"""Seconds an idle worker waits for new work before self-exiting.""" - @runtime_checkable class Executor(Protocol): @@ -112,9 +110,9 @@ class WorkerExecutor: worker (up to ``max_concurrency``) and falls through to a blocking ``put`` (which is what backpressures the caller when at the cap). - Workers self-exit after ``idle_timeout`` seconds without work; on exit - they remove their cloned receiver from ``open_receivers`` under the - executor's lock so submission and worker bookkeeping stay consistent. + Workers all pull from a single shared receiver. Once spawned, a worker + lives until the executor closes — no idle-timeout self-exit. See + ``docs/adr/0005-no-idle-timeout-for-worker-pools.md``. """ def __init__( @@ -122,33 +120,23 @@ def __init__( *, max_concurrency: float = math.inf, backlog: int | None = 0, - idle_timeout: float = DEFAULT_IDLE_TIMEOUT, thread_name_prefix: str = "lp-worker", ) -> None: if max_concurrency <= 0: raise ValueError("max_concurrency must be > 0") - if idle_timeout <= 0: - raise ValueError("idle_timeout must be > 0") self._max_concurrency = max_concurrency - self._idle_timeout = idle_timeout self._thread_name_prefix = thread_name_prefix - # ``_rx_template`` is kept open for the lifetime of the executor so - # ``open_receive_channels`` never drops to zero between worker spawns - # — that would make the next ``put_nowait`` raise ClosedResourceError. - # Workers always run on a clone they own. self._tx: SendChannel[Task] - self._rx_template: ReceiveChannel[Task] - self._tx, self._rx_template = Channel.create(capacity=backlog) + self._rx: ReceiveChannel[Task] + self._tx, self._rx = Channel.create(capacity=backlog) self._lock = threading.Lock() - self._open_receivers: list[ReceiveChannel[Task]] = [] self._workers: list[threading.Thread] = [] self._opened = False self._closed = False - self._next_worker_id = 0 @property - def open_receivers(self) -> list[ReceiveChannel[Task]]: - return self._open_receivers + def workers(self) -> list[threading.Thread]: + return self._workers def __enter__(self) -> Self: if self._closed: @@ -162,16 +150,17 @@ def __exit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: + del exc_type, exc, tb with self._lock: if self._closed: return self._closed = True - # Closing the sender lets in-flight workers drain whatever is buffered - # and then observe EndOfStream on their next ``get``. + # Closing tx makes idle workers see EndOfStream; busy workers finish + # their current task and then see EndOfStream on the next get. self._tx.close() for t in list(self._workers): t.join() - self._rx_template.close() + self._rx.close() def submit[**P, R]( self, @@ -189,53 +178,24 @@ def submit[**P, R]( with self._lock: if self._closed: raise RuntimeError("WorkerExecutor is closed") from None - if len(self._open_receivers) < self._max_concurrency: - rx = self._rx_template.clone() - self._open_receivers.append(rx) - self._spawn_worker(rx) + if len(self._workers) < self._max_concurrency: + self._spawn_worker() self._tx.put(task) # blocks once we're at the cap return task.future - def _spawn_worker(self, rx: ReceiveChannel[Task]) -> None: - wid = self._next_worker_id - self._next_worker_id += 1 + def _spawn_worker(self) -> None: + wid = len(self._workers) t = threading.Thread( target=self._run_worker, - args=(rx,), name=f"{self._thread_name_prefix}-{wid}", daemon=True, ) self._workers.append(t) t.start() - def _run_worker(self, rx: ReceiveChannel[Task]) -> None: - try: - while True: - try: - task = rx.get(timeout=self._idle_timeout) - except TimeoutError: - # Idle. Close the timeout-vs-submit race under the lock: - # if a submitter slipped a task into the channel between - # ``get`` returning and us deciding to die, take it. - with self._lock: - try: - task = rx.get_nowait() - except (WouldBlock, EndOfStream, ClosedResourceError): - if rx in self._open_receivers: - self._open_receivers.remove(rx) - try: - rx.close() - except ClosedResourceError: - pass - return - except (EndOfStream, ClosedResourceError): - return - task.run() - finally: - try: - rx.close() - except ClosedResourceError: - pass + def _run_worker(self) -> None: + for task in self._rx: + task.run() # -------------------------------------------------------------------------- @@ -266,22 +226,20 @@ def __init__( portal: Portal, max_concurrency: float = math.inf, backlog: int | None = 0, - idle_timeout: float = DEFAULT_IDLE_TIMEOUT, ) -> None: if max_concurrency <= 0: raise ValueError("max_concurrency must be > 0") - if idle_timeout <= 0: - raise ValueError("idle_timeout must be > 0") self._portal = portal self._max_concurrency = max_concurrency - self._idle_timeout = idle_timeout self._tx: SendChannel[Task] - self._rx_template: ReceiveChannel[Task] - self._tx, self._rx_template = Channel.create(capacity=backlog) + self._rx: ReceiveChannel[Task] + self._tx, self._rx = Channel.create(capacity=backlog) self._lock = threading.Lock() - self._open_receivers: list[ReceiveChannel[Task]] = [] - self._stack: AsyncExitStack | None = None - self._tg: AioTaskGroup | None = None + self._worker_count = 0 + # Constructed eagerly: callers always instantiate inside ``async with`` on the + # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so + # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. + self._tg = create_task_group() self._opened = False self._closed = False @@ -290,15 +248,13 @@ def portal(self) -> Portal: return self._portal @property - def open_receivers(self) -> list[ReceiveChannel[Task]]: - return self._open_receivers + def worker_count(self) -> int: + return self._worker_count async def __aenter__(self) -> Self: if self._closed: raise RuntimeError("AsyncWorkerExecutor cannot be reused") - self._stack = AsyncExitStack() - await self._stack.__aenter__() - self._tg = await self._stack.enter_async_context(create_task_group()) + await self._tg.__aenter__() self._opened = True return self @@ -316,11 +272,8 @@ async def __aexit__( self._tx.close() # Exit the task group: on clean body it waits for host tasks to finish; # on exception AnyIO cancels children automatically. - if self._stack is not None: - await self._stack.__aexit__(exc_type, exc, tb) - self._stack = None - self._rx_template.close() - self._tg = None + await self._tg.__aexit__(exc_type, exc, tb) + self._rx.close() def stop(self) -> None: """Cooperatively shut down: close the channel and cancel the internal @@ -330,14 +283,14 @@ def stop(self) -> None: :func:`anyio.from_thread.check_cancelled` raise. Tasks that don't poll run to natural completion. """ - if (tg := self._tg) is None: + if not self._opened or self._closed: return # The channel uses thread locks; closing is safe from any thread. try: self._tx.close() except ClosedResourceError: pass - self._portal.run_sync(tg.cancel_scope.cancel) + self._portal.run_sync(self._tg.cancel_scope.cancel) def submit[**P, R]( self, @@ -355,48 +308,22 @@ def submit[**P, R]( with self._lock: if self._closed: raise RuntimeError("AsyncWorkerExecutor is closed") from None - if len(self._open_receivers) < self._max_concurrency: - rx = self._rx_template.clone() - self._open_receivers.append(rx) - self._spawn_worker(rx) + if self._worker_count < self._max_concurrency: + self._spawn_worker() self._tx.put(task) return task.future - def _spawn_worker(self, rx: ReceiveChannel[Task]) -> None: - tg = self._tg - assert tg is not None - + def _spawn_worker(self) -> None: async def host_task() -> None: - await to_thread.run_sync(self._run_worker, rx, abandon_on_cancel=False) + await to_thread.run_sync(self._run_worker, abandon_on_cancel=False) # Schedule the host task into our internal task group from any thread. - self._portal.run_sync(tg.start_soon, host_task) + self._portal.run_sync(self._tg.start_soon, host_task) + self._worker_count += 1 - def _run_worker(self, rx: ReceiveChannel[Task]) -> None: - try: - while True: - try: - task = rx.get(timeout=self._idle_timeout) - except TimeoutError: - with self._lock: - try: - task = rx.get_nowait() - except (WouldBlock, EndOfStream, ClosedResourceError): - if rx in self._open_receivers: - self._open_receivers.remove(rx) - try: - rx.close() - except ClosedResourceError: - pass - return - except (EndOfStream, ClosedResourceError): - return - task.run() - finally: - try: - rx.close() - except ClosedResourceError: - pass + def _run_worker(self) -> None: + for task in self._rx: + task.run() # -------------------------------------------------------------------------- @@ -429,10 +356,11 @@ def __init__( if max_concurrency <= 0: raise ValueError("max_concurrency must be > 0") self._portal = portal - self._max_concurrency = max_concurrency - self._stack: AsyncExitStack | None = None - self._tg: AioTaskGroup | None = None - self._limiter: CapacityLimiter | None = None + # Constructed eagerly: callers always instantiate inside ``async with`` on the + # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so + # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. + self._tg = create_task_group() + self._limiter = CapacityLimiter(max_concurrency) self._opened = False self._closed = False @@ -443,10 +371,7 @@ def portal(self) -> Portal: async def __aenter__(self) -> Self: if self._closed: raise RuntimeError("AsyncExecutor cannot be reused") - self._stack = AsyncExitStack() - await self._stack.__aenter__() - self._tg = await self._stack.enter_async_context(create_task_group()) - self._limiter = CapacityLimiter(self._max_concurrency) + await self._tg.__aenter__() self._opened = True return self @@ -461,18 +386,15 @@ async def __aexit__( self._closed = True # Exit the internal task group: clean body waits for in-flight submits; # exception body cancels them. No manual tracking needed. - if self._stack is not None: - await self._stack.__aexit__(exc_type, exc, tb) - self._stack = None - self._tg = None - self._limiter = None + await self._tg.__aexit__(exc_type, exc, tb) def stop(self) -> None: """Cancel every in-flight submit by cancelling the internal task group. Safe from any thread. Idempotent. """ - if (tg := self._tg) is not None: - self._portal.run_sync(tg.cancel_scope.cancel) + if not self._opened or self._closed: + return + self._portal.run_sync(self._tg.cancel_scope.cancel) def submit[**P, R]( self, @@ -483,11 +405,8 @@ def submit[**P, R]( ) -> Future[R]: if not self._opened or self._closed: raise RuntimeError("AsyncExecutor is not open") - tg = self._tg - limiter = self._limiter - assert tg is not None - assert limiter is not None task = Task(fn, args, kwargs) + limiter = self._limiter async def runner() -> None: if not task.future.set_running_or_notify_cancel(): @@ -500,5 +419,5 @@ async def runner() -> None: else: task.future.set_result(cast("Any", result)) - self._portal.run_sync(tg.start_soon, runner) + self._portal.run_sync(self._tg.start_soon, runner) return task.future diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py index d4a1585..bc09a39 100644 --- a/tests/threadtools/executor.py +++ b/tests/threadtools/executor.py @@ -15,14 +15,6 @@ import pytest from localpost.threadtools import WorkerExecutor -from localpost.threadtools import _executor as _ex - - -@pytest.fixture -def fast_idle_timeout(monkeypatch: pytest.MonkeyPatch) -> float: - """Make idle-timeout tests fast: 100 ms instead of 60 s.""" - monkeypatch.setattr(_ex, "DEFAULT_IDLE_TIMEOUT", 0.1) - return 0.1 # --------------------------------------------------------------------------- @@ -107,15 +99,20 @@ def hold(): assert len(seen) == n -def test_idle_workers_self_exit_on_timeout(fast_idle_timeout: float): - """A worker that sees no work for ``idle_timeout`` should retire.""" - with WorkerExecutor(idle_timeout=fast_idle_timeout) as ex: - ex.submit(lambda: None).result(timeout=5) - time.sleep(fast_idle_timeout * 5) - # Workers self-exit and remove themselves from open_receivers. - assert ex.open_receivers == [] - # Submitting again still works — a fresh worker spawns. +def test_workers_persist_until_close(): + """Workers live for the executor's lifetime; once spawned they don't self-exit.""" + with WorkerExecutor(max_concurrency=2) as ex: ex.submit(lambda: None).result(timeout=5) + spawned = list(ex.workers) + assert len(spawned) >= 1 + # Idle stretch — workers must still be alive (no idle-timeout self-exit). + time.sleep(0.2) + assert ex.workers == spawned + assert all(t.is_alive() for t in spawned) + # Subsequent submits reuse the same workers, never exceed the cap. + for _ in range(20): + ex.submit(lambda: None).result(timeout=5) + assert len(ex.workers) <= 2 def test_submit_after_close_raises(): From 49dc1ef04b56664843ff51b51bdb70203f0294c3 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 15:13:13 +0000 Subject: [PATCH 265/286] simply executor --- localpost/threadtools/_executor.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index 884bee9..8458f95 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -360,7 +360,8 @@ def __init__( # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. self._tg = create_task_group() - self._limiter = CapacityLimiter(max_concurrency) + self._limiter = limiter = CapacityLimiter(max_concurrency) + self._to_thread = functools.partial(to_thread.run_sync, limiter=limiter) self._opened = False self._closed = False @@ -406,18 +407,5 @@ def submit[**P, R]( if not self._opened or self._closed: raise RuntimeError("AsyncExecutor is not open") task = Task(fn, args, kwargs) - limiter = self._limiter - - async def runner() -> None: - if not task.future.set_running_or_notify_cancel(): - return - bound = functools.partial(task.context.run, task.fn, *task.args, **task.kwargs) - try: - result = await to_thread.run_sync(bound, limiter=limiter, abandon_on_cancel=False) - except BaseException as exc: # noqa: BLE001 - task.future.set_exception(exc) - else: - task.future.set_result(cast("Any", result)) - - self._portal.run_sync(self._tg.start_soon, runner) + self._portal.run_sync(self._tg.start_soon, self._to_thread, task.run) return task.future From 9c15475b7d1f881ad9a5cc1c1323353039e3baa0 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 19:42:16 +0400 Subject: [PATCH 266/286] refactor(threadtools)!: deque + Condition pool; drop AsyncExecutor and concurrency caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker pools no longer manage their own concurrency cap or backlog — callers control concurrency upstream of submit (Cloud Run-like gate, consumer-level Semaphore, etc.). With the cap gone, the channel-backed storage layer collapses to a plain deque + threading.Condition: one lock per submit instead of put_nowait → WouldBlock → put, and no dependence on Channel's capacity / clones / broadcast machinery. AsyncExecutor (per-task spawn gated by CapacityLimiter) is removed; it had no in-tree callers and per-task Future.cancel() propagation isn't needed when concurrency is controlled at the call site. ADR-0005 updated to reflect the deque-based shape and the broader "caller owns concurrency" stance. --- .../0005-no-idle-timeout-for-worker-pools.md | 93 +++--- localpost/threadtools/README.md | 40 +-- localpost/threadtools/__init__.py | 2 - localpost/threadtools/_executor.py | 290 ++++++------------ localpost/threadtools/_task_group.py | 6 +- tests/threadtools/async_executor.py | 92 +----- tests/threadtools/executor.py | 60 ++-- 7 files changed, 172 insertions(+), 411 deletions(-) diff --git a/docs/adr/0005-no-idle-timeout-for-worker-pools.md b/docs/adr/0005-no-idle-timeout-for-worker-pools.md index 5a8a60f..e7d832f 100644 --- a/docs/adr/0005-no-idle-timeout-for-worker-pools.md +++ b/docs/adr/0005-no-idle-timeout-for-worker-pools.md @@ -5,12 +5,11 @@ ## Context -`localpost.threadtools.WorkerExecutor` and `AsyncWorkerExecutor` are -channel-backed worker pools with lazy spawn (driven by -`put_nowait` / `WouldBlock`) up to `max_concurrency`. The original -design also had **idle-timeout self-exit**: a worker that saw no work -for `idle_timeout` seconds would close its receiver clone and exit, -shrinking the live worker count. +`localpost.threadtools.WorkerExecutor` and `AsyncWorkerExecutor` were +originally channel-backed worker pools with lazy spawn (driven by +`put_nowait` / `WouldBlock`) up to `max_concurrency`, and **idle-timeout +self-exit**: a worker that saw no work for `idle_timeout` seconds would +close its receiver clone and exit, shrinking the live worker count. That feature interacted badly with the rest of the design: @@ -74,36 +73,36 @@ Workers in `WorkerExecutor` and `AsyncWorkerExecutor` live for the `idle_timeout=` parameter on either constructor. The `DEFAULT_IDLE_TIMEOUT` module constant is removed. -Two structural simplifications follow naturally and are also part of -this decision: - -1. **One shared receiver** instead of per-worker `rx.clone()`. Workers - all pull from the executor's single `self._rx`; the channel's state - lock already serializes concurrent `get` calls correctly. This - matches Tokio's blocking pool design. -2. **No `_rx_template` placeholder.** With workers staying alive until - close, there is no inter-worker generation gap to bridge. The - executor holds `tx` and `rx` directly for its whole lifetime. - -The worker loop reduces to: +With the lifetime guarantee in place, the storage layer was further +simplified from a `Channel` (capacity / clones / broadcast-on-close +machinery, all unused once `max_concurrency` and `backlog` were also +dropped — see follow-on entry below) to a plain +`collections.deque` + `threading.Condition`. The worker loop becomes: ```python def _run_worker(self) -> None: - for task in self._rx: + while True: + with self._cond: + self._idle += 1 + while not self._queue and not self._closed: + self._cond.wait() + self._idle -= 1 + if not self._queue: + return # closed and drained + task = self._queue.popleft() task.run() ``` -Submit keeps its existing two-phase shape (`put_nowait` → spawn → -blocking `put`), but the cap check uses `len(self._workers)` / -`self._worker_count` and the spawn path no longer manages cloned -receivers. +`submit` enqueues under the same condition; if `self._idle == 0` it +spawns a new worker before notifying. There is no cap and no backlog — +concurrency control is the caller's concern. Alternatives considered: - **Keep idle timeout, keep the lock dance.** Correct, in good company with Java / Tokio. Rejected because the savings don't justify the - ongoing cost — the duplicated race-handling sits across two classes - and is the easiest part of the file to break in a refactor without + ongoing cost — the duplicated race-handling sat across two classes + and was the easiest part of the file to break in a refactor without noticing. - **Push the race resolution into the channel API** (e.g. a `BrokenChannel` signal when the last receiver dies, paired with a @@ -116,32 +115,32 @@ Alternatives considered: ## Consequences - `WorkerExecutor(...)` and `AsyncWorkerExecutor(...)` no longer - accept `idle_timeout=`. This is a **breaking API change**; given - v-next is pre-1.0 and the parameter never had a non-default use in - this repo, no deprecation path is provided. + accept `idle_timeout=`, `max_concurrency=`, or `backlog=`. This is a + **breaking API change**; given v-next is pre-1.0 and these + parameters had no non-default use in this repo, no deprecation path + is provided. Callers that need a cap should apply a + `threading.Semaphore` / `anyio.CapacityLimiter` upstream of + `submit`. +- `AsyncExecutor` (per-task spawn gated by `CapacityLimiter`) is + removed — it had no in-tree callers, and "control concurrency at the + call site" makes per-task `Future.cancel()` propagation unnecessary. - `localpost.threadtools.DEFAULT_IDLE_TIMEOUT` is removed from the module's public surface. -- `WorkerExecutor.open_receivers` and `AsyncWorkerExecutor.open_receivers` - are replaced by `WorkerExecutor.workers` (the `Thread` list) and - `AsyncWorkerExecutor.worker_count` (an int). These were primarily - used by tests; downstream code that introspected them needs to - switch. -- The `_run_worker` method on both executors collapses to a 2-line - `for task in self._rx: task.run()` loop. The `_open_receivers` list, - the per-worker `rx.clone()` / `rx.close()` bookkeeping, and the - `_rx_template` placeholder are gone. -- The producer-vs-worker-timeout race no longer exists, so the - `get_nowait` recheck under `self._lock` in the worker's - `TimeoutError` handler is gone too. The submit-side lock still - guards the cap check + spawn (cap accounting is the executor's - concern; the channel doesn't track that). +- The executors no longer depend on `Channel`. `Channel` itself stays + in the public API for users who want a thread-safe queue with + capacity / timeouts / broadcast-on-close. +- `WorkerExecutor.workers` (the `Thread` list) and + `AsyncWorkerExecutor.worker_count` (an int) replace the prior + `open_receivers` introspection. These were primarily used by tests; + downstream code that introspected them needs to switch. +- The producer-vs-worker-timeout race is gone with the idle timeout. + The submit-side lock (now the condition's lock) guards enqueue + + idle-count check + spawn under one critical section. - Once at the high-water mark, the pool keeps that many threads alive until `__exit__`. For a long-running hosted service this is the intended behavior — the pool sizes itself to peak concurrency and - stays there. Callers that need bursty work without a sticky pool - should use `AsyncExecutor` (per-task spawn gated by a - `CapacityLimiter`), which already exists for exactly this case. + stays there. - Memory expectation in production: ~100 KB RSS per parked worker. - At a `max_concurrency` of, say, 64, that's ~6 MB held — noise next - to the Python interpreter and cheaper than re-paying + At a peak of, say, 64 concurrent submissions that's ~6 MB held — + noise next to the Python interpreter and cheaper than re-paying `pthread_create` on every burst. diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md index db53f13..f95d994 100644 --- a/localpost/threadtools/README.md +++ b/localpost/threadtools/README.md @@ -3,12 +3,12 @@ Thread-friendly building blocks for moving work off the event loop: - [`Channel`](#channel) — a typed, thread-safe queue with `timeout` on `put` / `get` and broadcast-on-close. -- [`Executor`](#executors) — three implementations, one `submit` contract. +- [`Executor`](#executors) — two implementations, one `submit` contract. - [`Portal`](#portal) — thread-aware view over `anyio.from_thread.BlockingPortal`. - [`TaskGroup`](#taskgroup) — Trio-style structured concurrency over an `Executor`. - [`run_async`](#run_async) — sync→async bridge: dispatch a coroutine onto the current service's loop from a worker thread. -`localpost.threadtools` is built on plain locks; the AnyIO loop is needed only for the `Async…Executor` variants. +`localpost.threadtools` is built on plain locks; the AnyIO loop is needed only for the `AsyncWorkerExecutor`. ## Channel @@ -25,33 +25,30 @@ got = rx.get_nowait() # raises WouldBlock / EndOfStream ## Executors -Three implementations share the same `submit(fn, *args, **kwargs) -> Future` contract. Lifecycle and cancellation differ: +Two implementations share the same `submit(fn, *args, **kwargs) -> Future` contract. Both are spawn-on-demand pools with no cap and no backlog — concurrency is the caller's concern (a Cloud Run-like upstream gate, a consumer-level `Semaphore`, etc.). Lifecycle and cancellation differ: | Executor | CM | Cancellation | Loop required | |-------------------------|--------------|-------------------------------------|---------------| | `WorkerExecutor` | `with` | None — workers finish naturally | No | | `AsyncWorkerExecutor` | `async with` | Per-worker (`stop()` / scope cancel)| Yes | -| `AsyncExecutor` | `async with` | Per-task (`Future.cancel()`) | Yes | -All three propagate `contextvars.Context` to the task, matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. +Both propagate `contextvars.Context` to the task, matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. -### `WorkerExecutor` — sync, channel-backed +### `WorkerExecutor` — sync, deque + Condition -Plain `threading.Thread` workers. Lazy spawn; workers live until the executor closes (no idle-timeout self-exit — see [ADR-0005](../../docs/adr/0005-no-idle-timeout-for-worker-pools.md)). No event loop. +Plain `threading.Thread` workers. Lazy spawn: `submit` enqueues onto a shared `deque` under a `threading.Condition`; if no worker is idle, a new one is spawned. Workers live until the executor closes (no idle-timeout self-exit — see [ADR-0005](../../docs/adr/0005-no-idle-timeout-for-worker-pools.md)). No event loop. ```python from localpost.threadtools import WorkerExecutor -with WorkerExecutor(max_concurrency=8) as ex: +with WorkerExecutor() as ex: fut = ex.submit(work, x) result = fut.result() ``` -`submit` tries `put_nowait` first; on `WouldBlock` it spawns a new worker (up to `max_concurrency`) and falls through to a blocking `put` (which is what backpressures the caller when at the cap). All workers pull from a single shared receiver. - -### `AsyncWorkerExecutor` — channel + AnyIO threadlocals +### `AsyncWorkerExecutor` — deque + AnyIO threadlocals -Same channel / lazy-spawn shape as `WorkerExecutor`, but workers run inside `anyio.to_thread.run_sync(..., abandon_on_cancel=False)` so user code can call `anyio.from_thread.check_cancelled`. +Same shape as `WorkerExecutor`, but workers run inside `anyio.to_thread.run_sync(..., abandon_on_cancel=False)` so user code can call `anyio.from_thread.check_cancelled`. The worker's cancel scope spans its whole lifetime (one worker handles many tasks), so cancellation granularity is **per-worker**, not per-task. Use `stop()` for fast cooperative shutdown. @@ -68,21 +65,6 @@ async with anyio.from_thread.BlockingPortal() as raw_portal: `submit` and `stop` are safe to call from any thread — the wrapping `Portal` does the on-loop / off-loop dispatch internally. -### `AsyncExecutor` — fresh AnyIO task per submit - -Every `submit` schedules a fresh AnyIO task on the executor's internal task group; concurrency is gated by an `anyio.CapacityLimiter` (`math.inf` = no cap). Cancellation is **per-task**: `Future.cancel()` propagates to the underlying task's `check_cancelled`. - -```python -from localpost import Portal - -async with anyio.from_thread.BlockingPortal() as raw_portal: - portal = Portal(raw_portal) - async with AsyncExecutor(portal=portal, max_concurrency=4) as ex: - fut = await anyio.to_thread.run_sync(ex.submit, work, x) - # cancel just this task: - fut.cancel() -``` - ## TaskGroup A thin sync bookkeeping layer over an `Executor`. Tracks `Future`s; on `__exit__` waits for every submitted task and re-raises failures as a `BaseExceptionGroup` (Trio `strict_exception_groups=True` semantics — body and task exceptions are merged into one group). @@ -97,7 +79,7 @@ with WorkerExecutor() as ex: # On exit: drain in-flight tasks; raise BaseExceptionGroup if any failed. ``` -`TaskGroup` is **collect-and-raise only** — running tasks are not interrupted on the first failure. If you want cooperative cancel, give it an `AsyncWorkerExecutor` / `AsyncExecutor` so tasks can poll `check_cancelled`. +`TaskGroup` is **collect-and-raise only** — running tasks are not interrupted on the first failure. If you want cooperative cancel, give it an `AsyncWorkerExecutor` so tasks can poll `check_cancelled`. ## `run_async` @@ -120,7 +102,7 @@ Resolves the portal via `localpost.hosting.current_service`, so the calling thre ## Portal -`Portal` wraps `anyio.from_thread.BlockingPortal` with loop-thread awareness. Construct it on the loop thread (it snapshots `threading.get_ident()` at creation), then pass it to `AsyncWorkerExecutor` / `AsyncExecutor` or any code that needs to schedule onto the loop without caring about the calling thread. +`Portal` wraps `anyio.from_thread.BlockingPortal` with loop-thread awareness. Construct it on the loop thread (it snapshots `threading.get_ident()` at creation), then pass it to `AsyncWorkerExecutor` or any code that needs to schedule onto the loop without caring about the calling thread. ```python from localpost import Portal diff --git a/localpost/threadtools/__init__.py b/localpost/threadtools/__init__.py index 5d76d1e..deef7d1 100644 --- a/localpost/threadtools/__init__.py +++ b/localpost/threadtools/__init__.py @@ -6,7 +6,6 @@ from ._channel import Channel, ReceiveChannel, SendChannel from ._executor import ( - AsyncExecutor, AsyncWorkerExecutor, Executor, WorkerExecutor, @@ -14,7 +13,6 @@ from ._task_group import TaskGroup __all__ = [ - "AsyncExecutor", "AsyncWorkerExecutor", "Channel", "Executor", diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index 8458f95..ecc4890 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -1,59 +1,47 @@ """Thread-management primitives for ``localpost.threadtools``. -Three flavors of executor, each with the same ``submit`` contract but +Two flavors of executor, each with the same ``submit`` contract but different lifecycle and cancellation properties: -* :class:`WorkerExecutor` — sync context manager. Channel-backed pool of - plain ``threading.Thread`` workers. Lazy spawn (driven by - ``put_nowait`` / ``WouldBlock``); workers live until the executor - closes. Standalone — no AnyIO loop required. -* :class:`AsyncWorkerExecutor` — async context manager. Same - channel / lazy-spawn shape, but workers run inside - ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` so user code - can call :func:`anyio.from_thread.check_cancelled`. The cancel scope of - each worker spans its whole lifetime (many tasks reuse the same worker), - so cancellation granularity is *per-worker*, not per-task. -* :class:`AsyncExecutor` — async context manager. Every :meth:`submit` - schedules a fresh AnyIO task on the executor's internal task group. - Cancel granularity is *per-task*: ``Future.cancel()`` propagates to the - underlying task's :func:`anyio.from_thread.check_cancelled`. - -All three propagate :class:`contextvars.Context` to the task, matching +* :class:`WorkerExecutor` — sync context manager. Spawn-on-demand pool of + plain ``threading.Thread`` workers backed by a ``deque`` and a + :class:`threading.Condition`. No event loop required. +* :class:`AsyncWorkerExecutor` — async context manager. Same shape, but + workers run inside ``anyio.to_thread.run_sync(..., abandon_on_cancel=False)`` + so user code can call :func:`anyio.from_thread.check_cancelled`. The + cancel scope of each worker spans its whole lifetime (many tasks reuse + the same worker), so cancellation granularity is *per-worker*, not + per-task. + +Both propagate :class:`contextvars.Context` to the task, matching :func:`asyncio.to_thread` / Trio / AnyIO spawn semantics. -The async variants take a caller-owned :class:`localpost.Portal` and run -their internal :class:`anyio.abc.TaskGroup` on its loop. They expose -:meth:`stop` (safe from any thread) for cooperative cancellation. - -There is no idle-timeout self-exit for workers in either pool variant — -once spawned, a worker lives until the executor closes. See +There is no cap on the number of workers and no backlog — concurrency is +the caller's concern (a Cloud Run-like upstream gate, a consumer-level +``Semaphore``, etc.). Workers are spawned lazily as submissions arrive +with no idle worker available, and live until the executor closes; see ``docs/adr/0005-no-idle-timeout-for-worker-pools.md`` for the rationale. + +The async variant takes a caller-owned :class:`localpost.Portal` and runs +its internal :class:`anyio.abc.TaskGroup` on its loop. It exposes +:meth:`stop` (safe from any thread) for cooperative cancellation. """ from __future__ import annotations import contextvars -import functools -import math import threading +from collections import deque from collections.abc import Callable from concurrent.futures import Future from dataclasses import dataclass, field from types import TracebackType -from typing import Any, Protocol, Self, cast, final, runtime_checkable +from typing import Any, Protocol, Self, final, runtime_checkable -from anyio import ( - CapacityLimiter, - ClosedResourceError, - WouldBlock, - create_task_group, - to_thread, -) +from anyio import create_task_group, to_thread from localpost._portal import Portal -from ._channel import Channel, ReceiveChannel, SendChannel - @runtime_checkable class Executor(Protocol): @@ -98,39 +86,27 @@ def run(self) -> None: # -------------------------------------------------------------------------- -# WorkerExecutor — channel + plain threads +# WorkerExecutor — deque + Condition, plain threads # -------------------------------------------------------------------------- @final class WorkerExecutor: - """Channel-backed worker pool of plain ``threading.Thread``s. + """Spawn-on-demand pool of plain ``threading.Thread`` workers. - ``submit`` tries ``put_nowait`` first; on ``WouldBlock`` it spawns a new - worker (up to ``max_concurrency``) and falls through to a blocking - ``put`` (which is what backpressures the caller when at the cap). - - Workers all pull from a single shared receiver. Once spawned, a worker - lives until the executor closes — no idle-timeout self-exit. See + ``submit`` enqueues onto a shared :class:`collections.deque` under a + :class:`threading.Condition`; if no worker is idle, a new one is + spawned. There is no cap and no backlog — concurrency is the caller's + concern. Once spawned, a worker lives until the executor closes. See ``docs/adr/0005-no-idle-timeout-for-worker-pools.md``. """ - def __init__( - self, - *, - max_concurrency: float = math.inf, - backlog: int | None = 0, - thread_name_prefix: str = "lp-worker", - ) -> None: - if max_concurrency <= 0: - raise ValueError("max_concurrency must be > 0") - self._max_concurrency = max_concurrency + def __init__(self, *, thread_name_prefix: str = "lp-worker") -> None: self._thread_name_prefix = thread_name_prefix - self._tx: SendChannel[Task] - self._rx: ReceiveChannel[Task] - self._tx, self._rx = Channel.create(capacity=backlog) - self._lock = threading.Lock() + self._cond = threading.Condition() + self._queue: deque[Task] = deque() self._workers: list[threading.Thread] = [] + self._idle = 0 self._opened = False self._closed = False @@ -151,16 +127,13 @@ def __exit__( tb: TracebackType | None, ) -> None: del exc_type, exc, tb - with self._lock: + with self._cond: if self._closed: return self._closed = True - # Closing tx makes idle workers see EndOfStream; busy workers finish - # their current task and then see EndOfStream on the next get. - self._tx.close() + self._cond.notify_all() for t in list(self._workers): t.join() - self._rx.close() def submit[**P, R]( self, @@ -169,18 +142,14 @@ def submit[**P, R]( *args: P.args, **kwargs: P.kwargs, ) -> Future[R]: - if not self._opened or self._closed: - raise RuntimeError("WorkerExecutor is not open") task = Task(fn, args, kwargs) - try: - self._tx.put_nowait(task) - except WouldBlock: - with self._lock: - if self._closed: - raise RuntimeError("WorkerExecutor is closed") from None - if len(self._workers) < self._max_concurrency: - self._spawn_worker() - self._tx.put(task) # blocks once we're at the cap + with self._cond: + if not self._opened or self._closed: + raise RuntimeError("WorkerExecutor is not open") + self._queue.append(task) + if self._idle == 0: + self._spawn_worker() + self._cond.notify() return task.future def _spawn_worker(self) -> None: @@ -194,12 +163,20 @@ def _spawn_worker(self) -> None: t.start() def _run_worker(self) -> None: - for task in self._rx: + while True: + with self._cond: + self._idle += 1 + while not self._queue and not self._closed: + self._cond.wait() + self._idle -= 1 + if not self._queue: + return # closed and drained + task = self._queue.popleft() task.run() # -------------------------------------------------------------------------- -# AsyncWorkerExecutor — channel + AnyIO-backed worker threads +# AsyncWorkerExecutor — deque + Condition, AnyIO-backed worker threads # -------------------------------------------------------------------------- @@ -215,27 +192,16 @@ class AsyncWorkerExecutor: Cancel granularity is per-worker (cancel scope spans the worker's whole lifetime, which serves many tasks). Use :meth:`stop` for fast cooperative - shutdown — it cancels the internal task group and closes the channel so - idle workers wake immediately and active tasks' next ``check_cancelled`` - raises. + shutdown — it cancels the internal task group and notifies idle workers + so they wake immediately and active tasks' next ``check_cancelled`` raises. """ - def __init__( - self, - *, - portal: Portal, - max_concurrency: float = math.inf, - backlog: int | None = 0, - ) -> None: - if max_concurrency <= 0: - raise ValueError("max_concurrency must be > 0") + def __init__(self, *, portal: Portal) -> None: self._portal = portal - self._max_concurrency = max_concurrency - self._tx: SendChannel[Task] - self._rx: ReceiveChannel[Task] - self._tx, self._rx = Channel.create(capacity=backlog) - self._lock = threading.Lock() + self._cond = threading.Condition() + self._queue: deque[Task] = deque() self._worker_count = 0 + self._idle = 0 # Constructed eagerly: callers always instantiate inside ``async with`` on the # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. @@ -264,32 +230,29 @@ async def __aexit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: - with self._lock: - if self._closed: - return - self._closed = True - # Close the channel first — idle workers wake via EndOfStream. - self._tx.close() + with self._cond: + if not self._closed: + self._closed = True + self._cond.notify_all() # Exit the task group: on clean body it waits for host tasks to finish; # on exception AnyIO cancels children automatically. await self._tg.__aexit__(exc_type, exc, tb) - self._rx.close() def stop(self) -> None: - """Cooperatively shut down: close the channel and cancel the internal + """Cooperatively shut down: wake idle workers and cancel the internal task group. Safe from any thread. Idempotent. - Idle workers wake via the channel close; active tasks see their next + Idle workers wake from the condition; active tasks see their next :func:`anyio.from_thread.check_cancelled` raise. Tasks that don't poll run to natural completion. """ - if not self._opened or self._closed: + if not self._opened: return - # The channel uses thread locks; closing is safe from any thread. - try: - self._tx.close() - except ClosedResourceError: - pass + with self._cond: + if self._closed: + return + self._closed = True + self._cond.notify_all() self._portal.run_sync(self._tg.cancel_scope.cancel) def submit[**P, R]( @@ -299,18 +262,14 @@ def submit[**P, R]( *args: P.args, **kwargs: P.kwargs, ) -> Future[R]: - if not self._opened or self._closed: - raise RuntimeError("AsyncWorkerExecutor is not open") task = Task(fn, args, kwargs) - try: - self._tx.put_nowait(task) - except WouldBlock: - with self._lock: - if self._closed: - raise RuntimeError("AsyncWorkerExecutor is closed") from None - if self._worker_count < self._max_concurrency: - self._spawn_worker() - self._tx.put(task) + with self._cond: + if not self._opened or self._closed: + raise RuntimeError("AsyncWorkerExecutor is not open") + self._queue.append(task) + if self._idle == 0: + self._spawn_worker() + self._cond.notify() return task.future def _spawn_worker(self) -> None: @@ -322,90 +281,13 @@ async def host_task() -> None: self._worker_count += 1 def _run_worker(self) -> None: - for task in self._rx: + while True: + with self._cond: + self._idle += 1 + while not self._queue and not self._closed: + self._cond.wait() + self._idle -= 1 + if not self._queue: + return # closed and drained + task = self._queue.popleft() task.run() - - -# -------------------------------------------------------------------------- -# AsyncExecutor — fresh AnyIO task per submit -# -------------------------------------------------------------------------- - - -@final -class AsyncExecutor: - """Async context manager. One AnyIO task per :meth:`submit`, executed via - ``anyio.to_thread.run_sync`` and gated by an - :class:`anyio.CapacityLimiter` (``math.inf`` = no cap). - - Cancel granularity is *per-task*: calling ``Future.cancel()`` on the - returned :class:`~concurrent.futures.Future` cancels just that submit's - AnyIO task; the cancel propagates through ``to_thread.run_sync(..., - abandon_on_cancel=False)`` into the worker thread's threadlocals so the - task's next :func:`anyio.from_thread.check_cancelled` raises. - - :meth:`stop` cancels the internal task group, signalling every in-flight - submit at once. - """ - - def __init__( - self, - *, - portal: Portal, - max_concurrency: float = math.inf, - ) -> None: - if max_concurrency <= 0: - raise ValueError("max_concurrency must be > 0") - self._portal = portal - # Constructed eagerly: callers always instantiate inside ``async with`` on the - # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so - # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. - self._tg = create_task_group() - self._limiter = limiter = CapacityLimiter(max_concurrency) - self._to_thread = functools.partial(to_thread.run_sync, limiter=limiter) - self._opened = False - self._closed = False - - @property - def portal(self) -> Portal: - return self._portal - - async def __aenter__(self) -> Self: - if self._closed: - raise RuntimeError("AsyncExecutor cannot be reused") - await self._tg.__aenter__() - self._opened = True - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - if self._closed: - return - self._closed = True - # Exit the internal task group: clean body waits for in-flight submits; - # exception body cancels them. No manual tracking needed. - await self._tg.__aexit__(exc_type, exc, tb) - - def stop(self) -> None: - """Cancel every in-flight submit by cancelling the internal task group. - Safe from any thread. Idempotent. - """ - if not self._opened or self._closed: - return - self._portal.run_sync(self._tg.cancel_scope.cancel) - - def submit[**P, R]( - self, - fn: Callable[P, R], - /, - *args: P.args, - **kwargs: P.kwargs, - ) -> Future[R]: - if not self._opened or self._closed: - raise RuntimeError("AsyncExecutor is not open") - task = Task(fn, args, kwargs) - self._portal.run_sync(self._tg.start_soon, self._to_thread, task.run) - return task.future diff --git a/localpost/threadtools/_task_group.py b/localpost/threadtools/_task_group.py index 11de641..261495b 100644 --- a/localpost/threadtools/_task_group.py +++ b/localpost/threadtools/_task_group.py @@ -7,9 +7,9 @@ There is no cancel signal — running tasks are not interrupted on the first failure. Use an executor that propagates AnyIO cancellation -(:class:`AnyIOWorkerExecutor`, :class:`AnyIOExecutor`) and have your tasks -poll :func:`anyio.from_thread.check_cancelled` if cooperative cancel is -needed in your application. +(:class:`AsyncWorkerExecutor`) and have your tasks poll +:func:`anyio.from_thread.check_cancelled` if cooperative cancel is needed +in your application. """ from __future__ import annotations diff --git a/tests/threadtools/async_executor.py b/tests/threadtools/async_executor.py index 85ffda2..4779c6c 100644 --- a/tests/threadtools/async_executor.py +++ b/tests/threadtools/async_executor.py @@ -1,8 +1,7 @@ -"""Tests for the AnyIO-backed executors. +"""Tests for :class:`localpost.threadtools.AsyncWorkerExecutor`. -Both :class:`AsyncWorkerExecutor` and :class:`AsyncExecutor` are async -context managers and require a caller-owned :class:`localpost.Portal` -running on the test's loop. +It's an async context manager and requires a caller-owned +:class:`localpost.Portal` running on the test's loop. We pin both backends (``asyncio`` and ``trio``) for the cross-backend ``check_cancelled`` story; ``stop()`` must work the same on each. @@ -21,7 +20,7 @@ import pytest from localpost import Portal -from localpost.threadtools import AsyncExecutor, AsyncWorkerExecutor +from localpost.threadtools import AsyncWorkerExecutor # Both backends, on every test in this module. pytestmark = pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"]) @@ -40,11 +39,6 @@ async def portal() -> AsyncIterator[Portal]: yield Portal(raw) -# --------------------------------------------------------------------------- -# AsyncWorkerExecutor -# --------------------------------------------------------------------------- - - async def test_async_worker_executor_basic_submit(anyio_backend, portal: Portal): async with AsyncWorkerExecutor(portal=portal) as ex: # ``submit`` and ``Future.result`` must both run off the loop thread. @@ -97,81 +91,3 @@ def slow_task() -> None: # The future ends up with a cancellation exception (backend-specific type). assert fut.done() assert fut.exception() is not None - - -# --------------------------------------------------------------------------- -# AsyncExecutor -# --------------------------------------------------------------------------- - - -async def test_async_executor_basic_submit(anyio_backend, portal: Portal): - async with AsyncExecutor(portal=portal) as ex: - fut: Future[int] = await anyio.to_thread.run_sync(ex.submit, lambda: 7 * 6) - result = await anyio.to_thread.run_sync(fut.result, 5) - assert result == 42 - - -async def test_async_executor_propagates_exception(anyio_backend, portal: Portal): - class Boom(Exception): - pass - - def bad() -> None: - raise Boom("nope") - - async with AsyncExecutor(portal=portal) as ex: - fut = await anyio.to_thread.run_sync(ex.submit, bad) - - def get_result() -> object: - return fut.result(timeout=5) - - with pytest.raises(Boom): - await anyio.to_thread.run_sync(get_result) - - -async def test_async_executor_capacity_limiter(anyio_backend, portal: Portal): - """``max_concurrency`` is enforced via :class:`anyio.CapacityLimiter`.""" - n_concurrent = 0 - max_seen = 0 - lock = threading.Lock() - - def hold() -> None: - nonlocal n_concurrent, max_seen - with lock: - n_concurrent += 1 - max_seen = max(max_seen, n_concurrent) - time.sleep(0.05) - with lock: - n_concurrent -= 1 - - async with AsyncExecutor(portal=portal, max_concurrency=2) as ex: - futs = [await anyio.to_thread.run_sync(ex.submit, hold) for _ in range(8)] - for f in futs: - await anyio.to_thread.run_sync(f.result, 5) - assert max_seen <= 2 - - -async def test_async_executor_stop_cancels_all_inflight(anyio_backend, portal: Portal): - cancelled_count = 0 - cancelled_lock = threading.Lock() - started = threading.Event() - n = 4 - - def slow_task() -> None: - nonlocal cancelled_count - started.set() - try: - for _ in range(500): - anyio.from_thread.check_cancelled() - time.sleep(0.005) - except BaseException: - with cancelled_lock: - cancelled_count += 1 - raise - - async with AsyncExecutor(portal=portal, max_concurrency=n) as ex: - for _ in range(n): - await anyio.to_thread.run_sync(ex.submit, slow_task) - await anyio.to_thread.run_sync(started.wait, 2.0) - await anyio.to_thread.run_sync(ex.stop) - - assert cancelled_count == n diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py index bc09a39..ebb9bb1 100644 --- a/tests/threadtools/executor.py +++ b/tests/threadtools/executor.py @@ -1,14 +1,12 @@ """Tests for :class:`localpost.threadtools.WorkerExecutor` (sync, no AnyIO). -The Async variants (``AsyncWorkerExecutor`` / ``AsyncExecutor``) live in -``async_executor.py`` since they're async context managers and need a -:class:`localpost.Portal`. +The Async variant (``AsyncWorkerExecutor``) lives in ``async_executor.py`` +since it's an async context manager and needs a :class:`localpost.Portal`. """ from __future__ import annotations import contextvars -import math import threading import time @@ -16,7 +14,6 @@ from localpost.threadtools import WorkerExecutor - # --------------------------------------------------------------------------- # Submit / result # --------------------------------------------------------------------------- @@ -56,29 +53,12 @@ def bad(): # --------------------------------------------------------------------------- -# Lifecycle / lazy spawn / max_concurrency +# Lifecycle / spawn-on-demand # --------------------------------------------------------------------------- -def test_lazy_worker_spawn_under_max_concurrency(): - """Workers are spawned on demand — never more than ``max_concurrency``.""" - seen: set[int] = set() - seen_lock = threading.Lock() - - def record(): - with seen_lock: - seen.add(threading.get_ident()) - time.sleep(0.005) - - with WorkerExecutor(max_concurrency=3) as ex: - futs = [ex.submit(record) for _ in range(40)] - for f in futs: - f.result(timeout=5) - assert len(seen) <= 3 - - -def test_unlimited_concurrency_spawns_per_pending_task(): - """Default (math.inf) → every concurrent submission gets its own worker thread.""" +def test_spawn_on_demand_per_pending_task(): + """Concurrent submissions with no idle worker each get a fresh worker.""" n = 8 seen: set[int] = set() barrier = threading.Barrier(n) @@ -91,28 +71,37 @@ def hold(): seen.add(threading.get_ident()) with WorkerExecutor() as ex: - # Default max_concurrency is math.inf. - assert math.isinf(ex._max_concurrency) futs = [ex.submit(hold) for _ in range(n)] for f in futs: f.result(timeout=5) assert len(seen) == n +def test_idle_workers_are_reused(): + """Workers stay around when idle and pick up subsequent submissions.""" + with WorkerExecutor() as ex: + ex.submit(lambda: None).result(timeout=5) + spawned = list(ex.workers) + assert len(spawned) == 1 + # Idle stretch — workers must still be alive (no idle-timeout self-exit). + time.sleep(0.05) + assert ex.workers == spawned + assert all(t.is_alive() for t in spawned) + # Sequential submits reuse the single idle worker. + for _ in range(20): + ex.submit(lambda: None).result(timeout=5) + assert len(ex.workers) == 1 + + def test_workers_persist_until_close(): """Workers live for the executor's lifetime; once spawned they don't self-exit.""" - with WorkerExecutor(max_concurrency=2) as ex: + with WorkerExecutor() as ex: ex.submit(lambda: None).result(timeout=5) spawned = list(ex.workers) assert len(spawned) >= 1 - # Idle stretch — workers must still be alive (no idle-timeout self-exit). time.sleep(0.2) assert ex.workers == spawned assert all(t.is_alive() for t in spawned) - # Subsequent submits reuse the same workers, never exceed the cap. - for _ in range(20): - ex.submit(lambda: None).result(timeout=5) - assert len(ex.workers) <= 2 def test_submit_after_close_raises(): @@ -132,11 +121,6 @@ def test_executor_cannot_be_reused(): pass -def test_max_concurrency_must_be_positive(): - with pytest.raises(ValueError, match="max_concurrency"): - WorkerExecutor(max_concurrency=0) - - # --------------------------------------------------------------------------- # ContextVar propagation # --------------------------------------------------------------------------- From 69e6cf04952e37b3eec68106d601fd059aba4062 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 20:20:57 +0400 Subject: [PATCH 267/286] refactor(threadtools): extract _WorkerPoolBase; add stop() to WorkerExecutor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both executors share the same wait/notify discipline (submit, _run_worker, mark-closed). Moving them onto a private _WorkerPoolBase removes ~80 lines of duplication and gives a single source of truth for the queue protocol; leaves stay @final and own their own context-manager lifecycle plus _spawn_worker. WorkerExecutor now has stop() (was async-only): marks the pool closed and wakes idle workers — busy workers finish their current task and exit on the next iteration. Useful from a signal handler or any thread that needs to start shutdown without waiting for the with-block to exit. The WorkerExecutor.workers property is dropped — it was test-only and leaked the internal Thread list. Tests now verify the contract via threading.get_ident() inside tasks (which is what the property was indirectly checking anyway). Both executors expose worker_count: int as the symmetric public observation point. --- localpost/threadtools/README.md | 12 +- localpost/threadtools/_executor.py | 210 +++++++++++++++-------------- tests/threadtools/executor.py | 68 +++++++--- 3 files changed, 159 insertions(+), 131 deletions(-) diff --git a/localpost/threadtools/README.md b/localpost/threadtools/README.md index f95d994..d2375a9 100644 --- a/localpost/threadtools/README.md +++ b/localpost/threadtools/README.md @@ -25,12 +25,12 @@ got = rx.get_nowait() # raises WouldBlock / EndOfStream ## Executors -Two implementations share the same `submit(fn, *args, **kwargs) -> Future` contract. Both are spawn-on-demand pools with no cap and no backlog — concurrency is the caller's concern (a Cloud Run-like upstream gate, a consumer-level `Semaphore`, etc.). Lifecycle and cancellation differ: +Two implementations share the same `submit(fn, *args, **kwargs) -> Future` contract. Both are spawn-on-demand pools with no cap and no backlog — concurrency is the caller's concern (a Cloud Run-like upstream gate, a consumer-level `Semaphore`, etc.). Both expose `worker_count: int` and a `stop()` method that's safe to call from any thread. Lifecycle and the effect of `stop()` differ: -| Executor | CM | Cancellation | Loop required | -|-------------------------|--------------|-------------------------------------|---------------| -| `WorkerExecutor` | `with` | None — workers finish naturally | No | -| `AsyncWorkerExecutor` | `async with` | Per-worker (`stop()` / scope cancel)| Yes | +| Executor | CM | `stop()` | Loop required | +|-------------------------|--------------|-----------------------------------------------------------------------|---------------| +| `WorkerExecutor` | `with` | Soft close — idle workers exit, busy workers finish their current task| No | +| `AsyncWorkerExecutor` | `async with` | Soft close + cancel scope (per-worker `check_cancelled`) | Yes | Both propagate `contextvars.Context` to the task, matching `asyncio.to_thread` / Trio / AnyIO spawn semantics. @@ -46,6 +46,8 @@ with WorkerExecutor() as ex: result = fut.result() ``` +`stop()` is safe to call from any thread (e.g. a signal handler): it marks the pool closed and wakes idle workers so they exit; busy workers finish their current task and then exit on the next loop iteration. Subsequent `submit` calls raise `RuntimeError`. + ### `AsyncWorkerExecutor` — deque + AnyIO threadlocals Same shape as `WorkerExecutor`, but workers run inside `anyio.to_thread.run_sync(..., abandon_on_cancel=False)` so user code can call `anyio.from_thread.check_cancelled`. diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index ecc4890..1f70fe0 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -14,7 +14,11 @@ per-task. Both propagate :class:`contextvars.Context` to the task, matching -:func:`asyncio.to_thread` / Trio / AnyIO spawn semantics. +:func:`asyncio.to_thread` / Trio / AnyIO spawn semantics. Both expose +:meth:`stop` (safe from any thread) for cooperative shutdown — the sync +variant only wakes idle workers and rejects new submits; the async +variant additionally cancels its internal task group so tasks polling +:func:`anyio.from_thread.check_cancelled` raise. There is no cap on the number of workers and no backlog — concurrency is the caller's concern (a Cloud Run-like upstream gate, a consumer-level @@ -23,8 +27,7 @@ ``docs/adr/0005-no-idle-timeout-for-worker-pools.md`` for the rationale. The async variant takes a caller-owned :class:`localpost.Portal` and runs -its internal :class:`anyio.abc.TaskGroup` on its loop. It exposes -:meth:`stop` (safe from any thread) for cooperative cancellation. +its internal :class:`anyio.abc.TaskGroup` on its loop. """ from __future__ import annotations @@ -86,54 +89,33 @@ def run(self) -> None: # -------------------------------------------------------------------------- -# WorkerExecutor — deque + Condition, plain threads +# Shared queue + spawn-on-demand protocol # -------------------------------------------------------------------------- -@final -class WorkerExecutor: - """Spawn-on-demand pool of plain ``threading.Thread`` workers. +class _WorkerPoolBase: + """Internal base: deque + condition queue protocol shared by the + executors. Subclasses provide :meth:`_spawn_worker` and own their + own context-manager lifecycle. - ``submit`` enqueues onto a shared :class:`collections.deque` under a - :class:`threading.Condition`; if no worker is idle, a new one is - spawned. There is no cap and no backlog — concurrency is the caller's - concern. Once spawned, a worker lives until the executor closes. See - ``docs/adr/0005-no-idle-timeout-for-worker-pools.md``. + The protocol is: ``submit`` enqueues onto ``_queue`` under ``_cond``; + if no worker is idle, it asks the subclass to spawn one. + ``_run_worker`` is the worker's loop — wait for work, pop, run, + repeat. ``stop`` marks the pool closed and wakes idle workers; busy + workers exit on their next loop iteration. """ - def __init__(self, *, thread_name_prefix: str = "lp-worker") -> None: - self._thread_name_prefix = thread_name_prefix + def __init__(self) -> None: self._cond = threading.Condition() self._queue: deque[Task] = deque() - self._workers: list[threading.Thread] = [] self._idle = 0 + self._worker_count = 0 self._opened = False self._closed = False @property - def workers(self) -> list[threading.Thread]: - return self._workers - - def __enter__(self) -> Self: - if self._closed: - raise RuntimeError("WorkerExecutor cannot be reused") - self._opened = True - return self - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - del exc_type, exc, tb - with self._cond: - if self._closed: - return - self._closed = True - self._cond.notify_all() - for t in list(self._workers): - t.join() + def worker_count(self) -> int: + return self._worker_count def submit[**P, R]( self, @@ -145,22 +127,39 @@ def submit[**P, R]( task = Task(fn, args, kwargs) with self._cond: if not self._opened or self._closed: - raise RuntimeError("WorkerExecutor is not open") + raise RuntimeError(f"{type(self).__name__} is not open") self._queue.append(task) if self._idle == 0: self._spawn_worker() + self._worker_count += 1 self._cond.notify() return task.future + def stop(self) -> None: + """Cooperatively shut down: mark closed and wake idle workers. + Safe from any thread. Idempotent. + + Idle workers wake from the condition and exit; busy workers + finish their current task and exit on the next loop iteration. + Subsequent ``submit`` calls raise :class:`RuntimeError`. + """ + if not self._opened: + return + self._mark_closed() + + def _mark_closed(self) -> bool: + """Mark closed under the condition; notify all waiters. Returns True + iff this call was the one that closed the pool. + """ + with self._cond: + if self._closed: + return False + self._closed = True + self._cond.notify_all() + return True + def _spawn_worker(self) -> None: - wid = len(self._workers) - t = threading.Thread( - target=self._run_worker, - name=f"{self._thread_name_prefix}-{wid}", - daemon=True, - ) - self._workers.append(t) - t.start() + raise NotImplementedError def _run_worker(self) -> None: while True: @@ -176,12 +175,61 @@ def _run_worker(self) -> None: # -------------------------------------------------------------------------- -# AsyncWorkerExecutor — deque + Condition, AnyIO-backed worker threads +# WorkerExecutor — sync, plain threads # -------------------------------------------------------------------------- @final -class AsyncWorkerExecutor: +class WorkerExecutor(_WorkerPoolBase): + """Spawn-on-demand pool of plain ``threading.Thread`` workers. + + No event loop required. There is no cap and no backlog — + concurrency is the caller's concern. Once spawned, a worker lives + until the executor closes (via ``__exit__`` or :meth:`stop`); see + ``docs/adr/0005-no-idle-timeout-for-worker-pools.md``. + """ + + def __init__(self, *, thread_name_prefix: str = "lp-worker") -> None: + super().__init__() + self._thread_name_prefix = thread_name_prefix + self._workers: list[threading.Thread] = [] + + def __enter__(self) -> Self: + if self._closed: + raise RuntimeError("WorkerExecutor cannot be reused") + self._opened = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + del exc_type, exc, tb + self._mark_closed() + for t in list(self._workers): + t.join() + + def _spawn_worker(self) -> None: + # ``_worker_count`` is the about-to-be index — base increments after we return. + wid = self._worker_count + t = threading.Thread( + target=self._run_worker, + name=f"{self._thread_name_prefix}-{wid}", + daemon=True, + ) + self._workers.append(t) + t.start() + + +# -------------------------------------------------------------------------- +# AsyncWorkerExecutor — AnyIO-backed worker threads +# -------------------------------------------------------------------------- + + +@final +class AsyncWorkerExecutor(_WorkerPoolBase): """Like :class:`WorkerExecutor`, but workers run via ``anyio.to_thread.run_sync`` so user code can call :func:`anyio.from_thread.check_cancelled`. @@ -191,32 +239,23 @@ class AsyncWorkerExecutor: ``host_task``. ``portal`` is required and caller-owned. Cancel granularity is per-worker (cancel scope spans the worker's whole - lifetime, which serves many tasks). Use :meth:`stop` for fast cooperative - shutdown — it cancels the internal task group and notifies idle workers - so they wake immediately and active tasks' next ``check_cancelled`` raises. + lifetime, which serves many tasks). :meth:`stop` cancels the internal + task group and wakes idle workers; active tasks see their next + ``check_cancelled`` raise. """ def __init__(self, *, portal: Portal) -> None: + super().__init__() self._portal = portal - self._cond = threading.Condition() - self._queue: deque[Task] = deque() - self._worker_count = 0 - self._idle = 0 # Constructed eagerly: callers always instantiate inside ``async with`` on the # portal's loop (the only loop ``tg.start_soon`` can target via ``portal``), so # ``get_async_backend()`` is satisfied. Loop-affinity is set by ``__aenter__``. self._tg = create_task_group() - self._opened = False - self._closed = False @property def portal(self) -> Portal: return self._portal - @property - def worker_count(self) -> int: - return self._worker_count - async def __aenter__(self) -> Self: if self._closed: raise RuntimeError("AsyncWorkerExecutor cannot be reused") @@ -230,17 +269,14 @@ async def __aexit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: - with self._cond: - if not self._closed: - self._closed = True - self._cond.notify_all() + self._mark_closed() # Exit the task group: on clean body it waits for host tasks to finish; # on exception AnyIO cancels children automatically. await self._tg.__aexit__(exc_type, exc, tb) def stop(self) -> None: - """Cooperatively shut down: wake idle workers and cancel the internal - task group. Safe from any thread. Idempotent. + """Cooperatively shut down: cancel the internal task group and wake + idle workers. Safe from any thread. Idempotent. Idle workers wake from the condition; active tasks see their next :func:`anyio.from_thread.check_cancelled` raise. Tasks that don't poll @@ -248,29 +284,8 @@ def stop(self) -> None: """ if not self._opened: return - with self._cond: - if self._closed: - return - self._closed = True - self._cond.notify_all() - self._portal.run_sync(self._tg.cancel_scope.cancel) - - def submit[**P, R]( - self, - fn: Callable[P, R], - /, - *args: P.args, - **kwargs: P.kwargs, - ) -> Future[R]: - task = Task(fn, args, kwargs) - with self._cond: - if not self._opened or self._closed: - raise RuntimeError("AsyncWorkerExecutor is not open") - self._queue.append(task) - if self._idle == 0: - self._spawn_worker() - self._cond.notify() - return task.future + if self._mark_closed(): + self._portal.run_sync(self._tg.cancel_scope.cancel) def _spawn_worker(self) -> None: async def host_task() -> None: @@ -278,16 +293,3 @@ async def host_task() -> None: # Schedule the host task into our internal task group from any thread. self._portal.run_sync(self._tg.start_soon, host_task) - self._worker_count += 1 - - def _run_worker(self) -> None: - while True: - with self._cond: - self._idle += 1 - while not self._queue and not self._closed: - self._cond.wait() - self._idle -= 1 - if not self._queue: - return # closed and drained - task = self._queue.popleft() - task.run() diff --git a/tests/threadtools/executor.py b/tests/threadtools/executor.py index ebb9bb1..97a3fce 100644 --- a/tests/threadtools/executor.py +++ b/tests/threadtools/executor.py @@ -74,34 +74,26 @@ def hold(): futs = [ex.submit(hold) for _ in range(n)] for f in futs: f.result(timeout=5) + assert ex.worker_count == n assert len(seen) == n def test_idle_workers_are_reused(): - """Workers stay around when idle and pick up subsequent submissions.""" + """Sequential submits with no concurrency reuse the single idle worker.""" with WorkerExecutor() as ex: - ex.submit(lambda: None).result(timeout=5) - spawned = list(ex.workers) - assert len(spawned) == 1 - # Idle stretch — workers must still be alive (no idle-timeout self-exit). - time.sleep(0.05) - assert ex.workers == spawned - assert all(t.is_alive() for t in spawned) - # Sequential submits reuse the single idle worker. - for _ in range(20): - ex.submit(lambda: None).result(timeout=5) - assert len(ex.workers) == 1 - - -def test_workers_persist_until_close(): - """Workers live for the executor's lifetime; once spawned they don't self-exit.""" + idents = [ex.submit(threading.get_ident).result(timeout=5) for _ in range(20)] + assert ex.worker_count == 1 + assert len(set(idents)) == 1 + + +def test_workers_persist_through_idle(): + """Workers don't self-exit during idle stretches.""" with WorkerExecutor() as ex: - ex.submit(lambda: None).result(timeout=5) - spawned = list(ex.workers) - assert len(spawned) >= 1 - time.sleep(0.2) - assert ex.workers == spawned - assert all(t.is_alive() for t in spawned) + a = ex.submit(threading.get_ident).result(timeout=5) + time.sleep(0.2) # idle stretch — must not self-exit + b = ex.submit(threading.get_ident).result(timeout=5) + assert ex.worker_count == 1 + assert a == b def test_submit_after_close_raises(): @@ -121,6 +113,38 @@ def test_executor_cannot_be_reused(): pass +# --------------------------------------------------------------------------- +# stop() +# --------------------------------------------------------------------------- + + +def test_stop_makes_subsequent_submits_raise(): + with WorkerExecutor() as ex: + ex.submit(lambda: None).result(timeout=5) + ex.stop() + with pytest.raises(RuntimeError): + ex.submit(lambda: None) + + +def test_stop_wakes_idle_workers_so_exit_returns_promptly(): + """After ``stop()``, idle workers must wake — otherwise ``__exit__`` would hang.""" + ex = WorkerExecutor() + with ex: + ex.submit(lambda: None).result(timeout=5) + # Worker is now idle, blocked in cond.wait. + ex.stop() + # __exit__ joins the worker; if stop() didn't wake it, this would hang. + + +def test_stop_is_idempotent_and_safe_outside_with(): + ex = WorkerExecutor() + ex.stop() # not opened — no-op + with ex: + ex.stop() + ex.stop() # idempotent + ex.stop() # already closed — no-op + + # --------------------------------------------------------------------------- # ContextVar propagation # --------------------------------------------------------------------------- From 9fcdf9dc9182ef6358d1a24ba822a1f4893ae2fc Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 20:32:13 +0400 Subject: [PATCH 268/286] test(threadtools): guard AsyncWorkerExecutor cancel-unblocks-idle-workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outer-scope cancellation already correctly unblocks idle workers stuck in cond.wait, but only because __aexit__'s _mark_closed() runs synchronously before the await on tg.__aexit__. If a future refactor swaps these or sneaks an await between them, idle workers would stay parked while AnyIO's abandon_on_cancel=False waits for the thread to return — a permanent hang. Add a test that wraps the scenario in fail_after(2.0) so the hang would surface as a test failure, and a comment in __aexit__ pointing at the test as the guard for the invariant. Verified passing on both asyncio and trio. --- localpost/threadtools/_executor.py | 9 +++++++-- tests/threadtools/async_executor.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/localpost/threadtools/_executor.py b/localpost/threadtools/_executor.py index 1f70fe0..19daa24 100644 --- a/localpost/threadtools/_executor.py +++ b/localpost/threadtools/_executor.py @@ -269,9 +269,14 @@ async def __aexit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: + # Invariant: ``_mark_closed`` must run synchronously before any await. + # It wakes idle workers (via ``cond.notify_all``) so their ``to_thread`` + # awaits can resolve. If an await sneaks in before this, an outer-scope + # cancellation hitting that await would leave workers stuck in + # ``cond.wait`` forever (``abandon_on_cancel=False`` waits for the + # thread to return naturally). Guarded by + # ``test_async_worker_executor_cancel_unblocks_idle_workers``. self._mark_closed() - # Exit the task group: on clean body it waits for host tasks to finish; - # on exception AnyIO cancels children automatically. await self._tg.__aexit__(exc_type, exc, tb) def stop(self) -> None: diff --git a/tests/threadtools/async_executor.py b/tests/threadtools/async_executor.py index 4779c6c..c056925 100644 --- a/tests/threadtools/async_executor.py +++ b/tests/threadtools/async_executor.py @@ -65,6 +65,27 @@ def task() -> str: assert polled.is_set() +async def test_async_worker_executor_cancel_unblocks_idle_workers(anyio_backend, portal: Portal): + """Outer-scope cancellation must unblock idle workers stuck in ``cond.wait``. + + Correctness depends on a sync ordering invariant inside ``__aexit__``: + ``_mark_closed()`` (which wakes workers via ``notify_all``) runs *before* + any ``await``. If a future refactor sneaks an await between the close and + the ``await self._tg.__aexit__(...)``, this test will hang and + ``fail_after`` will surface it. + """ + with anyio.fail_after(2.0): + with anyio.move_on_after(0.1) as scope: + async with AsyncWorkerExecutor(portal=portal) as ex: + # Spawn a worker via a quick submit; let it return to cond.wait. + fut = await anyio.to_thread.run_sync(ex.submit, lambda: None) + await anyio.to_thread.run_sync(fut.result, 5) + assert ex.worker_count == 1 + # Worker is now idle in cond.wait. Sleep past the deadline. + await anyio.sleep(60) + assert scope.cancelled_caught + + async def test_async_worker_executor_stop_propagates_to_running_task(anyio_backend, portal: Portal): """:meth:`stop` cancels the internal task group; running tasks polling ``check_cancelled`` raise on their next checkpoint.""" From 5877ce3c91b70035431374bcabecef464b14d8fe Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Sun, 10 May 2026 20:08:14 +0000 Subject: [PATCH 269/286] version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index edd6370..83c529b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ Docs = 'https://alexeyshockov.github.io/localpost.py/' [project] name = "localpost" -version = "0.6.0.dev0" +version = "0.6.0.b1" description = "Service hosting, in-process task scheduler, and a lightweight HTTP/1.1 server for long-running async Python processes" requires-python = ">=3.12" readme = "pypi_readme.md" From 98012ce32191d3b99c4289863f24131251f2119a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 20:09:29 +0000 Subject: [PATCH 270/286] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- localpost/_utils.py | 4 ++-- localpost/hosting/rsgi.py | 2 +- localpost/hosting/services/hypercorn.py | 4 ++-- localpost/http/_base.py | 6 +++--- localpost/http/_service.py | 4 ++-- localpost/http/router.py | 2 +- localpost/http/server_h11.py | 4 +++- localpost/openapi/_operation_core.py | 2 +- localpost/openapi/adapters/__init__.py | 6 +++--- localpost/openapi/aio/app.py | 6 +++--- localpost/openapi/aio/middleware.py | 6 +++--- localpost/openapi/app.py | 2 +- localpost/openapi/middleware.py | 6 +++--- localpost/openapi/sse.py | 2 +- localpost/scheduler/_scheduler.py | 6 +++--- tests/hosting/rsgi.py | 2 +- tests/http/_integration_app.py | 16 ++++++++-------- tests/http/backend_parity.py | 6 +++--- tests/http/compress.py | 6 +++--- tests/http/conftest.py | 2 +- tests/http/rsgi.py | 4 ++-- tests/http/wsgi_to.py | 2 +- tests/openapi/aio_app.py | 4 ++-- 23 files changed, 53 insertions(+), 51 deletions(-) diff --git a/localpost/_utils.py b/localpost/_utils.py index ec13c9e..d0a76b6 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -202,7 +202,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: return value if isinstance(value, str): try: - import pytimeparse2 # noqa: PLC0415 + import pytimeparse2 use_dateutil = pytimeparse2.HAS_RELITIVE_TIMEDELTA try: @@ -223,7 +223,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: def td_str(td: timedelta, /) -> str: try: - from humanize import precisedelta # noqa: PLC0415 + from humanize import precisedelta # 23 seconds or 0.24 seconds return precisedelta(td) diff --git a/localpost/hosting/rsgi.py b/localpost/hosting/rsgi.py index b481c62..11561b9 100644 --- a/localpost/hosting/rsgi.py +++ b/localpost/hosting/rsgi.py @@ -170,7 +170,7 @@ def _resolve_handler(handler: AsyncRequestHandler | HttpAsyncApp) -> AsyncReques """Accept either a raw :data:`AsyncRequestHandler` or an :class:`HttpAsyncApp` (compile its route handler on demand).""" # Lazy import — hosting shouldn't pull openapi at import time. - from localpost.openapi.aio.app import HttpAsyncApp as _App # noqa: PLC0415 + from localpost.openapi.aio.app import HttpAsyncApp as _App if isinstance(handler, _App): return handler._build_async_handler() diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py index 0c217a2..b251761 100644 --- a/localpost/hosting/services/hypercorn.py +++ b/localpost/hosting/services/hypercorn.py @@ -15,9 +15,9 @@ def run(sl: ServiceLifetime) -> Awaitable[None]: # ``asyncio`` ``serve`` modules and we pick one based on the running # event loop. A top-level import would force one backend at import time. if current_async_library() == "trio": - from hypercorn.trio import serve # noqa: PLC0415 + from hypercorn.trio import serve else: - from hypercorn.asyncio import serve # type: ignore[assignment] # noqa: PLC0415 + from hypercorn.asyncio import serve # type: ignore[assignment] observed_app = report_started(sl.started, app) return serve(observed_app, config, shutdown_trigger=sl.shutting_down.wait) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index e26404a..63a7b22 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -479,7 +479,7 @@ def _native_stream(ctx: _NativeReqCtx, response: Response, chunks: Iterator[byte available" and silently skipped. """ # Local import to avoid the import cycle (cancel imports HTTPReqCtx). - from localpost.http._cancel import RequestCancelled, check_cancelled # noqa: PLC0415 + from localpost.http._cancel import RequestCancelled, check_cancelled ctx.start_response(response) try: @@ -1230,10 +1230,10 @@ def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Abstr """ backend = config.backend if backend == "h11": - from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 + from localpost.http.server_h11 import HTTPConn elif backend == "httptools": try: - from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 + from localpost.http.server_httptools import HTTPConn except ImportError as e: raise ImportError( "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 8647cc0..53d729a 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -62,12 +62,12 @@ def _pick_conn_factory(backend: str): acceptor topology, where we wire :class:`RoundRobinAcceptor` directly instead of going through ``start_http_server``.""" if backend == "h11": - from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 + from localpost.http.server_h11 import HTTPConn return HTTPConn if backend == "httptools": try: - from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 + from localpost.http.server_httptools import HTTPConn except ImportError as e: raise ImportError( "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" diff --git a/localpost/http/router.py b/localpost/http/router.py index 9a8dfe3..902aa6e 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -324,7 +324,7 @@ def as_wsgi(self): is the request-side concurrency layer (``thread_pool_handler`` does not apply). """ - from localpost.http.wsgi import to_wsgi # noqa: PLC0415 + from localpost.http.wsgi import to_wsgi return to_wsgi(self.as_handler()) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index b12fa3b..5b1dff9 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -366,7 +366,9 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) # the real bytes go on the wire via ``socket.sendfile`` below. # h11 supports placeholders in ``Data`` events for sendfile — see # ``h11.Data.data`` docstring. Type-checkers see ``data: bytes``. - placeholder_event = h11.Data(data=_SendfilePlaceholder(count)) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + placeholder_event = h11.Data( + data=_SendfilePlaceholder(count) + ) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] self.conn.parser.send_with_data_passthrough(placeholder_event) if self._pending_header_bytes is not None: self._sock_sendall(self._pending_header_bytes) diff --git a/localpost/openapi/_operation_core.py b/localpost/openapi/_operation_core.py index 9ca4575..bb43e26 100644 --- a/localpost/openapi/_operation_core.py +++ b/localpost/openapi/_operation_core.py @@ -309,7 +309,7 @@ def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape] # with at least one non-None success type (a bare ``-> None`` annotation # still means "204 / empty success", not 404), and only when the user # didn't already declare 404 explicitly via ``NotFound[X]``. - from localpost.openapi.results import NotFound as _NotFound # noqa: PLC0415 + from localpost.openapi.results import NotFound as _NotFound null_is_not_found = has_none and has_success and 404 not in seen_codes if null_is_not_found: diff --git a/localpost/openapi/adapters/__init__.py b/localpost/openapi/adapters/__init__.py index ca5f7da..4a08f81 100644 --- a/localpost/openapi/adapters/__init__.py +++ b/localpost/openapi/adapters/__init__.py @@ -162,17 +162,17 @@ def default_registry() -> AdapterRegistry: Order: pydantic (if importable), attrs (if both ``attrs`` and ``cattrs`` are importable), then msgspec as the catch-all. Cached. """ - from localpost.openapi.adapters._msgspec import MsgspecAdapter # noqa: PLC0415 + from localpost.openapi.adapters._msgspec import MsgspecAdapter adapters: list[TypeAdapter] = [] try: - from localpost.openapi.adapters._pydantic import PydanticAdapter # noqa: PLC0415 + from localpost.openapi.adapters._pydantic import PydanticAdapter adapters.append(PydanticAdapter()) except ImportError: pass try: - from localpost.openapi.adapters._attrs import AttrsAdapter # noqa: PLC0415 + from localpost.openapi.adapters._attrs import AttrsAdapter adapters.append(AttrsAdapter()) except ImportError: diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py index 7d85f69..f05240a 100644 --- a/localpost/openapi/aio/app.py +++ b/localpost/openapi/aio/app.py @@ -219,12 +219,12 @@ def service( """ asgi_app = self.asgi() if server == "uvicorn": - from localpost.hosting.services.uvicorn import uvicorn_server # noqa: PLC0415 + from localpost.hosting.services.uvicorn import uvicorn_server config.app = asgi_app return uvicorn_server(config) if server == "hypercorn": - from localpost.hosting.services.hypercorn import hypercorn_server # noqa: PLC0415 + from localpost.hosting.services.hypercorn import hypercorn_server return hypercorn_server(asgi_app, config) raise ValueError(f"Unknown ASGI server: {server!r}") @@ -319,7 +319,7 @@ def _ensure_async_middleware(mw: object) -> None: isn't enough — we additionally check that ``__call__`` is a coroutine function. """ - import inspect # noqa: PLC0415 + import inspect call = getattr(type(mw), "__call__", None) # noqa: B004 — inspecting unbound method, not testing callability if not inspect.iscoroutinefunction(call): diff --git a/localpost/openapi/aio/middleware.py b/localpost/openapi/aio/middleware.py index 2728bf2..a82e008 100644 --- a/localpost/openapi/aio/middleware.py +++ b/localpost/openapi/aio/middleware.py @@ -102,7 +102,7 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return self.root_contribution(doc, registry) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - from localpost.openapi._operation_core import build_responses # noqa: PLC0415 + from localpost.openapi._operation_core import build_responses # Parameters / requestBody come from the resolver factories. for _name, param, factory in self.arg_factories: @@ -157,8 +157,8 @@ def async_op_middleware(fn: Callable[..., Awaitable[Any]]) -> _AsyncFunctionMidd f"(use @op_middleware for sync middlewares)" ) - from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 - from localpost.openapi.aio._ctx import AsyncHTTPReqCtx as _AsyncCtx # noqa: PLC0415 + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes + from localpost.openapi.aio._ctx import AsyncHTTPReqCtx as _AsyncCtx call_next_param = _identify_call_next_param(fn) sig, runtime, factories = build_arg_resolvers( diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 2cb341d..4f05105 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -240,7 +240,7 @@ def as_wsgi(self): :func:`localpost.http.check_cancelled` is a no-op (no socket handle inside the WSGI app). """ - from localpost.http.wsgi import to_wsgi # noqa: PLC0415 + from localpost.http.wsgi import to_wsgi return to_wsgi(self._build_router_handler()) diff --git a/localpost/openapi/middleware.py b/localpost/openapi/middleware.py index cbc7467..29a0c3c 100644 --- a/localpost/openapi/middleware.py +++ b/localpost/openapi/middleware.py @@ -160,7 +160,7 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return self.root_contribution(doc, registry) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - from localpost.openapi._operation_core import build_responses # noqa: PLC0415 + from localpost.openapi._operation_core import build_responses # Parameters / requestBody come from the resolver factories. for _name, param, factory in self.arg_factories: @@ -210,8 +210,8 @@ def op_middleware(fn: Callable[..., Any]) -> _FunctionMiddleware: the operation already declared). A bare ``OpResult`` member of the return union is the passthrough sentinel and contributes nothing. """ - from localpost.http import HTTPReqCtx # noqa: PLC0415 - from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + from localpost.http import HTTPReqCtx + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes call_next_param = _identify_call_next_param(fn) sig, runtime, factories = build_arg_resolvers( diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py index 80b26a3..e8e8b1c 100644 --- a/localpost/openapi/sse.py +++ b/localpost/openapi/sse.py @@ -77,7 +77,7 @@ def format_data_field(payload: object, adapters: "AdapterRegistry | None" = None elif isinstance(payload, (bytes, bytearray, memoryview)): text = bytes(payload).decode("utf-8") else: - from localpost.openapi.adapters import default_registry # noqa: PLC0415 + from localpost.openapi.adapters import default_registry registry = adapters or default_registry() body, _ct = registry.for_value(payload).encode(payload) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index b5a7b7b..5e9988a 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -43,7 +43,7 @@ @final @dc.dataclass() class Task( - Generic[T, R], # noqa: UP046 — see TypeVar comment above + Generic[T, R], AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], ): name: str @@ -130,7 +130,7 @@ def __call__(self, *args, **kwargs) -> Trigger[T]: return self.tf(*args, **kwargs) def __truediv__(self, middleware: TriggerFactoryMiddleware[T, T2]) -> ScheduledTaskTemplate[T2]: - from ._trigger import trigger_factory_middleware # noqa: PLC0415 + from ._trigger import trigger_factory_middleware return self // trigger_factory_middleware(middleware) @@ -198,7 +198,7 @@ async def __call__(self, lt: ServiceLifetime) -> None: type TriggerFactoryDecorator[T, T2] = Callable[[TriggerFactory[T]], TriggerFactory[T2]] -def scheduled_task( # noqa: UP047 — see TypeVar comment above +def scheduled_task( tf: TriggerFactory[T], /, *, name: str | None = None ) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: """ diff --git a/tests/hosting/rsgi.py b/tests/hosting/rsgi.py index 03c7931..9a8ff56 100644 --- a/tests/hosting/rsgi.py +++ b/tests/hosting/rsgi.py @@ -222,7 +222,7 @@ async def handler(ctx: AsyncHTTPReqCtx) -> None: await _drive_shutdown(app) async def test_accepts_http_async_app(self) -> None: - from localpost.openapi import HttpAsyncApp # noqa: PLC0415 + from localpost.openapi import HttpAsyncApp oapi_app = HttpAsyncApp(openapi_path=None, docs_path=None) diff --git a/tests/http/_integration_app.py b/tests/http/_integration_app.py index ccebf5b..1d3fb78 100644 --- a/tests/http/_integration_app.py +++ b/tests/http/_integration_app.py @@ -20,7 +20,7 @@ def _build_handler(): mode = os.environ.get("LP_TEST_MODE", "router") if mode == "router": - from localpost.http import ( # noqa: PLC0415 + from localpost.http import ( HTTPReqCtx, Response, Routes, @@ -56,7 +56,7 @@ def _hello(ctx: HTTPReqCtx) -> None: return routes.build().as_handler() if mode == "wsgi": - from localpost.http import wrap_wsgi # noqa: PLC0415 + from localpost.http import wrap_wsgi def wsgi_app(environ, start_response): path = environ.get("PATH_INFO", "/") @@ -70,9 +70,9 @@ def wsgi_app(environ, start_response): return wrap_wsgi(wsgi_app) if mode == "flask": - from flask import Flask # noqa: PLC0415 + from flask import Flask - from localpost.http.flask import flask_handler # noqa: PLC0415 + from localpost.http.flask import flask_handler flask_app = Flask(__name__) @@ -94,10 +94,10 @@ def hello(name: str): def _main() -> int: logging.basicConfig(level=logging.INFO) - from localpost.hosting import run, service # noqa: PLC0415 - from localpost.hosting.middleware import shutdown_on_signal # noqa: PLC0415 - from localpost.http import ServerConfig, http_server, thread_pool_handler # noqa: PLC0415 - from localpost.threadtools import WorkerExecutor # noqa: PLC0415 + from localpost.hosting import run, service + from localpost.hosting.middleware import shutdown_on_signal + from localpost.http import ServerConfig, http_server, thread_pool_handler + from localpost.threadtools import WorkerExecutor # Honor LP_TEST_BACKEND so tests can pin asyncio vs trio. backend = os.environ.get("LP_TEST_BACKEND", "asyncio") diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index 36a4140..6470f0c 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -39,7 +39,7 @@ def test_buffered_post_body_handler(serve_backend_in_thread, http_backend): # ``parser.feed_data`` callback chain — only safe via a worker # pool. The pooled equivalent lives in ``tests/http/service.py``; # here we just exercise the h11 path. - import pytest as _pytest # noqa: PLC0415 + import pytest as _pytest _pytest.skip("httptools requires worker-pool composition for body reads") @@ -113,7 +113,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_expect_100_continue(serve_backend_in_thread, http_backend): if http_backend == "httptools": - import pytest as _pytest # noqa: PLC0415 + import pytest as _pytest _pytest.skip("httptools requires worker-pool composition for body reads") @@ -254,7 +254,7 @@ def test_user_supplied_chunked_te_frames_chunks(serve_backend_in_thread): auto-frame branch, so user-supplied TE bypassed framing and the wire was malformed. """ - import socket # noqa: PLC0415 + import socket def handler(ctx: HTTPReqCtx) -> None: ctx.stream( diff --git a/tests/http/compress.py b/tests/http/compress.py index 8359e85..565b993 100644 --- a/tests/http/compress.py +++ b/tests/http/compress.py @@ -409,7 +409,7 @@ class TestGzipBytes: """ def test_actual_gzip_bytes_on_wire(self, serve_backend_in_thread): - import socket # noqa: PLC0415 + import socket body = b"hello world " * 1024 h = compress_handler(_make_handler(b"text/plain", body), algorithms=("gzip",)) @@ -625,8 +625,8 @@ class TestStreamingFlush: """ def test_first_event_decodes_before_stream_ends(self, serve_backend_in_thread): - import socket # noqa: PLC0415 - import threading # noqa: PLC0415 + import socket + import threading gate = threading.Event() diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 1697121..881961e 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -25,7 +25,7 @@ def http_backend(request) -> Backend: name: Backend = request.param if name == "httptools": try: - import httptools # noqa: F401, PLC0415 + import httptools # noqa: F401 except ImportError as e: pytest.skip(str(e)) return name diff --git a/tests/http/rsgi.py b/tests/http/rsgi.py index 51055b1..014920b 100644 --- a/tests/http/rsgi.py +++ b/tests/http/rsgi.py @@ -144,7 +144,7 @@ def _build_ctx(*, body: bytes = b"", proto: _FakeProto | None = None) -> _RSGIRe behaves as if the bridge had read exactly those bytes from upstream and observed EOM. """ - from localpost.http._types import Request # noqa: PLC0415 + from localpost.http._types import Request request = Request(b"GET", b"/", b"/", b"", []) @@ -274,7 +274,7 @@ async def test_sendfile_uses_response_file_range_when_path_present(self, tmp_pat @pytest.mark.anyio async def test_sendfile_falls_back_to_stream_when_no_path(self) -> None: - import io # noqa: PLC0415 + import io proto = _FakeProto() ctx = _build_ctx(proto=proto) diff --git a/tests/http/wsgi_to.py b/tests/http/wsgi_to.py index 957beb1..e93bee1 100644 --- a/tests/http/wsgi_to.py +++ b/tests/http/wsgi_to.py @@ -317,7 +317,7 @@ def test_router_match_with_path_args(self): @routes.get("/items/{id}") def get_item(ctx: HTTPReqCtx): - from localpost.http.router import route_match # noqa: PLC0415 + from localpost.http.router import route_match match = route_match(ctx) body = match.path_args["id"].encode() diff --git a/tests/openapi/aio_app.py b/tests/openapi/aio_app.py index 39387dc..9135aab 100644 --- a/tests/openapi/aio_app.py +++ b/tests/openapi/aio_app.py @@ -159,8 +159,8 @@ def foo() -> str: return "ok" def test_sync_middleware_rejected_at_construction(self): - from localpost.openapi import OpMiddleware # noqa: PLC0415 - from localpost.openapi.middleware import _FunctionMiddleware, op_middleware # noqa: PLC0415 + from localpost.openapi import OpMiddleware + from localpost.openapi.middleware import _FunctionMiddleware, op_middleware @op_middleware def sync_mw(ctx, call_next) -> OpResult: # type: ignore[no-untyped-def] From 3de4ae6813540f421b9ecb918b775eb30344074c Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 00:26:45 +0400 Subject: [PATCH 271/286] fix(ci): unblock lint-types, signal tests, FT job, flaky pool test - pyproject: add dev-tools group with ruff/ty/basedpyright so `uv sync --all-groups` installs the lint binaries the workflow runs. - hosting/middleware: install the signal receiver via tg.start (with task_status.started) before the wrapped service runs, so a signal arriving right after READY can't kill the process before open_signal_receiver registers a handler. - ci/tests-free-threaded: add the pure-Python optional groups (dev-http, dev-sentry, click extra) so tests/http and tests/hosting/services collect; ignore tests/openapi (needs the msgspec C ext, not part of the FT subset). - tests/http: gate test_many_requests_served_from_worker_threads on a Barrier so two concurrent in-flight requests force a second worker; the previous handler returned fast enough that one worker could serve all 10 sequentially. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 11 ++-- localpost/hosting/middleware.py | 15 ++++-- pyproject.toml | 6 +++ tests/http/service.py | 9 ++++ uv.lock | 90 ++++++++++++++++++++++++++++++++- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80db8ca..ba2f134 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -89,13 +89,18 @@ jobs: # --no-default-groups + narrow opt-in: avoid C extensions that # re-enable the GIL on 3.14t (httptools 0.7.x, grpcio, granian, # setproctitle). Covers the pure-Python core that the FT marking - # actually claims. + # actually claims. ``dev-http`` (httpx, flask), ``dev-sentry`` + # (sentry-sdk), and the ``click`` extra are pure Python — pulled in so + # tests/http and tests/hosting/services collect cleanly. run: > uv sync --python 3.14t --no-default-groups --group tests --group tests-unit - --extra http --extra scheduler --extra cron + --group dev-http --group dev-sentry + --extra http --extra scheduler --extra cron --extra click - name: pytest timeout-minutes: 5 env: PYTHON_GIL: "0" # fail loudly if any imported C ext re-enables the GIL - run: uv run --python 3.14t pytest -v -m "not integration" + # Skip ``tests/openapi`` — those tests require the ``openapi`` extra + # (msgspec C extension) which isn't covered by the FT-clean subset. + run: uv run --python 3.14t pytest -v -m "not integration" --ignore=tests/openapi diff --git a/localpost/hosting/middleware.py b/localpost/hosting/middleware.py index 85902b4..b3c2f7b 100644 --- a/localpost/hosting/middleware.py +++ b/localpost/hosting/middleware.py @@ -1,7 +1,8 @@ from collections.abc import Awaitable, Callable from functools import wraps -from anyio import move_on_after, open_signal_receiver +from anyio import TASK_STATUS_IGNORED, move_on_after, open_signal_receiver +from anyio.abc import TaskStatus from localpost._utils import HANDLED_SIGNALS from localpost.hosting._host import ServiceLifetime, ServiceLifetimeView, logger @@ -28,8 +29,12 @@ def wrapper(lt: ServiceLifetime) -> Awaitable[None]: return decorator -async def _handle_signals(h: ServiceLifetimeView, signals): +async def _handle_signals(h: ServiceLifetimeView, signals, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED): with open_signal_receiver(*signals) as received: + # Signal handler is now installed. Notify the parent so it can hand + # control to the wrapped service — without this, the service can race + # ahead and a signal arriving early would kill the process. + task_status.started() async for _ in received: # First Ctrl+C (or other termination method) if not h.shutting_down: @@ -45,9 +50,9 @@ async def _handle_signals(h: ServiceLifetimeView, signals): def shutdown_on_signal(*signals) -> Callable[[ServiceF], ServiceF]: def decorator(func: ServiceF) -> ServiceF: @wraps(func) - def wrapper(lt: ServiceLifetime) -> Awaitable[None]: - lt.tg.start_soon(_handle_signals, lt.view, signals or HANDLED_SIGNALS) - return func(lt) + async def wrapper(lt: ServiceLifetime) -> None: + await lt.tg.start(_handle_signals, lt.view, signals or HANDLED_SIGNALS) + await func(lt) return wrapper diff --git a/pyproject.toml b/pyproject.toml index 83c529b..d13b16f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,12 @@ dev = [ "vulture ~=2.14", ] +dev-tools = [ + # Linters / type checkers used by ``just check``, ``just format``, and CI. + "ruff ~=0.15", + "ty ~=0.0.34", + "basedpyright ~=1.39", +] dev-hosting-services = [ "uvicorn ~=0.30", "hypercorn ~=0.17", diff --git a/tests/http/service.py b/tests/http/service.py index b8b5811..4775c75 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -619,7 +619,16 @@ class TestDispatchLoad: async def test_many_requests_served_from_worker_threads(self, free_port): cfg = ServerConfig(host="127.0.0.1", port=free_port) + # Hold each handler in flight until the second one arrives so the pool + # is forced to spawn a second worker. Without this, the single worker + # can finish each request fast enough to handle all 10 sequentially. + barrier = threading.Barrier(2, timeout=2.0) + def handler(ctx: HTTPReqCtx): + try: + barrier.wait() + except threading.BrokenBarrierError: + pass tid = str(threading.get_ident()).encode() ctx.complete( Response(status_code=200, headers=[(b"content-length", str(len(tid)).encode())]), diff --git a/uv.lock b/uv.lock index ab594d5..4b2fe66 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "basedpyright" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -761,7 +773,7 @@ wheels = [ [[package]] name = "localpost" -version = "0.6.0.dev0" +version = "0.6.0b1" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -840,6 +852,11 @@ dev-otel = [ dev-sentry = [ { name = "sentry-sdk" }, ] +dev-tools = [ + { name = "basedpyright" }, + { name = "ruff" }, + { name = "ty" }, +] dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, @@ -918,6 +935,11 @@ dev-openapi = [ ] dev-otel = [{ name = "opentelemetry-exporter-otlp" }] dev-sentry = [{ name = "sentry-sdk", specifier = "~=2.51" }] +dev-tools = [ + { name = "basedpyright", specifier = "~=1.39" }, + { name = "ruff", specifier = "~=0.15" }, + { name = "ty", specifier = "~=0.0.34" }, +] dev-types = [ { name = "types-croniter" }, { name = "types-grpcio" }, @@ -1058,6 +1080,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.41.1" @@ -1526,6 +1564,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + [[package]] name = "sentry-sdk" version = "2.58.0" @@ -1708,6 +1771,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, ] +[[package]] +name = "ty" +version = "0.0.35" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/53/440e7b1212c4b0abbd4adb7aed93f4971aa1f8dca386ac5515930afa9172/ty-0.0.35.tar.gz", hash = "sha256:8375c240ab38138a19db07996c9808fb7a92047c1492e1ce587c2ef5112ad3a9", size = 5629237, upload-time = "2026-05-10T18:25:17.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/84/19662ee881675815b7fafff940a365be1985730465afd9b75cb2edd5f8b3/ty-0.0.35-py3-none-linux_armv6l.whl", hash = "sha256:85ae1e59b9fb0b40e9d84fe61b29653c5f2f5e78b487ece371a7a38c20c781cf", size = 11198741, upload-time = "2026-05-10T18:24:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b", size = 10948304, upload-time = "2026-05-10T18:24:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc", size = 10407413, upload-time = "2026-05-10T18:24:37.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/23/fda6fae8a81ce0cb5f24cdfe63260e110c7af8844e31fa07d1e6e8ef0232/ty-0.0.35-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7afbcfc61904b7e82e7fe1a1db832a40d8f01e69dee1775f6594e552980536c", size = 10932614, upload-time = "2026-05-10T18:24:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/72/3d/b98d8d4aa1a5ed6daaf15864e838f605ca7b1e8b93b7e17b96ed4bc4dfed/ty-0.0.35-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b61498cc3e4178031c079951257fbdb209a891b4feb10ad6c40f615a51846f41", size = 10962982, upload-time = "2026-05-10T18:24:44.88Z" }, + { url = "https://files.pythonhosted.org/packages/18/c4/2881aad71bf6fb2f8df17fc8e4bc89e904e54490a3ee747b5ef73f98ac85/ty-0.0.35-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573b1eacda349fc8dba0d767b41631c3a6f66412363127c5bf2b1b40a1d898d2", size = 11476274, upload-time = "2026-05-10T18:24:42.4Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/7717650adaeaddd23eea70470e2c26d3f0b9b18fdc7f26ec9552d6001f17/ty-0.0.35-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7209746158d6393c1040aa64b3ca29622e212ea7d8bae22ba50dbcbb4f96f0a", size = 12012027, upload-time = "2026-05-10T18:25:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/22/c9/1a16cb4aab6f4707d8f550772e91abc26d1c8870f19b5e2453ad10bb8209/ty-0.0.35-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4466a1470aa4418d49a9aa45d9da7de42033addd0a2837c5b2b0eb71d3c2bcd3", size = 11648894, upload-time = "2026-05-10T18:25:12.44Z" }, + { url = "https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b", size = 11560482, upload-time = "2026-05-10T18:25:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/a5fb11227d5cc4ac3f29a115d8c8bc817578e8ef6907d1e4c914ddbf45ee/ty-0.0.35-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:34b219250736c989b2670a03782c61315f523f3a2be37f1f90b1207e2212c188", size = 11718495, upload-time = "2026-05-10T18:24:54.12Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cb/e92e4317388b6d1fd821a46941b448a8a1ff0bf13e22147c5167d8fa1b00/ty-0.0.35-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88e2ac497decc0940ef1a07571dee8a746112a93a09cdc7f8bca0099752e2e05", size = 10900815, upload-time = "2026-05-10T18:25:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4f/03bd87388a92567f262f35ac64e10d2be047d258f2dfcf1405f500fa2b90/ty-0.0.35-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:02cae51b53e6ec17d5d827ff1a3a76fd119705b56a92156e04399eda6e911596", size = 10998051, upload-time = "2026-05-10T18:25:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/b4/60/6edbc375ee6073973200096168f644e1081e5e55a7d42596826465b275de/ty-0.0.35-py3-none-musllinux_1_2_i686.whl", hash = "sha256:11871d730c9400d899ac0b9f3d660ed2e7e433377c8725549f8250a36a7f2620", size = 11148910, upload-time = "2026-05-10T18:24:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b1/a845d2066ed521c477450f436d4bd353d107e7c02dd6536a485944aaf892/ty-0.0.35-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ad0a2f0530d0933dcc99ad36ac556c63e384ea72ab9a18d23ad2e2c9fd61c73", size = 11671005, upload-time = "2026-05-10T18:24:56.223Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/1d5912a54fb66b2f95ac828ae61d422ef5afeae1263e4d231e40796c229f/ty-0.0.35-py3-none-win32.whl", hash = "sha256:0e25d63ec4ab116e7f6757e44d16ca9216bca679d19ecc36d119cf80faada61a", size = 10481096, upload-time = "2026-05-10T18:24:39.976Z" }, + { url = "https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl", hash = "sha256:6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5", size = 11555691, upload-time = "2026-05-10T18:25:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fb/59325221bce52f6e833d6865ce8360ef7d5e1e21151b38df6dc77c4327a7/ty-0.0.35-py3-none-win_arm64.whl", hash = "sha256:619c52c0fb2aa21961a848a1995135ad3b6d0a9aa54da0194e60f679cc200e13", size = 10925457, upload-time = "2026-05-10T18:25:10.352Z" }, +] + [[package]] name = "types-croniter" version = "6.2.2.20260408" From a9681e1a72d6d7337c1d601b4845e00bcc131bcb Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 00:38:57 +0400 Subject: [PATCH 272/286] fix(ci): restore PLC0415 noqa stripped by pre-commit, skip more FT tests - Re-add the ``# noqa: PLC0415`` directives the pre-commit.ci bot stripped: v0.9.7 of ruff (pinned in .pre-commit-config) doesn't know the rule and treats the comments as unused, but v0.15 (the workflow's ruff) still raises PLC0415, so the GH Actions lint-types job fails on the bare lazy-import sites. Bump the pre-commit ruff to v0.15.12 to align with dev-tools so the next autoupdate won't strip them again. - tests-free-threaded: gate three httptools-specific tests in tests/http/app.py on pytest.importorskip("httptools") so they skip cleanly on the FT subset, and --ignore tests/hosting/rsgi.py since it drives lifecycle hooks that pull in granian's mocks but don't run cleanly on 3.14t. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 9 ++++++--- .pre-commit-config.yaml | 2 +- localpost/_utils.py | 4 ++-- localpost/hosting/rsgi.py | 2 +- localpost/hosting/services/hypercorn.py | 4 ++-- localpost/http/_base.py | 6 +++--- localpost/http/_service.py | 4 ++-- localpost/http/router.py | 2 +- localpost/http/server_h11.py | 4 +--- localpost/openapi/_operation_core.py | 2 +- localpost/openapi/adapters/__init__.py | 6 +++--- localpost/openapi/aio/app.py | 6 +++--- localpost/openapi/aio/middleware.py | 6 +++--- localpost/openapi/app.py | 2 +- localpost/openapi/middleware.py | 6 +++--- localpost/openapi/sse.py | 2 +- localpost/scheduler/_scheduler.py | 6 +++--- tests/http/app.py | 3 +++ 18 files changed, 40 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba2f134..6c5be52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -101,6 +101,9 @@ jobs: timeout-minutes: 5 env: PYTHON_GIL: "0" # fail loudly if any imported C ext re-enables the GIL - # Skip ``tests/openapi`` — those tests require the ``openapi`` extra - # (msgspec C extension) which isn't covered by the FT-clean subset. - run: uv run --python 3.14t pytest -v -m "not integration" --ignore=tests/openapi + # Skip ``tests/openapi`` (msgspec C ext) and ``tests/hosting/rsgi.py`` + # (exercises Granian hooks; granian C ext not in the FT subset). + run: > + uv run --python 3.14t pytest -v -m "not integration" + --ignore=tests/openapi + --ignore=tests/hosting/rsgi.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5eb2385..55c62cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.15.12 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/localpost/_utils.py b/localpost/_utils.py index d0a76b6..ec13c9e 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -202,7 +202,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: return value if isinstance(value, str): try: - import pytimeparse2 + import pytimeparse2 # noqa: PLC0415 use_dateutil = pytimeparse2.HAS_RELITIVE_TIMEDELTA try: @@ -223,7 +223,7 @@ def ensure_td(value: timedelta | str, /) -> timedelta: def td_str(td: timedelta, /) -> str: try: - from humanize import precisedelta + from humanize import precisedelta # noqa: PLC0415 # 23 seconds or 0.24 seconds return precisedelta(td) diff --git a/localpost/hosting/rsgi.py b/localpost/hosting/rsgi.py index 11561b9..b481c62 100644 --- a/localpost/hosting/rsgi.py +++ b/localpost/hosting/rsgi.py @@ -170,7 +170,7 @@ def _resolve_handler(handler: AsyncRequestHandler | HttpAsyncApp) -> AsyncReques """Accept either a raw :data:`AsyncRequestHandler` or an :class:`HttpAsyncApp` (compile its route handler on demand).""" # Lazy import — hosting shouldn't pull openapi at import time. - from localpost.openapi.aio.app import HttpAsyncApp as _App + from localpost.openapi.aio.app import HttpAsyncApp as _App # noqa: PLC0415 if isinstance(handler, _App): return handler._build_async_handler() diff --git a/localpost/hosting/services/hypercorn.py b/localpost/hosting/services/hypercorn.py index b251761..0c217a2 100644 --- a/localpost/hosting/services/hypercorn.py +++ b/localpost/hosting/services/hypercorn.py @@ -15,9 +15,9 @@ def run(sl: ServiceLifetime) -> Awaitable[None]: # ``asyncio`` ``serve`` modules and we pick one based on the running # event loop. A top-level import would force one backend at import time. if current_async_library() == "trio": - from hypercorn.trio import serve + from hypercorn.trio import serve # noqa: PLC0415 else: - from hypercorn.asyncio import serve # type: ignore[assignment] + from hypercorn.asyncio import serve # type: ignore[assignment] # noqa: PLC0415 observed_app = report_started(sl.started, app) return serve(observed_app, config, shutdown_trigger=sl.shutting_down.wait) diff --git a/localpost/http/_base.py b/localpost/http/_base.py index 63a7b22..e26404a 100644 --- a/localpost/http/_base.py +++ b/localpost/http/_base.py @@ -479,7 +479,7 @@ def _native_stream(ctx: _NativeReqCtx, response: Response, chunks: Iterator[byte available" and silently skipped. """ # Local import to avoid the import cycle (cancel imports HTTPReqCtx). - from localpost.http._cancel import RequestCancelled, check_cancelled + from localpost.http._cancel import RequestCancelled, check_cancelled # noqa: PLC0415 ctx.start_response(response) try: @@ -1230,10 +1230,10 @@ def start_http_server(config: ServerConfig, handler: RequestHandler, /) -> Abstr """ backend = config.backend if backend == "h11": - from localpost.http.server_h11 import HTTPConn + from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 elif backend == "httptools": try: - from localpost.http.server_httptools import HTTPConn + from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 except ImportError as e: raise ImportError( "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" diff --git a/localpost/http/_service.py b/localpost/http/_service.py index 53d729a..8647cc0 100644 --- a/localpost/http/_service.py +++ b/localpost/http/_service.py @@ -62,12 +62,12 @@ def _pick_conn_factory(backend: str): acceptor topology, where we wire :class:`RoundRobinAcceptor` directly instead of going through ``start_http_server``.""" if backend == "h11": - from localpost.http.server_h11 import HTTPConn + from localpost.http.server_h11 import HTTPConn # noqa: PLC0415 return HTTPConn if backend == "httptools": try: - from localpost.http.server_httptools import HTTPConn + from localpost.http.server_httptools import HTTPConn # noqa: PLC0415 except ImportError as e: raise ImportError( "httptools backend requires the [http-fast] extra (pip install localpost[http-fast])" diff --git a/localpost/http/router.py b/localpost/http/router.py index 902aa6e..9a8dfe3 100644 --- a/localpost/http/router.py +++ b/localpost/http/router.py @@ -324,7 +324,7 @@ def as_wsgi(self): is the request-side concurrency layer (``thread_pool_handler`` does not apply). """ - from localpost.http.wsgi import to_wsgi + from localpost.http.wsgi import to_wsgi # noqa: PLC0415 return to_wsgi(self.as_handler()) diff --git a/localpost/http/server_h11.py b/localpost/http/server_h11.py index 5b1dff9..b12fa3b 100644 --- a/localpost/http/server_h11.py +++ b/localpost/http/server_h11.py @@ -366,9 +366,7 @@ def sendfile(self, response: Response, file: BinaryIO, offset: int, count: int) # the real bytes go on the wire via ``socket.sendfile`` below. # h11 supports placeholders in ``Data`` events for sendfile — see # ``h11.Data.data`` docstring. Type-checkers see ``data: bytes``. - placeholder_event = h11.Data( - data=_SendfilePlaceholder(count) - ) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] + placeholder_event = h11.Data(data=_SendfilePlaceholder(count)) # ty: ignore[invalid-argument-type] # type: ignore[arg-type] self.conn.parser.send_with_data_passthrough(placeholder_event) if self._pending_header_bytes is not None: self._sock_sendall(self._pending_header_bytes) diff --git a/localpost/openapi/_operation_core.py b/localpost/openapi/_operation_core.py index bb43e26..9ca4575 100644 --- a/localpost/openapi/_operation_core.py +++ b/localpost/openapi/_operation_core.py @@ -309,7 +309,7 @@ def extract_response_shapes(return_annotation: Any) -> tuple[list[ResponseShape] # with at least one non-None success type (a bare ``-> None`` annotation # still means "204 / empty success", not 404), and only when the user # didn't already declare 404 explicitly via ``NotFound[X]``. - from localpost.openapi.results import NotFound as _NotFound + from localpost.openapi.results import NotFound as _NotFound # noqa: PLC0415 null_is_not_found = has_none and has_success and 404 not in seen_codes if null_is_not_found: diff --git a/localpost/openapi/adapters/__init__.py b/localpost/openapi/adapters/__init__.py index 4a08f81..ca5f7da 100644 --- a/localpost/openapi/adapters/__init__.py +++ b/localpost/openapi/adapters/__init__.py @@ -162,17 +162,17 @@ def default_registry() -> AdapterRegistry: Order: pydantic (if importable), attrs (if both ``attrs`` and ``cattrs`` are importable), then msgspec as the catch-all. Cached. """ - from localpost.openapi.adapters._msgspec import MsgspecAdapter + from localpost.openapi.adapters._msgspec import MsgspecAdapter # noqa: PLC0415 adapters: list[TypeAdapter] = [] try: - from localpost.openapi.adapters._pydantic import PydanticAdapter + from localpost.openapi.adapters._pydantic import PydanticAdapter # noqa: PLC0415 adapters.append(PydanticAdapter()) except ImportError: pass try: - from localpost.openapi.adapters._attrs import AttrsAdapter + from localpost.openapi.adapters._attrs import AttrsAdapter # noqa: PLC0415 adapters.append(AttrsAdapter()) except ImportError: diff --git a/localpost/openapi/aio/app.py b/localpost/openapi/aio/app.py index f05240a..7d85f69 100644 --- a/localpost/openapi/aio/app.py +++ b/localpost/openapi/aio/app.py @@ -219,12 +219,12 @@ def service( """ asgi_app = self.asgi() if server == "uvicorn": - from localpost.hosting.services.uvicorn import uvicorn_server + from localpost.hosting.services.uvicorn import uvicorn_server # noqa: PLC0415 config.app = asgi_app return uvicorn_server(config) if server == "hypercorn": - from localpost.hosting.services.hypercorn import hypercorn_server + from localpost.hosting.services.hypercorn import hypercorn_server # noqa: PLC0415 return hypercorn_server(asgi_app, config) raise ValueError(f"Unknown ASGI server: {server!r}") @@ -319,7 +319,7 @@ def _ensure_async_middleware(mw: object) -> None: isn't enough — we additionally check that ``__call__`` is a coroutine function. """ - import inspect + import inspect # noqa: PLC0415 call = getattr(type(mw), "__call__", None) # noqa: B004 — inspecting unbound method, not testing callability if not inspect.iscoroutinefunction(call): diff --git a/localpost/openapi/aio/middleware.py b/localpost/openapi/aio/middleware.py index a82e008..2728bf2 100644 --- a/localpost/openapi/aio/middleware.py +++ b/localpost/openapi/aio/middleware.py @@ -102,7 +102,7 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return self.root_contribution(doc, registry) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - from localpost.openapi._operation_core import build_responses + from localpost.openapi._operation_core import build_responses # noqa: PLC0415 # Parameters / requestBody come from the resolver factories. for _name, param, factory in self.arg_factories: @@ -157,8 +157,8 @@ def async_op_middleware(fn: Callable[..., Awaitable[Any]]) -> _AsyncFunctionMidd f"(use @op_middleware for sync middlewares)" ) - from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes - from localpost.openapi.aio._ctx import AsyncHTTPReqCtx as _AsyncCtx + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 + from localpost.openapi.aio._ctx import AsyncHTTPReqCtx as _AsyncCtx # noqa: PLC0415 call_next_param = _identify_call_next_param(fn) sig, runtime, factories = build_arg_resolvers( diff --git a/localpost/openapi/app.py b/localpost/openapi/app.py index 4f05105..2cb341d 100644 --- a/localpost/openapi/app.py +++ b/localpost/openapi/app.py @@ -240,7 +240,7 @@ def as_wsgi(self): :func:`localpost.http.check_cancelled` is a no-op (no socket handle inside the WSGI app). """ - from localpost.http.wsgi import to_wsgi + from localpost.http.wsgi import to_wsgi # noqa: PLC0415 return to_wsgi(self._build_router_handler()) diff --git a/localpost/openapi/middleware.py b/localpost/openapi/middleware.py index 29a0c3c..cbc7467 100644 --- a/localpost/openapi/middleware.py +++ b/localpost/openapi/middleware.py @@ -160,7 +160,7 @@ def contribute_root(self, doc: spec.OpenAPI, registry: SchemaRegistry, /) -> spe return self.root_contribution(doc, registry) def contribute_operation(self, op: spec.Operation, registry: SchemaRegistry, /) -> spec.Operation: - from localpost.openapi._operation_core import build_responses + from localpost.openapi._operation_core import build_responses # noqa: PLC0415 # Parameters / requestBody come from the resolver factories. for _name, param, factory in self.arg_factories: @@ -210,8 +210,8 @@ def op_middleware(fn: Callable[..., Any]) -> _FunctionMiddleware: the operation already declared). A bare ``OpResult`` member of the return union is the passthrough sentinel and contributes nothing. """ - from localpost.http import HTTPReqCtx - from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes + from localpost.http import HTTPReqCtx # noqa: PLC0415 + from localpost.openapi._operation_core import build_arg_resolvers, extract_response_shapes # noqa: PLC0415 call_next_param = _identify_call_next_param(fn) sig, runtime, factories = build_arg_resolvers( diff --git a/localpost/openapi/sse.py b/localpost/openapi/sse.py index e8e8b1c..80b26a3 100644 --- a/localpost/openapi/sse.py +++ b/localpost/openapi/sse.py @@ -77,7 +77,7 @@ def format_data_field(payload: object, adapters: "AdapterRegistry | None" = None elif isinstance(payload, (bytes, bytearray, memoryview)): text = bytes(payload).decode("utf-8") else: - from localpost.openapi.adapters import default_registry + from localpost.openapi.adapters import default_registry # noqa: PLC0415 registry = adapters or default_registry() body, _ct = registry.for_value(payload).encode(payload) diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index 5e9988a..badb478 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -43,7 +43,7 @@ @final @dc.dataclass() class Task( - Generic[T, R], + Generic[T, R], # noqa: UP046 AbstractAsyncContextManager[Callable[[T], Awaitable[None]]], ): name: str @@ -130,7 +130,7 @@ def __call__(self, *args, **kwargs) -> Trigger[T]: return self.tf(*args, **kwargs) def __truediv__(self, middleware: TriggerFactoryMiddleware[T, T2]) -> ScheduledTaskTemplate[T2]: - from ._trigger import trigger_factory_middleware + from ._trigger import trigger_factory_middleware # noqa: PLC0415 return self // trigger_factory_middleware(middleware) @@ -198,7 +198,7 @@ async def __call__(self, lt: ServiceLifetime) -> None: type TriggerFactoryDecorator[T, T2] = Callable[[TriggerFactory[T]], TriggerFactory[T2]] -def scheduled_task( +def scheduled_task( # noqa: UP047 tf: TriggerFactory[T], /, *, name: str | None = None ) -> Callable[[TaskHandler[T, R] | Task[T, R]], _ScheduledTask[T, R]]: """ diff --git a/tests/http/app.py b/tests/http/app.py index cacff46..a8af2b9 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -552,6 +552,7 @@ async def test_streaming_route_body_across_multiple_recvs_httptools(self, free_p writes (so the parser only sees headers in the selector's first feed). The worker must pull the rest through the parser via ``ctx.receive`` → ``sock.recv`` → ``feed_data`` → ``on_body``.""" + pytest.importorskip("httptools") captured: dict = {} app = HttpApp() @@ -601,6 +602,7 @@ def hit() -> bytes: async def test_httptools_deferred_streaming_dispatch_sees_current_feed_body(self, free_port): """httptools starts streaming work after callbacks from the current feed finish.""" + pytest.importorskip("httptools") captured: dict = {} def handler(ctx: HTTPReqCtx): @@ -653,6 +655,7 @@ async def test_streaming_then_keep_alive_request_httptools(self, free_port): """After a streaming POST, the same connection serves a regular GET. Verifies parser state is consistent after streaming — the next request parses cleanly without any reset / replace.""" + pytest.importorskip("httptools") captured: list[str] = [] app = HttpApp() From ceb3714ea5e13d6b8777a558478d7ac7a9e87045 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 00:51:52 +0400 Subject: [PATCH 273/286] fix(tests): harden flaky worker-pool test, skip more httptools-only tests on FT - tests/http/app.py::TestWorkerPool::test_handlers_run_on_worker_threads now gates each handler on a 2-thread Barrier so two requests run concurrently and force a second worker; without the barrier the pool's single worker can serve all 8 requests sequentially (race observed on 3.12). - Add ``pytest.importorskip("httptools")`` to tests/http/service.py::TestAcceptorTopology::test_serves_requests_httptools so it skips on the FT subset (httptools isn't installed because 0.7.x re-enables the GIL on 3.14t). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/http/app.py | 8 ++++++++ tests/http/service.py | 1 + 2 files changed, 9 insertions(+) diff --git a/tests/http/app.py b/tests/http/app.py index a8af2b9..4f5acda 100644 --- a/tests/http/app.py +++ b/tests/http/app.py @@ -405,11 +405,19 @@ async def test_handlers_run_on_worker_threads(self, free_port): not the selector thread.""" seen: list[int] = [] lock = threading.Lock() + # Hold each in-flight handler until a second one arrives, forcing + # the pool to spawn a second worker. Otherwise a single fast worker + # may serve all 8 requests sequentially (race seen on 3.12). + barrier = threading.Barrier(2, timeout=2.0) app = HttpApp() @app.get("/tid") def tid(): + try: + barrier.wait() + except threading.BrokenBarrierError: + pass with lock: seen.append(threading.get_ident()) return "x" diff --git a/tests/http/service.py b/tests/http/service.py index 4775c75..cc8cd02 100644 --- a/tests/http/service.py +++ b/tests/http/service.py @@ -413,6 +413,7 @@ async def test_serves_requests_httptools(self, free_port): ``RoundRobinAcceptor.__call__``) and *driven* on the worker thread. Smoke-tests that the parser instance survives the move. """ + pytest.importorskip("httptools") cfg = ServerConfig(host="127.0.0.1", port=free_port, backend="httptools") async with serve(http_server(cfg, _handler_200(b"hi"), selectors=4, acceptor=True)) as lt: await lt.started From 6f42cea213f01ebf2bac4d146d80aee0f6900336 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 01:03:08 +0400 Subject: [PATCH 274/286] chore: rename FT job to 3.14t; drop MemoryStream wrapper for sonar - Rename the ``tests-free-threaded`` job to ``3.14t`` so the check name matches uv's interpreter naming. - Drop the ``MemoryStream[T]`` wrapper from ``localpost/_utils.py`` and inline ``anyio.create_memory_object_stream[T](...)`` at the two call sites. SonarCloud's analyzer reads PEP 695 ``class Foo[T]:`` subscription as instance ``__getitem__`` and false-positives on ``MemoryStream[X]``; anyio's own subscription pattern is recognised and the wrapper was a thin pass-through anyway. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 2 +- localpost/_utils.py | 10 ---------- localpost/scheduler/_cond.py | 4 ++-- localpost/scheduler/_scheduler.py | 5 ++--- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6c5be52..0d0bf96 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,7 +76,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - tests-free-threaded: + 3.14t: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/localpost/_utils.py b/localpost/_utils.py index ec13c9e..1fe3f5b 100644 --- a/localpost/_utils.py +++ b/localpost/_utils.py @@ -30,13 +30,11 @@ from anyio import ( CancelScope, CapacityLimiter, - create_memory_object_stream, create_task_group, from_thread, to_thread, ) from anyio.abc import TaskGroup -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream # Sentinel object, to indicate that a value is not set (see https://python-patterns.guide/python/sentinel-object) NOT_SET: Final = object() @@ -291,14 +289,6 @@ def sleep(i: timedelta | int | float | None, /) -> Coroutine[Any, Any, None]: return anyio.sleep(interval_sec) -# Thin generic-over-T wrapper around ``create_memory_object_stream`` so callers -# get parameterised stream pairs without touching ``cast`` themselves. -class MemoryStream[T]: - @staticmethod - def create(max_buffer_size: float = 0) -> tuple[MemoryObjectSendStream[T], MemoryObjectReceiveStream[T]]: - return create_memory_object_stream(max_buffer_size) - - class AsyncBackendConfig(TypedDict): backend: str backend_options: NotRequired[dict[str, Any]] diff --git a/localpost/scheduler/_cond.py b/localpost/scheduler/_cond.py index c9f0757..2eb08fc 100644 --- a/localpost/scheduler/_cond.py +++ b/localpost/scheduler/_cond.py @@ -12,6 +12,7 @@ BrokenResourceError, EndOfStream, WouldBlock, + create_memory_object_stream, create_task_group, get_cancelled_exc_class, ) @@ -19,7 +20,6 @@ from localpost._utils import ( TD_ZERO, EventView, - MemoryStream, Result, cancellable_from, ensure_td, @@ -36,7 +36,7 @@ @asynccontextmanager async def wait_trigger(time_spans: Iterable[timedelta], shutting_down: EventView): - events, events_reader = MemoryStream[None].create(0) + events, events_reader = create_memory_object_stream[None](0) @cancellable_from(shutting_down) # DO NOT cancel the main task group async def generate(): diff --git a/localpost/scheduler/_scheduler.py b/localpost/scheduler/_scheduler.py index badb478..f2e06b7 100644 --- a/localpost/scheduler/_scheduler.py +++ b/localpost/scheduler/_scheduler.py @@ -8,13 +8,12 @@ from contextlib import AbstractAsyncContextManager, ExitStack from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, final -from anyio import BrokenResourceError, WouldBlock, to_thread +from anyio import BrokenResourceError, WouldBlock, create_memory_object_stream, to_thread from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from localpost._utils import ( Event, EventView, - MemoryStream, Result, def_full_name, is_async_callable, @@ -77,7 +76,7 @@ def subscribe(self, buffer_max_size: float = math.inf) -> MemoryObjectReceiveStr # Task is one-shot: once the last user has exited, the underlying ExitStack is closed and # cannot be re-entered. A fresh subscriber would silently never receive anything. raise RuntimeError(f"Cannot subscribe to {self!r}: task has already finished") - send_stream, receive_stream = MemoryStream[Result[R]].create(buffer_max_size) + send_stream, receive_stream = create_memory_object_stream[Result[R]](buffer_max_size) self._subscribers.append(self._cm.enter_context(send_stream)) return receive_stream From bf0bb1f413707ebf6a750754b244372a65fe7a59 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 06:19:29 +0000 Subject: [PATCH 275/286] no custom domain for now --- docs/CNAME | 1 - mkdocs.yml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index ffe5910..0000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -localpost.dev diff --git a/mkdocs.yml b/mkdocs.yml index 3fcd3a7..fbd34d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,7 +2,7 @@ site_name: LocalPost site_description: >- A small async Python framework for long-running processes: service hosting, in-process task scheduling, and a lightweight HTTP server. -site_url: https://localpost.dev/ +site_url: https://alexeyshockov.github.io/localpost.py/ repo_url: https://github.com/alexeyshockov/localpost.py repo_name: alexeyshockov/localpost.py edit_uri: edit/main/docs/ From f726b16c078700970fa0ef232bc47c39d3fa5d2e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 10:38:12 +0400 Subject: [PATCH 276/286] fix(ci): restore valid job ID for the 3.14t check; add actionlint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions job IDs must match ^[A-Za-z_][A-Za-z0-9_-]*$; "3.14t" violates both rules (starts with a digit, contains a dot), so the workflow failed to parse. Rename the job to ``tests-3-14t`` and keep "3.14t" as the display ``name:`` so the check label is unchanged. Wire actionlint into pre-commit to catch this class of schema/syntax error locally — it flags the invalid job ID and validates expressions, action refs, and shell snippets via shellcheck. Drive-by: quote the ``$(which python)`` arg flagged by SC2046. --- .github/workflows/ci.yaml | 5 +++-- .pre-commit-config.yaml | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d0bf96..94459b1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,7 +30,7 @@ jobs: - name: ty run: uv run ty check localpost - name: type coverage (basedpyright --verifytypes) - run: uv run basedpyright --pythonpath $(which python) --verifytypes localpost localpost/* + run: uv run basedpyright --pythonpath "$(which python)" --verifytypes localpost localpost/* tests: runs-on: ubuntu-latest @@ -76,7 +76,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - 3.14t: + tests-3-14t: + name: "3.14t" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55c62cf..cb66395 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,3 +28,8 @@ repos: - id: end-of-file-fixer - id: check-toml - id: check-yaml + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.7 + hooks: + - id: actionlint From e8cb4a9af38fe79d4eb665a8566ce26e9ad44a12 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 10:52:51 +0400 Subject: [PATCH 277/286] chore(pre-commit): bump revs and expand hook coverage - Bump pre-commit-hooks v5 -> v6, codespell -> v2.4.2, actionlint -> v1.7.12. - Rename ``ruff`` -> ``ruff-check`` (new canonical hook id; old one prints a legacy-alias warning). - Repoint interrogate from ``tests`` to ``localpost`` with ``--fail-under=24`` so it measures library-docstring coverage and prevents regressions (floor matches current coverage; ratchet up as docstrings land). - Add three cheap safety nets from pre-commit-hooks: check-merge-conflict, check-case-conflict, check-added-large-files. - Add ``validate-pyproject`` (catches pyproject.toml schema typos). - Add ``astral-sh/uv-pre-commit`` (``uv-lock``) so the lockfile can't drift from ``pyproject.toml`` without a commit-time failure. - Pin ``minimum_pre_commit_version`` for clarity. --- .pre-commit-config.yaml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb66395..27b02ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +minimum_pre_commit_version: "3.5.0" + ci: autoupdate_schedule: monthly @@ -5,7 +7,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.12 hooks: - - id: ruff + - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format @@ -13,23 +15,37 @@ repos: rev: 1.7.0 hooks: - id: interrogate - args: [tests] + # Floor tracks current coverage; ratchet up as docstrings land. + args: [--fail-under=24, localpost] - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell args: [-L, alog, -L, abl] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-toml - id: check-yaml + - id: check-merge-conflict + - id: check-case-conflict + - id: check-added-large-files + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.25 + hooks: + - id: validate-pyproject + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.13 + hooks: + - id: uv-lock - repo: https://github.com/rhysd/actionlint - rev: v1.7.7 + rev: v1.7.12 hooks: - id: actionlint From ab70f31f2dcab7f338e2af2fc2ff0e452ef1d16b Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 11:32:51 +0400 Subject: [PATCH 278/286] chore(pre-commit): replace codespell with typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop codespell in favour of crate-ci/typos: Rust-based, ~10x faster, identifier-aware tokenizer (handles camelCase / snake_case), and a smaller curated dictionary with materially fewer false positives. The previous ``-L alog -L abl`` allowlist translates to zero entries in typos — both tokens are correctly left alone by its tokenizer. The only allowlisted word now is ``ands`` (genuine verb form for the selector ``ANDs`` test name in tests/benchmarks/http_stacks.py). --- .pre-commit-config.yaml | 7 +++---- pyproject.toml | 4 ++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 27b02ba..5d83eef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,11 +18,10 @@ repos: # Floor tracks current coverage; ratchet up as docstrings land. args: [--fail-under=24, localpost] - - repo: https://github.com/codespell-project/codespell - rev: v2.4.2 + - repo: https://github.com/crate-ci/typos + rev: v1.46.1 hooks: - - id: codespell - args: [-L, alog, -L, abl] + - id: typos - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/pyproject.toml b/pyproject.toml index d13b16f..bf68264 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -332,3 +332,7 @@ markers = [ "integration: Consumers (SQS, Kafka,..) integration tests", "no_ambient_pool: opt out of the autouse threadtools ambient pool fixture", ] + +[tool.typos.default.extend-words] +# ``ANDs`` (verb) — selector group spec ANDs with filter spec. +ands = "ands" From 70e0fe587a6366498dba11e63854109c64c43005 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 11:33:02 +0400 Subject: [PATCH 279/286] docs,http: spell ``unparsable`` consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply typos's preferred spelling (``unparsable`` rather than the equally-valid ``unparseable``) across docs, the static-file handler, and its tests. Includes the ``test_unparseable`` → ``test_unparsable`` rename and the matching docstring on ``_parse_range``. --- docs/modules/http.md | 2 +- localpost/http/static.py | 4 ++-- plans/static-files.md | 4 ++-- tests/http/static.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/modules/http.md b/docs/modules/http.md index 5178264..c7c2ecc 100644 --- a/docs/modules/http.md +++ b/docs/modules/http.md @@ -238,7 +238,7 @@ Behaviour: `If-Modified-Since` short-circuit to 304 inline on the selector — no worker hop, no file open. - **Range**: single byte-range only (`bytes=N-M`, `bytes=N-`, `bytes=-K`). - Multi-range / unparseable falls back to 200 (RFC 7233 §3.1 compliant). + Multi-range / unparsable falls back to 200 (RFC 7233 §3.1 compliant). Out-of-bounds → 416 with `Content-Range: bytes */`. - **Body**: 200 / 206 GET opens the file and calls `ctx.sendfile(...)` — wrap in `thread_pool_handler` to dispatch the syscall to a worker. diff --git a/localpost/http/static.py b/localpost/http/static.py index 2e4cf15..1e4877d 100644 --- a/localpost/http/static.py +++ b/localpost/http/static.py @@ -134,7 +134,7 @@ def handle(ctx: HTTPReqCtx) -> None: _send_not_modified(ctx, etag, last_modified, cc_bytes) return - # Range — single byte-range only. Multi-range / unparseable falls back to 200. + # Range — single byte-range only. Multi-range / unparsable falls back to 200. offset = 0 length = size status = 200 @@ -254,7 +254,7 @@ def _parse_range(header: bytes, size: int) -> tuple[int, int] | Literal["unsatis """Parse a ``Range:`` header. Returns ``(offset, length)`` for a satisfiable single byte-range, ``"unsatisfiable"`` for a syntactically valid but out-of-bounds range (→ 416), or ``None`` for absent / - unparseable / multi-range (→ fall back to full body). + unparsable / multi-range (→ fall back to full body). """ unit, _, spec = header.strip().partition(b"=") if unit.lower() != b"bytes" or not spec: diff --git a/plans/static-files.md b/plans/static-files.md index 0486d22..b0ce18e 100644 --- a/plans/static-files.md +++ b/plans/static-files.md @@ -98,7 +98,7 @@ No new method on `HTTPReqCtx` Protocol. - `_if_none_match(header: bytes, etag: bytes) -> bool` — comma-split, trim whitespace, weak-prefix-aware compare. - `_parse_range(header: bytes, size: int) -> tuple[int, int] | None | "unsatisfiable"` - — `(start, length)`, `None` for absent / multi-range / unparseable + — `(start, length)`, `None` for absent / multi-range / unparsable (200 fallback per RFC 7233 §3.1), unsatisfiable sentinel for 416. - `_content_type(path: Path) -> bytes` — `mimetypes.guess_type`, `application/octet-stream` fallback. @@ -146,7 +146,7 @@ API workers. `tests/http/test_static.py`: - Path traversal (`/static/../etc/passwd` → 404). - Range: full, `0-99`, `100-199`, suffix `-50`, beyond-EOF (416), - unparseable (200 fallback), multi-range (200 fallback). + unparsable (200 fallback), multi-range (200 fallback). - Conditionals: `If-None-Match` hit/miss; `If-Modified-Since` before / equal / after mtime. - HEAD parity: same headers, no body. diff --git a/tests/http/static.py b/tests/http/static.py index f639ddf..df5a1a5 100644 --- a/tests/http/static.py +++ b/tests/http/static.py @@ -150,7 +150,7 @@ def test_multi_range_falls_back(self): # Multi-range → 200 fallback (we don't speak multipart/byteranges). assert _parse_range(b"bytes=0-99,200-299", 1000) is None - def test_unparseable(self): + def test_unparsable(self): assert _parse_range(b"bytes=abc-def", 1000) is None assert _parse_range(b"bytes=", 1000) is None assert _parse_range(b"items=0-99", 1000) is None From fb3f96fe6eed75478c4462cd6cf09817ff0c3554 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 12:12:32 +0400 Subject: [PATCH 280/286] ci: split tests into stable + free-threaded matrices; sonar self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure CI test jobs so the free-threaded build isn't an oddball single-job sibling of the stable matrix: - ``tests`` matrix: 3.12, 3.13, 3.14, 3.15 (beta). Drop ``--cov`` flags and the upload-artifact step — coverage is owned by sonar now. - ``tests-ft`` matrix (new): 3.14t, 3.15t (beta). Same FT-clean dependency subset and ``PYTHON_GIL=0`` env as before, now applied uniformly across the two free-threaded interpreters. - ``sonarcloud`` now runs pytest itself with coverage (no download-artifact, no ``needs: tests``) on the project-pinned Python from ``.python-version``. Runs in parallel with the test jobs. --- .github/workflows/ci.yaml | 80 +++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 94459b1..13bcebd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,11 +37,10 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.12", "3.13", "3.14"] + # 3.15 is still beta — uv ships ``cpython-3.15.0b1``. + python: ["3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Sonar needs full history - uses: astral-sh/setup-uv@v5 with: enable-cache: true @@ -49,52 +48,32 @@ jobs: - run: uv sync --python ${{ matrix.python }} --all-groups --all-extras - name: pytest (unit only) timeout-minutes: 5 - run: > - uv run --python ${{ matrix.python }} - pytest -m "not integration" -v - --cov-report=term --cov-report=xml --cov - - name: Upload coverage (3.13) - if: matrix.python == '3.13' - uses: actions/upload-artifact@v4 - with: - name: coverage-xml - path: coverage.xml - retention-days: 1 + run: uv run --python ${{ matrix.python }} pytest -m "not integration" -v - sonarcloud: - needs: tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/download-artifact@v4 - with: - name: coverage-xml - - uses: SonarSource/sonarqube-scan-action@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - tests-3-14t: - name: "3.14t" + tests-ft: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.15t is still beta — uv ships ``cpython-3.15.0b1+freethreaded``. + python: ["3.14t", "3.15t"] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 with: enable-cache: true - - name: Install Python 3.14t - run: uv python install 3.14t + - name: Install Python ${{ matrix.python }} + run: uv python install ${{ matrix.python }} - name: Install dependencies (FT-clean subset) # --no-default-groups + narrow opt-in: avoid C extensions that - # re-enable the GIL on 3.14t (httptools 0.7.x, grpcio, granian, - # setproctitle). Covers the pure-Python core that the FT marking - # actually claims. ``dev-http`` (httpx, flask), ``dev-sentry`` - # (sentry-sdk), and the ``click`` extra are pure Python — pulled in so - # tests/http and tests/hosting/services collect cleanly. + # re-enable the GIL on free-threaded builds (httptools 0.7.x, + # grpcio, granian, setproctitle). Covers the pure-Python core + # that the FT marking actually claims. ``dev-http`` (httpx, + # flask), ``dev-sentry`` (sentry-sdk), and the ``click`` extra + # are pure Python — pulled in so tests/http and + # tests/hosting/services collect cleanly. run: > - uv sync --python 3.14t --no-default-groups + uv sync --python ${{ matrix.python }} --no-default-groups --group tests --group tests-unit --group dev-http --group dev-sentry --extra http --extra scheduler --extra cron --extra click @@ -105,6 +84,27 @@ jobs: # Skip ``tests/openapi`` (msgspec C ext) and ``tests/hosting/rsgi.py`` # (exercises Granian hooks; granian C ext not in the FT subset). run: > - uv run --python 3.14t pytest -v -m "not integration" + uv run --python ${{ matrix.python }} pytest -v -m "not integration" --ignore=tests/openapi --ignore=tests/hosting/rsgi.py + + sonarcloud: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Sonar needs full history + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version-file: ".python-version" + - run: uv sync --all-groups --all-extras + - name: pytest with coverage + timeout-minutes: 5 + run: > + uv run pytest -m "not integration" -v + --cov-report=term --cov-report=xml --cov + - uses: SonarSource/sonarqube-scan-action@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From bbd76667d0592261ceadc27af07df047f5fb04d1 Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 15:02:48 +0400 Subject: [PATCH 281/286] ci: drop lint-types job; mark 3.15 as experimental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the ``lint-types`` job. ruff is already covered by pre-commit.ci on every PR; ty and basedpyright-verifytypes were the only checks unique to this job, and basedpyright-verifytypes has been unmaintainable in CI (requires 100% type completeness; reached 97.2% even after ``--ignoreexternal``). Move both to local-only tooling — ``just types`` and ``just type-coverage`` are still wired up. Also convert the ``tests`` matrix to ``include`` form and add ``continue-on-error: true`` for the 3.15 entry. Python 3.15 is still beta and ``granian``'s PyO3 0.27.2 doesn't compile against it yet — this keeps 3.15 as an early-signal channel without blocking the PR. --- .github/workflows/ci.yaml | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 13bcebd..9b363aa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,31 +14,20 @@ concurrency: cancel-in-progress: true jobs: - lint-types: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - python-version-file: ".python-version" - - run: uv sync --all-groups --all-extras - - name: ruff check - run: uv run ruff check localpost - - name: ruff format --check - run: uv run ruff format --check localpost - - name: ty - run: uv run ty check localpost - - name: type coverage (basedpyright --verifytypes) - run: uv run basedpyright --pythonpath "$(which python)" --verifytypes localpost localpost/* - tests: runs-on: ubuntu-latest + # 3.15 is still beta and ``granian``'s PyO3 0.27 doesn't support it yet; + # the entry is kept as an early-signal channel until PyO3 catches up. + continue-on-error: ${{ matrix.experimental || false }} strategy: fail-fast: false matrix: - # 3.15 is still beta — uv ships ``cpython-3.15.0b1``. - python: ["3.12", "3.13", "3.14", "3.15"] + include: + - python: "3.12" + - python: "3.13" + - python: "3.14" + - python: "3.15" + experimental: true steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 From ec22d7d6f85df66388d77086c4d5adf1eb9230ba Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 15:03:00 +0400 Subject: [PATCH 282/286] chore: tighten types on Channel protocols; ignore PLC0415 in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ``localpost/threadtools/_channel.py``: annotate ``ReceiveChannel.__exit__/__aexit__`` and ``SendChannel.__exit__/__aexit__`` with standard ``type[BaseException] | None`` / ``BaseException | None`` / ``TracebackType | None``. Improves the type hints downstream users see when implementing or consuming the channel protocols. - ``pyproject.toml``: ignore ``PLC0415`` (function-level imports) under ``tests/`` — tests legitimately use lazy / conditional imports for optional extras and internals under test. - ``tests/http/backend_parity.py``: move ``import pytest`` to the top of the file and call ``pytest.skip`` directly; the per-function ``import pytest as _pytest`` was tripping both PT013 and PLC0415. --- localpost/threadtools/_channel.py | 29 +++++++++++++++++++++++++---- pyproject.toml | 3 +++ tests/http/backend_parity.py | 9 +++------ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/localpost/threadtools/_channel.py b/localpost/threadtools/_channel.py index e4b4684..2940820 100644 --- a/localpost/threadtools/_channel.py +++ b/localpost/threadtools/_channel.py @@ -5,6 +5,7 @@ import time from collections import deque from collections.abc import Iterator +from types import TracebackType from typing import Protocol, Self, final, override from anyio import ( @@ -42,13 +43,23 @@ class ReceiveChannel[T](Protocol): def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.close() async def __aenter__(self) -> Self: return self - async def __aexit__(self, exc_type, exc_value, traceback) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.close() def __iter__(self) -> Iterator[T]: @@ -80,13 +91,23 @@ class SendChannel[T](Protocol): def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.close() async def __aenter__(self) -> Self: return self - async def __aexit__(self, exc_type, exc_value, traceback) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self.close() def clone(self) -> SendChannel[T]: ... diff --git a/pyproject.toml b/pyproject.toml index bf68264..f81eb7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -311,6 +311,9 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "examples/*" = ["T201"] "benchmarks/*" = ["T201"] +# Tests legitimately use lazy/conditional imports inside test bodies (optional +# extras, internals under test). Top-of-file imports are not always feasible. +"tests/*" = ["PLC0415"] # Not a Python package; just a hook file mkdocs loads by path. "docs/*" = ["INP001"] diff --git a/tests/http/backend_parity.py b/tests/http/backend_parity.py index 6470f0c..40b291c 100644 --- a/tests/http/backend_parity.py +++ b/tests/http/backend_parity.py @@ -5,6 +5,7 @@ import socket import httpx +import pytest from localpost.http import HTTPReqCtx, Response, ServerConfig, read_body from tests.http._helpers import drain_socket, read_http_response, read_until @@ -39,9 +40,7 @@ def test_buffered_post_body_handler(serve_backend_in_thread, http_backend): # ``parser.feed_data`` callback chain — only safe via a worker # pool. The pooled equivalent lives in ``tests/http/service.py``; # here we just exercise the h11 path. - import pytest as _pytest - - _pytest.skip("httptools requires worker-pool composition for body reads") + pytest.skip("httptools requires worker-pool composition for body reads") received: list[bytes] = [] @@ -113,9 +112,7 @@ def handler(ctx: HTTPReqCtx) -> None: def test_expect_100_continue(serve_backend_in_thread, http_backend): if http_backend == "httptools": - import pytest as _pytest - - _pytest.skip("httptools requires worker-pool composition for body reads") + pytest.skip("httptools requires worker-pool composition for body reads") def handler(ctx: HTTPReqCtx) -> None: # ``read_body`` drives the body recv loop, which sends 100 Continue From 154a640ab4bf2647b3fcdc224d818c4f2958debf Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 15:08:15 +0400 Subject: [PATCH 283/286] ci: mark 3.15t as experimental too Mirror the ``tests`` matrix: convert ``tests-ft`` to ``include`` form and mark 3.15t as ``experimental: true`` so its failure won't block the PR. Free-threaded 3.15 is at the same beta-readiness as the stable 3.15 build, and there's no reason to gate merges on it. --- .github/workflows/ci.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9b363aa..421917a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,11 +41,15 @@ jobs: tests-ft: runs-on: ubuntu-latest + # 3.15t is still beta (uv ships ``cpython-3.15.0b1+freethreaded``). + continue-on-error: ${{ matrix.experimental || false }} strategy: fail-fast: false matrix: - # 3.15t is still beta — uv ships ``cpython-3.15.0b1+freethreaded``. - python: ["3.14t", "3.15t"] + include: + - python: "3.14t" + - python: "3.15t" + experimental: true steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 From 25c7e0ef06750118c827642b3270bc9d9e857c9d Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 16:02:26 +0400 Subject: [PATCH 284/286] add release pre-flight: just release-check + just api-diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``just release-check`` prints a pre-release report: - current type completeness (basedpyright --verifytypes, now with ``--ignoreexternal`` so the score reflects our own code only — was ~94% with werkzeug/flask noise, ~97% without) - public-API breaking changes vs the previous stable tag, via ``griffe check`` — same tool Pydantic / FastAPI use for the same purpose ``just api-diff [base]`` is the griffe call on its own. Both recipes default ``base`` to the latest tag matching strictly ``vX.Y.Z`` (no ``b1`` / ``rc1`` / ``.dev0`` suffix), via a justfile-level variable ``previous_stable_tag``. Both are informational (always exit 0); for assessment, not gatekeeping. ``griffe`` is already installed at user level via ``uv tool install griffe`` in ``just doctor``. ``type-coverage`` now uses the ``-`` prefix to match the other type-check recipes — failure shouldn't abort the umbrella. --- justfile | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index baffb25..a4060ea 100755 --- a/justfile +++ b/justfile @@ -1,7 +1,8 @@ #!/usr/bin/env -S just --justfile doctor: - brew install uv oha ty + brew install uv oha ty pre-commit vulture actionslint + uv tool install griffe deps: uv sync --all-groups --all-extras @@ -24,7 +25,7 @@ types-all: types [doc("Check that the public API is correctly typed")] type-coverage: - basedpyright --pythonpath $(which python) --verifytypes localpost localpost/* + -basedpyright --pythonpath "$(which python)" --verifytypes localpost --ignoreexternal localpost/* format: ruff check --fix localpost @@ -101,6 +102,35 @@ docs-build: deadcode *args: vulture {{ args }} +# --------------------------------------------------------------------------- +# Release pre-flight +# +# Workflow: +# just release-check # current type coverage + API BC vs previous stable tag +# +# just release X.Y.Z # (see below) +# --------------------------------------------------------------------------- + +# Latest tag matching strictly ``vX.Y.Z`` (no ``b1`` / ``rc1`` / ``.dev0`` suffix). +previous_stable_tag := `git tag --sort=-v:refname --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1` + +[doc("Show API breaking changes vs [base] (default: previous stable tag) using griffe")] +api-diff base=previous_stable_tag: + #!/usr/bin/env bash + set -euo pipefail + if [ -z "{{ base }}" ]; then + echo "no previous stable tag found; pass [base] explicitly" >&2 + exit 1 + fi + echo "▸ Public API vs {{ base }} (griffe check, breaking changes only)" + griffe check localpost -a "{{ base }}" -s . || true + +[doc("Pre-release report: current type coverage + API BC vs [base]")] +release-check base=previous_stable_tag: + @just type-coverage + @echo + @just api-diff {{ base }} + # --------------------------------------------------------------------------- # Release automation # From ba8e233e017f878f2d34ccf7fbb0bf2c27597ffe Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 16:07:47 +0400 Subject: [PATCH 285/286] docs(contributing): doctor + pre-commit setup; release pre-flight step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Setup: surface ``just doctor`` (user-level toolchain) and the one-time ``pre-commit install`` step, and note the project keeps tools at user level (not in the project venv). - PR checklist: clarify that ``just type-coverage`` is informational now — basedpyright's score reports our public-API completeness but isn't a 100% gate (lint-types was dropped from CI). - Release process: add ``just release-check`` as step 1 (pre-flight review of type coverage + griffe BC report vs the previous stable tag), renumber the remaining steps. --- CONTRIBUTING.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 309592d..ec252bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,11 +20,16 @@ Requirements: Python 3.12+ and [`uv`](https://docs.astral.sh/uv/) (which manages the virtualenv, and dependencies). [`just`](https://github.com/casey/just) is used to wrap the common workflows. +The project keeps **tools at user level** (Homebrew + `uv tool install`), separate from the project +venv. `just doctor` installs everything you need: + ```bash git clone https://github.com/alexeyshockov/localpost.py.git cd localpost.py -just deps # uv sync --all-groups --all-extras -just unit-tests # confirm the toolchain is healthy +just doctor # user-level toolchain: uv, ty, basedpyright, ruff, griffe, pre-commit, ... +just deps # sync the project venv (uv sync --all-groups --all-extras) +pre-commit install # wire the repo-local commit hooks (one-time per clone) +just unit-tests # confirm the toolchain is healthy ``` The full list of recipes is in `justfile` (run `just --list`). The most common ones: @@ -107,9 +112,10 @@ Other rules: Before opening: -- [ ] `just format` is clean -- [ ] `just types` passes -- [ ] `just type-coverage` passes (public API stays fully typed) +- [ ] `just format` is clean (pre-commit hooks enforce this too) +- [ ] `just types` is clean +- [ ] `just type-coverage` reviewed — no regressions on public API you touched + (basedpyright reports an overall score; treat it as informational) - [ ] `just unit-tests` passes - [ ] You added or updated tests for the change - [ ] You added a CHANGELOG entry under `## [Unreleased]` (Added / Changed / Fixed / Removed, @@ -155,11 +161,24 @@ The release pipeline is automated via two `just` recipes plus a GitHub Actions w ### Cutting a release -1. **Land the release notes.** On a feature branch, replace `## [Unreleased]` in `CHANGELOG.md` +1. **Pre-flight: review type coverage and public-API breakages** since the previous stable + release: + + ```bash + just release-check + ``` + + This prints (a) the current `basedpyright --verifytypes` score and (b) `griffe check`'s + breaking-change report against the previous stable tag (latest `vX.Y.Z` with no + `b1`/`rc1`/`.devN` suffix). Use it to sanity-check the CHANGELOG and the version bump: + any breaking change must be called out in the CHANGELOG and should bump the minor (pre-1.0) + or major (post-1.0). The report is informational — exit code is always 0. + +2. **Land the release notes.** On a feature branch, replace `## [Unreleased]` in `CHANGELOG.md` with a `## [X.Y.Z] - YYYY-MM-DD` heading and finalise the entries. Open a PR, get it merged to `main`. -2. **Run the release recipe** from a clean `main`: +3. **Run the release recipe** from a clean `main`: ```bash just release 0.6.0 @@ -175,14 +194,14 @@ The release pipeline is automated via two `just` recipes plus a GitHub Actions w Guards: aborts if the working tree is dirty, you're not on `main`, or the matching CHANGELOG section is missing. -3. **Review and publish the release** on GitHub. Edit notes if needed and click **Publish +4. **Review and publish the release** on GitHub. Edit notes if needed and click **Publish release**. This: - creates the `v0.6.0` git tag at the release commit, - fires the `release: published` event, which triggers [`pypi-publish.yaml`](.github/workflows/pypi-publish.yaml), - the workflow runs `uv build` then `uv publish` with PyPI trusted publishing. -4. **Open the next dev cycle:** +5. **Open the next dev cycle:** ```bash just release-post 0.7.0 From 5e27f9e377c6e7c594a2f3b9b403ca618d2db69e Mon Sep 17 00:00:00 2001 From: Alexey Shokov Date: Mon, 11 May 2026 12:09:11 +0000 Subject: [PATCH 286/286] local tools --- pyproject.toml | 2 -- uv.lock | 11 ----------- 2 files changed, 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f81eb7b..01cf95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,8 +93,6 @@ dev = [ "icecream ~=2.1", "structlog ~=25.0", "trio ~=0.32", - - "vulture ~=2.14", ] dev-tools = [ # Linters / type checkers used by ``just check``, ``just format``, and CI. diff --git a/uv.lock b/uv.lock index 4b2fe66..a83e7a0 100644 --- a/uv.lock +++ b/uv.lock @@ -829,7 +829,6 @@ dev = [ { name = "icecream" }, { name = "structlog" }, { name = "trio" }, - { name = "vulture" }, ] dev-hosting-services = [ { name = "click" }, @@ -916,7 +915,6 @@ dev = [ { name = "icecream", specifier = "~=2.1" }, { name = "structlog", specifier = "~=25.0" }, { name = "trio", specifier = "~=0.32" }, - { name = "vulture", specifier = "~=2.14" }, ] dev-hosting-services = [ { name = "click", specifier = "~=8.0" }, @@ -1857,15 +1855,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] -[[package]] -name = "vulture" -version = "2.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, -] - [[package]] name = "werkzeug" version = "3.1.8"