diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14bf0b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + lint-test-docs: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ['3.9', '3.13', '3.14'] + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv sync + + - name: Lint and type check + run: uv run poe lint + + - name: Run tests + run: | + uv run pytest --cov=src --cov-report=html:coverage_html_report + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-html-report-${{ matrix.os }}-${{ matrix.python-version }} + path: coverage_html_report/ + + deploy-docs: + runs-on: ubuntu-latest + needs: lint-test-docs + # TODO(preview): deploy on releases only + permissions: + contents: read + pages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: '3.9' + + - name: Install dependencies + run: uv sync + + - name: Build API docs + run: uv run poe docs + + - name: Upload docs to GitHub Pages + uses: actions/upload-pages-artifact@v3 + with: + path: apidocs + + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 505a3b1..60cde98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments +__pycache__ .venv +apidocs +dist +docs diff --git a/.python-version b/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7680f61 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +We will welcome contributions once the SDK has reached a stable release. + +### Type-check and lint + +```sh +uv run poe lint +``` + +### Format +``` +uv run poe format +``` + +### Test +``` +uv run pytest +``` + +### API docs +``` +uv run poe docs +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..156ce57 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Temporal Technologies Inc. All Rights Reserved + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index e69de29..2494091 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,80 @@ +# Nexus Python SDK + +⚠️ **This SDK is currently at an experimental release stage. Backwards-incompatible changes are anticipated until a stable release is announced.** ⚠️ + +## What is Nexus? + +[Nexus](https://github.com/nexus-rpc/) is a synchronous RPC protocol. Arbitrary duration operations are modeled on top of +a set of pre-defined synchronous RPCs. + +A Nexus caller calls a handler. The handler may respond inline (synchronous response) or +return a token referencing the ongoing operation (asynchronous response). The caller can +cancel an asynchronous operation, check for its outcome, or fetch its current state. The +caller can also specify a callback URL, which the handler uses to deliver the result of +an asynchronous operation when it is ready. + +## Installation + +``` +uv add nexus-rpc +``` +or +``` +pip install nexus-rpc +``` + +## Usage + +The SDK currently supports two use cases: + +1. As an end user, defining Nexus services and operations. + +2. Implementing a Nexus handler that can accept and respond to incoming Nexus requests, dispatching to the corresponding user-defined Nexus operation. + +The handler in (2) would form part of a server or worker that processes Nexus requests; the SDK does not yet provide reference implementations of these, or of a Nexus client. + +### Defining Nexus services and operations + +```python +from dataclasses import dataclass + +import nexusrpc +from nexusrpc.handler import StartOperationContext, service_handler, sync_operation + + +@dataclass +class MyInput: + name: str + + +@dataclass +class MyOutput: + message: str + + +@nexusrpc.service +class MyNexusService: + my_sync_operation: nexusrpc.Operation[MyInput, MyOutput] + + +@service_handler(service=MyNexusService) +class MyNexusServiceHandler: + # You can create an __init__ method accepting what is needed by your operation + # handlers to handle requests. You will typically instantiate your service handler class + # when starting your Nexus server/worker. + + # This is a Nexus operation that responds synchronously to all requests. That means + # that the `start` method returns the final operation result. + # + # Sync operations are free to make arbitrary network calls. + @sync_operation + async def my_sync_operation( + self, ctx: StartOperationContext, input: MyInput + ) -> MyOutput: + return MyOutput(message=f"Hello {input.name}!") +``` + + +## Note regarding version number +The nexus-rpc name in PyPi was originally held by an unrelated project. Despite the +version being at `v1.x` it is currently at an experimental release stage. diff --git a/pyproject.toml b/pyproject.toml index 5c8de08..684d09b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,67 @@ [project] name = "nexus-rpc" -version = "0.1.0" +# The nexus-rpc name in PyPi was originally held by an unrelated project that reached +# 1.0.1 and was abandoned in 2018. The name was inherited by the Python Nexus SDK in +# 2025 and version numbering started from 1.1.0. Despite the version number, this is an +# experimental release and backwards-incompatible changes are anticipated until a GA +# release is announced. +version = "1.1.0" description = "Nexus Python SDK" readme = "README.md" authors = [ - { name = "Dan Davison", email = "dandavison7@gmail.com" } + { name = "Temporal Technologies", email = "sdk@temporal.io" } +] +requires-python = ">=3.9" +dependencies = [ + "typing-extensions>=4.12.2", +] + +[dependency-groups] +dev = [ + "mypy>=1.15.0", + "poethepoet>=0.35.0", + "pydoctor>=25.4.0", + "pyright>=1.1.402", + "pytest>=8.3.5", + "pytest-asyncio>=0.26.0", + "pytest-cov>=6.1.1", + "pytest-pretty>=1.3.0", + "ruff>=0.12.0", ] -requires-python = ">=3.13" -dependencies = [] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/nexusrpc"] + +[tool.poe.tasks] +lint = [ + {cmd = "uv run pyright src"}, + {cmd = "uv run mypy --check-untyped-defs src"}, + {cmd = "uv run ruff check --select I"}, + {cmd = "uv run ruff format --check"}, +] +format = [ + {cmd = "uv run ruff check --select I --fix"}, + {cmd = "uv run ruff format"}, +] +docs = [ + {cmd = "uv run pydoctor src/nexusrpc"}, +] + +[tool.pyright] +include = ["src", "tests"] + +[tool.mypy] +disable_error_code = ["empty-body"] + +[tool.ruff] +target-version = "py39" + +[tool.ruff.lint.isort] +combine-as-imports = true + +[tool.pydoctor] +docformat = "google" diff --git a/src/nexus_rpc/__init__.py b/src/nexus_rpc/__init__.py deleted file mode 100644 index a239ccc..0000000 --- a/src/nexus_rpc/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -def hello() -> str: - return "Hello from nexus-rpc!" diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py new file mode 100644 index 0000000..e8aef51 --- /dev/null +++ b/src/nexusrpc/__init__.py @@ -0,0 +1,57 @@ +""" +nexusrpc is a library for building Nexus handlers. + +See https://github.com/nexus-rpc and https://github.com/nexus-rpc/api/blob/main/SPEC.md. + +Nexus is a synchronous RPC protocol. Arbitrary duration operations are modeled on top of +a set of pre-defined synchronous RPCs. + +A Nexus caller calls a handler. The handler may respond inline (synchronous response) or +return a token referencing the ongoing operation (asynchronous response). The caller can +cancel an asynchronous operation, check for its outcome, or fetch its current state. The +caller can also specify a callback URL, which the handler uses to deliver the result of +an asynchronous operation when it is ready. +""" + +from __future__ import annotations + +from . import handler +from ._common import ( + HandlerError, + HandlerErrorType, + InputT, + Link, + OperationError, + OperationErrorState, + OperationInfo, + OperationState, + OutputT, +) +from ._serializer import Content, LazyValue +from ._service import Operation, ServiceDefinition, service +from ._util import ( + get_operation_definition, + get_service_definition, + set_operation_definition, +) + +__all__ = [ + "Content", + "get_operation_definition", + "get_service_definition", + "handler", + "HandlerError", + "HandlerErrorType", + "InputT", + "LazyValue", + "Link", + "Operation", + "OperationError", + "OperationErrorState", + "OperationInfo", + "OperationState", + "OutputT", + "service", + "ServiceDefinition", + "set_operation_definition", +] diff --git a/src/nexusrpc/_common.py b/src/nexusrpc/_common.py new file mode 100644 index 0000000..a9d3cb3 --- /dev/null +++ b/src/nexusrpc/_common.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, TypeVar + +InputT = TypeVar("InputT", contravariant=True) +"""Operation input type""" + +OutputT = TypeVar("OutputT", covariant=True) +"""Operation output type""" + +ServiceHandlerT = TypeVar("ServiceHandlerT") +"""A user's service handler class, typically decorated with @service_handler""" + +ServiceT = TypeVar("ServiceT") +"""A user's service definition class, typically decorated with @service""" + + +class HandlerError(Exception): + """ + A Nexus handler error. + + This exception is used to represent errors that occur during the handling of a + Nexus operation that should be reported to the caller as a handler error. + + Example: + .. code-block:: python + + import nexusrpc + + # Raise a bad request error + raise nexusrpc.HandlerError( + "Invalid input provided", + type=nexusrpc.HandlerErrorType.BAD_REQUEST + ) + + # Raise a retryable internal error + raise nexusrpc.HandlerError( + "Database unavailable", + type=nexusrpc.HandlerErrorType.INTERNAL, + retryable=True + ) + """ + + def __init__( + self, + message: str, + *, + type: HandlerErrorType, + retryable: Optional[bool] = None, + ): + """ + Initialize a new HandlerError. + + :param message: A descriptive message for the error. This will become the + `message` in the resulting Nexus Failure object. + + :param type: + + :param retryable: + """ + super().__init__(message) + self._type = type + self._retryable = retryable + + @property + def retryable(self) -> Optional[bool]: + """ + Whether this error should be retried. + + If None, then the default behavior for the error type should be used. + See https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors + """ + return self._retryable + + @property + def type(self) -> HandlerErrorType: + """ + The type of handler error. + + See :py:class:`HandlerErrorType` and + https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors. + """ + return self._type + + +class OperationError(Exception): + """ + An error that represents "failed" and "canceled" operation results. + + :param message: A descriptive message for the error. This will become the + `message` in the resulting Nexus Failure object. + + :param state: + + Example: + .. code-block:: python + + import nexusrpc + + # Indicate operation failed + raise nexusrpc.OperationError( + "Processing failed due to invalid data", + state=nexusrpc.OperationErrorState.FAILED + ) + + # Indicate operation was canceled + raise nexusrpc.OperationError( + "Operation was canceled by user request", + state=nexusrpc.OperationErrorState.CANCELED + ) + """ + + def __init__(self, message: str, *, state: OperationErrorState): + super().__init__(message) + self._state = state + + @property + def state(self) -> OperationErrorState: + """ + The state of the operation. + """ + return self._state + + +class OperationState(Enum): + """ + Describes the current state of an operation. + """ + + RUNNING = "running" + """ + The operation is running. + """ + + SUCCEEDED = "succeeded" + """ + The operation succeeded. + """ + + FAILED = "failed" + """ + The operation failed. + """ + + CANCELED = "canceled" + """ + The operation was canceled. + """ + + +class OperationErrorState(Enum): + """ + The state of an operation as described by an :py:class:`OperationError`. + """ + + FAILED = "failed" + """ + The operation failed. + """ + + CANCELED = "canceled" + """ + The operation was canceled. + """ + + +@dataclass(frozen=True) +class OperationInfo: + """ + Information about an operation. + """ + + token: str + """ + Token identifying the operation (returned on operation start). + """ + + state: OperationState + """ + The operation's state. + """ + + +class HandlerErrorType(Enum): + """Nexus handler error types. + + See https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors + """ + + BAD_REQUEST = "BAD_REQUEST" + """ + The handler cannot or will not process the request due to an apparent client error. + + Clients should not retry this request unless advised otherwise. + """ + + UNAUTHENTICATED = "UNAUTHENTICATED" + """ + The client did not supply valid authentication credentials for this request. + + Clients should not retry this request unless advised otherwise. + """ + + UNAUTHORIZED = "UNAUTHORIZED" + """ + The caller does not have permission to execute the specified operation. + + Clients should not retry this request unless advised otherwise. + """ + + NOT_FOUND = "NOT_FOUND" + """ + The requested resource could not be found but may be available in the future. + + Subsequent requests by the client are permissible but not advised. + """ + + RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED" + """ + Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system is out of space. + + Subsequent requests by the client are permissible. + """ + + INTERNAL = "INTERNAL" + """ + An internal error occurred. + + Subsequent requests by the client are permissible. + """ + + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + """ + The handler either does not recognize the request method, or it lacks the ability to fulfill the request. + + Clients should not retry this request unless advised otherwise. + """ + + UNAVAILABLE = "UNAVAILABLE" + """ + The service is currently unavailable. + + Subsequent requests by the client are permissible. + """ + + UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT" + """ + Used by gateways to report that a request to an upstream handler has timed out. + + Subsequent requests by the client are permissible. + """ + + +@dataclass(frozen=True) +class Link: + """ + A Link contains a URL and a type that can be used to decode the URL. + + The URL may contain arbitrary data (percent-encoded). It can be used to pass + information about the caller to the handler, or vice versa. + """ + + url: str + """ + Link URL. + + Must be percent-encoded. + """ + + type: str + """ + A data type for decoding the URL. + + Valid chars: alphanumeric, '_', '.', '/' + """ diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py new file mode 100644 index 0000000..5f4fa84 --- /dev/null +++ b/src/nexusrpc/_serializer.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ( + Any, + AsyncIterable, + Awaitable, + Mapping, + Optional, + Protocol, + Type, + Union, +) + + +@dataclass(frozen=True) +class Content: + """ + A container for a map of headers and a byte array of data. + + It is used by the SDK's Serializer interface implementations. + """ + + headers: Mapping[str, str] + """ + Header that should include information on how to deserialize this content. + Headers constructed by the framework always have lower case keys. + User provided keys are treated case-insensitively. + """ + + data: bytes + """Request or response data.""" + + +class Serializer(Protocol): + """ + Serializer is used by the framework to serialize/deserialize input and output. + """ + + def serialize(self, value: Any) -> Union[Content, Awaitable[Content]]: + """Serialize encodes a value into a Content.""" + ... + + def deserialize( + self, content: Content, as_type: Optional[Type[Any]] = None + ) -> Union[Any, Awaitable[Any]]: + """Deserialize decodes a Content into a value. + + Args: + content: The content to deserialize. + as_type: The type to convert the result of deserialization into. + Do not attempt type conversion if this is None. + """ + ... + + +class LazyValueT(Protocol): + def consume( + self, as_type: Optional[Type[Any]] = None + ) -> Union[Any, Awaitable[Any]]: ... + + +class LazyValue(LazyValueT): + """ + A container for a value encoded in an underlying stream. + + It is used to stream inputs and outputs in the various client and server APIs. + + Example: + .. code-block:: python + + # Creating a LazyValue from raw data + lazy_input = LazyValue( + serializer=my_serializer, + headers={"content-type": "application/json"}, + stream=async_data_stream + ) + + # Using LazyValue with Handler.start_operation + handler = nexusrpc.handler.Handler([my_service]) + result = await handler.start_operation(ctx, lazy_input) + + # Consuming a LazyValue directly + deserialized_data = await lazy_input.consume(as_type=MyInputType) + """ + + def __init__( + self, + serializer: Serializer, + headers: Mapping[str, str], + stream: Optional[AsyncIterable[bytes]] = None, + ) -> None: + """ + Args: + serializer: The serializer to use for consuming the value. + headers: Headers that include information on how to process the stream's content. + Headers constructed by the framework always have lower case keys. + User provided keys are treated case-insensitively. + stream: Iterable that contains request or response data. None means empty data. + """ + self.serializer = serializer + self.headers = headers + self.stream = stream + + async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + if self.stream is None: + return await self.serializer.deserialize( + Content(headers=self.headers, data=b""), as_type=as_type + ) + elif not isinstance(self.stream, AsyncIterable): + raise ValueError("When using consume, stream must be an AsyncIterable") + + return await self.serializer.deserialize( + Content( + headers=self.headers, + data=b"".join([c async for c in self.stream]), + ), + as_type=as_type, + ) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py new file mode 100644 index 0000000..a407330 --- /dev/null +++ b/src/nexusrpc/_service.py @@ -0,0 +1,299 @@ +""" +A Nexus service definition is a class with class attributes of type Operation. It must +be be decorated with @nexusrpc.service. The decorator validates the Operation +attributes. +""" + +from __future__ import annotations + +import dataclasses +import typing +from dataclasses import dataclass +from typing import ( + Any, + Callable, + Generic, + Mapping, + Optional, + Type, + Union, + overload, +) + +from nexusrpc._common import InputT, OutputT, ServiceT +from nexusrpc._util import ( + get_annotations, + get_service_definition, + set_service_definition, +) + + +@dataclass +class Operation(Generic[InputT, OutputT]): + """Defines a Nexus operation in a Nexus service definition. + + This class is for definition of operation name and input/output types only; to + implement an operation, see `:py:meth:nexusrpc.handler.operation_handler`. + + Example: + + .. code-block:: python + + @nexusrpc.service + class MyNexusService: + my_operation: nexusrpc.Operation[MyInput, MyOutput] + """ + + name: str + # TODO(preview): they should not be able to set method_name in constructor + method_name: Optional[str] = dataclasses.field(default=None) + input_type: Optional[Type[InputT]] = dataclasses.field(default=None) + output_type: Optional[Type[OutputT]] = dataclasses.field(default=None) + + def __post_init__(self): + if not self.name: + raise ValueError("Operation name cannot be empty") + + def _validation_errors(self) -> list[str]: + errors = [] + if not self.name: + errors.append( + f"Operation has no name (method_name is '{self.method_name}')" + ) + if not self.method_name: + errors.append(f"Operation '{self.name}' has no method name") + if not self.input_type: + errors.append(f"Operation '{self.name}' has no input type") + if not self.output_type: + errors.append(f"Operation '{self.name}' has no output type") + return errors + + +@overload +def service(cls: Type[ServiceT]) -> Type[ServiceT]: ... + + +@overload +def service( + *, name: Optional[str] = None +) -> Callable[[Type[ServiceT]], Type[ServiceT]]: ... + + +def service( + cls: Optional[Type[ServiceT]] = None, + *, + name: Optional[str] = None, +) -> Union[ + Type[ServiceT], + Callable[[Type[ServiceT]], Type[ServiceT]], +]: + """ + Decorator marking a class as a Nexus service definition. + + The decorator validates the operation definitions in the service definition: that they + have the correct type, and that there are no duplicate operation names. The decorator + also creates instances of the Operation class for each operation definition. + + Example: + .. code-block:: python + + @nexusrpc.service + class MyNexusService: + my_op: nexusrpc.Operation[MyInput, MyOutput] + another_op: nexusrpc.Operation[str, dict] + + @nexusrpc.service(name="custom-service-name") + class AnotherService: + process: nexusrpc.Operation[ProcessInput, ProcessOutput] + """ + + # TODO(preview): error on attempt foo = Operation[int, str](name="bar") + # The input and output types are not accessible on the instance. + # TODO(preview): Support foo = Operation[int, str]? E.g. via + # ops = {name: nexusrpc.Operation[int, int] for name in op_names} + # service_cls = nexusrpc.service(type("ServiceContract", (), ops)) + # This will require forming a union of operations disovered via __annotations__ + # and __dict__ + + def decorator(cls: Type[ServiceT]) -> Type[ServiceT]: + if name is not None and not name: + raise ValueError("Service name must not be empty.") + defn = ServiceDefinition.from_class(cls, name or cls.__name__) + set_service_definition(cls, defn) + + # In order for callers to refer to operations at run-time, a decorated user + # service class must itself have a class attribute for every operation, even if + # declared only via a type annotation, and whether inherited from a parent class + # or not. + # + # TODO(preview): it is sufficient to do this setattr only for the subset of + # operations that were declared on *this* class. Currently however we are + # setting all inherited operations. + for op_name, op in defn.operations.items(): + setattr(cls, op_name, op) + + return cls + + if cls is None: + return decorator + else: + return decorator(cls) + + +@dataclass(frozen=True) +class ServiceDefinition: + name: str + operations: Mapping[str, Operation[Any, Any]] + + def __post_init__(self): + if errors := self._validation_errors(): + raise ValueError( + f"Service definition {self.name} has validation errors: {', '.join(errors)}" + ) + + @staticmethod + def from_class(user_class: Type[ServiceT], name: str) -> ServiceDefinition: + """Create a ServiceDefinition from a user service definition class. + + The set of service definition operations returned is the union of operations + defined directly on this class with those inherited from ancestral service + definitions (i.e. ancestral classes that were decorated with @nexusrpc.service). + If multiple service definitions define an operation with the same name, then the + usual mro() precedence rules apply. + """ + operations = ServiceDefinition._collect_operations(user_class) + + # Obtain the set of operations to be inherited from ancestral service + # definitions. Operations are only inherited from classes that are also + # decorated with @nexusrpc.service. We do not permit any "overriding" by child + # classes; both the following must be true: + # 1. No inherited operation has the same name as that of an operation defined + # here. If this were violated, it would indicate two service definitions + # exposing potentially different operation definitions behind the same + # operation name. + # 2. No inherited operation has the same method name as that of an operation + # defined here. If this were violated, there would be ambiguity in which + # operation handler is dispatched to. + parent_defns = ( + defn + for defn in (get_service_definition(cls) for cls in user_class.mro()[1:]) + if defn + ) + method_names = {op.method_name for op in operations.values() if op.method_name} + if parent_defn := next(parent_defns, None): + for op in parent_defn.operations.values(): + if op.method_name in method_names: + raise ValueError( + f"Operation method name '{op.method_name}' in class '{user_class}' " + f"also occurs in a service definition inherited from a parent class: " + f"'{parent_defn.name}'. This is not allowed." + ) + if op.name in operations: + raise ValueError( + f"Operation name '{op.name}' in class '{user_class}' " + f"also occurs in a service definition inherited from a parent class: " + f"'{parent_defn.name}'. This is not allowed." + ) + operations[op.name] = op + + return ServiceDefinition(name=name, operations=operations) + + def _validation_errors(self) -> list[str]: + errors = [] + if not self.name: + errors.append("Service has no name") + seen_method_names = set() + for op in self.operations.values(): + if op.method_name in seen_method_names: + errors.append(f"Operation method name '{op.method_name}' is not unique") + seen_method_names.add(op.method_name) + errors.extend(op._validation_errors()) + return errors + + @staticmethod + def _collect_operations( + user_class: Type[ServiceT], + ) -> dict[str, Operation[Any, Any]]: + """Collect operations from a user service definition class. + + Does not visit parent classes. + """ + + # Form the union of all class attribute names that are either an Operation + # instance or have an Operation type annotation, or both. + operations: dict[str, Operation[Any, Any]] = {} + for k, v in user_class.__dict__.items(): + if isinstance(v, Operation): + operations[k] = v + elif typing.get_origin(v) is Operation: + raise TypeError( + "Operation definitions in the service definition should look like " + "my_op: nexusrpc.Operation[InputType, OutputType]. Did you accidentally " + "use '=' instead of ':'?" + ) + + annotations = { + k: v + for k, v in get_annotations(user_class, eval_str=True).items() + if v == Operation or typing.get_origin(v) == Operation + } + for key in operations.keys() | annotations.keys(): + # If the name has a type annotation, then add the input and output types to + # the operation instance, or create the instance if there was only an + # annotation. + if op_type := annotations.get(key): + args = typing.get_args(op_type) + if len(args) != 2: + raise TypeError( + f"Operation types in the service definition should look like " + f"nexusrpc.Operation[InputType, OutputType], but '{key}' in " + f"'{user_class}' has {len(args)} type parameters." + ) + input_type, output_type = args + if key not in operations: + # It looked like + # my_op: Operation[I, O] + op = operations[key] = Operation( + name=key, + method_name=key, + input_type=input_type, + output_type=output_type, + ) + else: + op = operations[key] + # It looked like + # my_op: Operation[I, O] = Operation(...) + if not op.input_type: + op.input_type = input_type + elif op.input_type != input_type: + raise ValueError( + f"Operation {key} input_type ({op.input_type}) must match type parameter {input_type}" + ) + if not op.output_type: + op.output_type = output_type + elif op.output_type != output_type: + raise ValueError( + f"Operation {key} output_type ({op.output_type}) must match type parameter {output_type}" + ) + else: + # It looked like + # my_op = Operation(...) + op = operations[key] + if not op.method_name: + op.method_name = key + elif op.method_name != key: + raise ValueError( + f"Operation {key} method_name ({op.method_name}) must match attribute name {key}" + ) + + if op.method_name is None: + op.method_name = key + + operations_by_name = {} + for op in operations.values(): + if op.name in operations_by_name: + raise ValueError( + f"Operation '{op.name}' in class '{user_class}' is defined multiple times" + ) + operations_by_name[op.name] = op + return operations_by_name diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py new file mode 100644 index 0000000..ed963f2 --- /dev/null +++ b/src/nexusrpc/_util.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import functools +import inspect +import typing +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type + +from typing_extensions import TypeGuard + +import nexusrpc + +if TYPE_CHECKING: + import nexusrpc + from nexusrpc import InputT, OutputT + from nexusrpc._common import ServiceT + from nexusrpc.handler._operation_handler import OperationHandler + + +def get_service_definition( + obj: Any, +) -> Optional[nexusrpc.ServiceDefinition]: + """Return the :py:class:`nexusrpc.ServiceDefinition` for the object, or None""" + # getattr would allow a non-decorated class to act as a service + # definition if it inherits from a decorated class. + if isinstance(obj, type): + defn = obj.__dict__.get("__nexus_service__") + else: + defn = getattr(obj, "__dict__", {}).get("__nexus_service__") + if defn and not isinstance(defn, nexusrpc.ServiceDefinition): + raise ValueError( + f"Service definition {obj.__name__} has a __nexus_service__ attribute that is not a ServiceDefinition." + ) + return defn + + +def set_service_definition( + cls: Type[ServiceT], service_definition: nexusrpc.ServiceDefinition +) -> None: + """Set the :py:class:`nexusrpc.ServiceDefinition` for this object.""" + if not isinstance(cls, type): + raise TypeError(f"Expected {cls} to be a class, but is {type(cls)}.") + setattr(cls, "__nexus_service__", service_definition) + + +def get_operation_definition( + obj: Any, +) -> Optional[nexusrpc.Operation]: + """Return the :py:class:`nexusrpc.Operation` for the object, or None + + ``obj`` should be a decorated operation start method. + """ + return getattr(obj, "__nexus_operation__", None) + + +def set_operation_definition( + obj: Any, + operation_definition: nexusrpc.Operation, +) -> None: + """Set the :py:class:`nexusrpc.Operation` for this object. + + ``obj`` should be an operation start method. + """ + setattr(obj, "__nexus_operation__", operation_definition) + + +def get_operation_factory( + obj: Any, +) -> tuple[ + Optional[Callable[[Any], OperationHandler[InputT, OutputT]]], + Optional[nexusrpc.Operation[InputT, OutputT]], +]: + """Return the :py:class:`Operation` for the object along with the factory function. + + ``obj`` should be a decorated operation start method. + """ + op_defn = get_operation_definition(obj) + if op_defn: + factory = obj + else: + if factory := getattr(obj, "__nexus_operation_factory__", None): + op_defn = get_operation_definition(factory) + if not isinstance(op_defn, nexusrpc.Operation): + return None, None + return factory, op_defn + + +def set_operation_factory( + obj: Any, + operation_factory: Callable[[Any], OperationHandler[InputT, OutputT]], +) -> None: + """Set the :py:class:`OperationHandler` factory for this object. + + ``obj`` should be an operation start method. + """ + setattr(obj, "__nexus_operation_factory__", operation_factory) + + +# Copied from https://github.com/modelcontextprotocol/python-sdk +# +# Copyright (c) 2024 Anthropic, PBC. +# +# Modified to use TypeGuard. +# +# This file is licensed under the MIT License. +def is_async_callable(obj: Any) -> TypeGuard[Callable[..., Awaitable[Any]]]: + """ + Return True if `obj` is an async callable. + + Supports partials of async callable class instances. + """ + while isinstance(obj, functools.partial): + obj = obj.func + + return inspect.iscoroutinefunction(obj) or ( + callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) + ) + + +def is_callable(obj: Any) -> TypeGuard[Callable[..., Any]]: + """ + Return True if `obj` is a callable. + """ + while isinstance(obj, functools.partial): + obj = obj.func + return inspect.isfunction(obj) or (callable(obj) and hasattr(obj, "__call__")) + + +def get_callable_name(fn: Callable[..., Any]) -> str: + method_name = getattr(fn, "__name__", None) + if not method_name and callable(fn) and hasattr(fn, "__call__"): + method_name = fn.__class__.__name__ + if not method_name: + raise TypeError( + f"Could not determine callable name: " + f"expected {fn} to be a function or callable instance." + ) + return method_name + + +def is_subtype(type1: Type[Any], type2: Type[Any]) -> bool: + # Note that issubclass() argument 2 cannot be a parameterized generic + # TODO(nexus-preview): review desired type compatibility logic + if type1 == type2: + return True + return issubclass(type1, typing.get_origin(type2) or type2) + + +# See +# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older + +try: + from inspect import get_annotations # type: ignore +except ImportError: + import functools + import sys + import types + + # This is inspect.get_annotations from Python 3.13.5 + def get_annotations(obj, *, globals=None, locals=None, eval_str=False): # type: ignore[misc] + """Compute the annotations dict for an object. + + obj may be a callable, class, or module. + Passing in an object of any other type raises TypeError. + + Returns a dict. get_annotations() returns a new dict every time + it's called; calling it twice on the same object will return two + different but equivalent dicts. + + This function handles several details for you: + + * If eval_str is true, values of type str will + be un-stringized using eval(). This is intended + for use with stringized annotations + ("from __future__ import annotations"). + * If obj doesn't have an annotations dict, returns an + empty dict. (Functions and methods always have an + annotations dict; classes, modules, and other types of + callables may not.) + * Ignores inherited annotations on classes. If a class + doesn't have its own annotations dict, returns an empty dict. + * All accesses to object members and dict values are done + using getattr() and dict.get() for safety. + * Always, always, always returns a freshly-created dict. + + eval_str controls whether or not values of type str are replaced + with the result of calling eval() on those values: + + * If eval_str is true, eval() is called on values of type str. + * If eval_str is false (the default), values of type str are unchanged. + + globals and locals are passed in to eval(); see the documentation + for eval() for more information. If either globals or locals is + None, this function may replace that value with a context-specific + default, contingent on type(obj): + + * If obj is a module, globals defaults to obj.__dict__. + * If obj is a class, globals defaults to + sys.modules[obj.__module__].__dict__ and locals + defaults to the obj class namespace. + * If obj is a callable, globals defaults to obj.__globals__, + although if obj is a wrapped function (using + functools.update_wrapper()) it is first unwrapped. + """ + if isinstance(obj, type): + # class + obj_dict = getattr(obj, "__dict__", None) + if obj_dict and hasattr(obj_dict, "get"): + ann = obj_dict.get("__annotations__", None) + if isinstance(ann, types.GetSetDescriptorType): + ann = None + else: + ann = None + + obj_globals = None + module_name = getattr(obj, "__module__", None) + if module_name: + module = sys.modules.get(module_name, None) + if module: + obj_globals = getattr(module, "__dict__", None) + obj_locals = dict(vars(obj)) + unwrap = obj + elif isinstance(obj, types.ModuleType): + # module + ann = getattr(obj, "__annotations__", None) + obj_globals = getattr(obj, "__dict__") + obj_locals = None + unwrap = None + elif callable(obj): + # this includes types.Function, types.BuiltinFunctionType, + # types.BuiltinMethodType, functools.partial, functools.singledispatch, + # "class funclike" from Lib/test/test_inspect... on and on it goes. + ann = getattr(obj, "__annotations__", None) + obj_globals = getattr(obj, "__globals__", None) + obj_locals = None + unwrap = obj + else: + raise TypeError(f"{obj!r} is not a module, class, or callable.") + + if ann is None: + return {} + + if not isinstance(ann, dict): + raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") + + if not ann: + return {} + + if not eval_str: + return dict(ann) + + if unwrap is not None: + while True: + if hasattr(unwrap, "__wrapped__"): + unwrap = unwrap.__wrapped__ # type: ignore + continue + if isinstance(unwrap, functools.partial): + unwrap = unwrap.func # type: ignore + continue + break + if hasattr(unwrap, "__globals__"): + obj_globals = unwrap.__globals__ # type: ignore + + if globals is None: + globals = obj_globals + if locals is None: + locals = obj_locals or {} + + # "Inject" type parameters into the local namespace + # (unless they are shadowed by assignments *in* the local namespace), + # as a way of emulating annotation scopes when calling `eval()` + if type_params := getattr(obj, "__type_params__", ()): + locals = {param.__name__: param for param in type_params} | locals + + return_value = { + key: value if not isinstance(value, str) else eval(value, globals, locals) + for key, value in ann.items() + } + return return_value + + +get_annotations = get_annotations diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py new file mode 100644 index 0000000..af54049 --- /dev/null +++ b/src/nexusrpc/handler/__init__.py @@ -0,0 +1,38 @@ +""" +Components for implementing Nexus handlers. + +Server/worker authors will use this module to create the top-level Nexus handlers +responsible for dispatching requests to Nexus operations. + +Nexus service/operation authors will use this module to implement operation handler +methods within service handler classes. +""" + +from __future__ import annotations + +from ._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from ._core import Handler as Handler +from ._decorators import service_handler, sync_operation +from ._operation_handler import OperationHandler as OperationHandler + +__all__ = [ + "CancelOperationContext", + "FetchOperationInfoContext", + "FetchOperationResultContext", + "Handler", + "OperationContext", + "OperationHandler", + "service_handler", + "StartOperationContext", + "StartOperationResultAsync", + "StartOperationResultSync", + "sync_operation", +] diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py new file mode 100644 index 0000000..bac6b4e --- /dev/null +++ b/src/nexusrpc/handler/_common.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass, field +from datetime import timedelta +from typing import ( + Generic, + Mapping, + Optional, + Sequence, +) + +from nexusrpc._common import Link, OutputT + + +@dataclass(frozen=True) +class OperationContext(ABC): + """Context for the execution of the requested operation method. + + Includes information from the request.""" + + def __new__(cls, *args, **kwargs): + if cls is OperationContext: + raise TypeError( + "OperationContext is an abstract class and cannot be instantiated directly" + ) + return super().__new__(cls) + + service: str + """ + The name of the service that the operation belongs to. + """ + + operation: str + """ + The name of the operation. + """ + + headers: Mapping[str, str] + """ + Optional header fields sent by the caller. + """ + + +@dataclass(frozen=True) +class StartOperationContext(OperationContext): + """Context for the start method. + + Includes information from the request.""" + + request_id: str + """ + Request ID that may be used by the handler to dedupe a start request. + By default a v4 UUID should be generated by the client. + """ + + callback_url: Optional[str] = None + """ + A callback URL is required to deliver the completion of an async operation. This URL should be + called by a handler upon completion if the started operation is async. + """ + + callback_headers: Mapping[str, str] = field(default_factory=dict) + """ + Optional header fields set by the caller to be attached to the callback request when an + asynchronous operation completes. + """ + + inbound_links: Sequence[Link] = field(default_factory=list) + """ + Links received in the request. This list is automatically populated when handling a start + request. Handlers may use these links, for example to add information about the + caller to a resource associated with the operation execution. + """ + + outbound_links: list[Link] = field(default_factory=list) + """ + Links to be returned by the handler. This list is initially empty. Handlers may + populate this list, for example with links describing resources associated with the + operation execution that may be of use to the caller. + """ + + +@dataclass(frozen=True) +class CancelOperationContext(OperationContext): + """Context for the cancel method. + + Includes information from the request.""" + + +@dataclass(frozen=True) +class FetchOperationInfoContext(OperationContext): + """Context for the fetch_info method. + + Includes information from the request.""" + + +@dataclass(frozen=True) +class FetchOperationResultContext(OperationContext): + """Context for the fetch_result method. + + Includes information from the request.""" + + wait: Optional[timedelta] = None + """ + Allowed time to wait for the operation result (long poll). If by the end of the + wait period the operation is still running, a response with 412 status code will + be returned, and the caller may re-issue the request to start a new long poll. + """ + + +@dataclass(frozen=True) +class StartOperationResultSync(Generic[OutputT]): + """ + A result returned synchronously by the start method of a nexus operation handler. + """ + + value: OutputT # type: ignore[misc] + """ + The value returned by the operation. + """ + + +@dataclass(frozen=True) +class StartOperationResultAsync: + """ + A value returned by the start method of a nexus operation handler indicating that + the operation is responding asynchronously. + """ + + token: str + """ + A token representing the in-progress operation that the caller can submit with + subsequent ``fetch_info``, ``fetch_result``, or ``cancel`` requests. + """ diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py new file mode 100644 index 0000000..79f4d70 --- /dev/null +++ b/src/nexusrpc/handler/_core.py @@ -0,0 +1,459 @@ +""" +This module contains Handler classes. A Handler manages a collection of Nexus +service handlers. It receives and responds to incoming Nexus requests, dispatching to +the corresponding operation handler. + +A description of the dispatch logic follows. + +There are two cases: + +Case 1: Every user service handler class has a corresponding service definition +=============================================================================== + +I.e., there are service definitions that look like:: + + @service + class MyServiceDefinition: + my_op: nexusrpc.Operation[I, O] + + +and every service handler class looks like:: + + @service_handler(service=MyServiceDefinition) + class MyServiceHandler: + @sync_operation + def my_op(self, ...) + + +Import time +----------- + +1. The @service decorator builds a ServiceDefinition instance and attaches it to + MyServiceDefinition. + + The ServiceDefinition contains `name` and a map of Operation instances, + keyed by Operation.name (this is the publicly advertised name). + + An Operation contains `name`, `method_name`, and input and output types. + +2. The @sync_operation decorator builds a second Operation instance and attaches + it to a factory method that is attached to the my_op method object. + +3. The @service_handler decorator acquires the ServiceDefinition instance from + MyServiceDefinition and attaches it to the MyServiceHandler class. + + +Handler-registration time +------------------------- + +1. Handler.__init__ is called with [MyServiceHandler()] + +2. A ServiceHandler instance is built from the user service handler class. This comprises a + ServiceDefinition and a map {op.name: OperationHandler}. The map is built by taking + every operation in the service definition and locating the operation handler factory method + whose *method name* matches the method name of the operation in the service definition. + +3. Finally we build a map {service_definition.name: ServiceHandler} using the service definition + in each ServiceHandler. + +Request-handling time +--------------------- + +Now suppose a request has arrived for service S and operation O. + +1. The Handler does self.service_handlers[S], yielding an instance of ServiceHandler. + +2. The ServiceHandler does self.operation_handlers[O], yielding an instance of + OperationHandler + +Therefore we require that Handler.service_handlers and ServiceHandler.operation_handlers +are keyed by the publicly advertised service and operation name respectively. This was achieved +at steps (3) and (2) respectively. + + +Case 2: There exists a user service handler class without a corresponding service definition +============================================================================================ + +I.e., at least one user service handler class looks like:: + + @service_handler + class MyServiceHandler: + @sync_operation + def my_op(...) + +This follows Case 1 with the following differences at import time: + +- Step (1) does not occur. +- At step (3) the ServiceDefinition is synthesized by the @service_handler decorator from + MyServiceHandler. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ( + Any, + Awaitable, + Callable, + Mapping, + Optional, + Sequence, + Union, +) + +from typing_extensions import Self, TypeGuard + +from nexusrpc._common import HandlerError, HandlerErrorType, OperationInfo +from nexusrpc._serializer import LazyValueT +from nexusrpc._service import ServiceDefinition +from nexusrpc._util import get_service_definition, is_async_callable + +from ._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from ._operation_handler import ( + OperationHandler, + collect_operation_handler_factories_by_method_name, +) + + +class AbstractHandler(ABC): + @abstractmethod + def start_operation( + self, + ctx: StartOperationContext, + input: LazyValueT, + ) -> Union[ + StartOperationResultSync[Any], + StartOperationResultAsync, + Awaitable[ + Union[ + StartOperationResultSync[Any], + StartOperationResultAsync, + ] + ], + ]: ... + + @abstractmethod + def fetch_operation_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> Union[OperationInfo, Awaitable[OperationInfo]]: + """Handle a Fetch Operation Info request. + + Args: + ctx: The operation context. + token: The operation token. + """ + ... + + @abstractmethod + def fetch_operation_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Union[Any, Awaitable[Any]]: + """Handle a Fetch Operation Result request. + + Args: + ctx: The operation context. + token: The operation token. + """ + ... + + @abstractmethod + def cancel_operation( + self, ctx: CancelOperationContext, token: str + ) -> Union[None, Awaitable[None]]: + """Handle a Cancel Operation request. + + Args: + ctx: The operation context. + token: The operation token. + """ + ... + + +class BaseServiceCollectionHandler(AbstractHandler): + """ + A Nexus handler, managing a collection of Nexus service handlers. + + Operation requests are delegated to a :py:class:`ServiceHandler` based on the service + name in the operation context. + """ + + def __init__( + self, + user_service_handlers: Sequence[Any], + executor: Optional[concurrent.futures.Executor] = None, + ): + """Initialize a :py:class:`Handler` instance from user service handler instances. + + The user service handler instances must have been decorated with the + :py:func:`@nexusrpc.handler.service_handler` decorator. + + Args: + user_service_handlers: A sequence of user service handlers. + executor: A concurrent.futures.Executor in which to run non-`async def` operation handlers. + """ + self.executor = _Executor(executor) if executor else None + self.service_handlers = self._register_service_handlers(user_service_handlers) + + def _register_service_handlers( + self, user_service_handlers: Sequence[Any] + ) -> Mapping[str, ServiceHandler]: + service_handlers = {} + for sh in user_service_handlers: + if isinstance(sh, type): + raise TypeError( + f"Expected a service instance, but got a class: {type(sh)}. " + "Nexus service handlers must be supplied as instances, not classes." + ) + # Users may register ServiceHandler instances directly. + if not isinstance(sh, ServiceHandler): + # It must be a user service handler instance (i.e. an instance of a class + # decorated with @nexusrpc.handler.service_handler). + sh = ServiceHandler.from_user_instance(sh) + if sh.service.name in service_handlers: + raise RuntimeError( + f"Service '{sh.service.name}' has already been registered." + ) + service_handlers[sh.service.name] = sh + return service_handlers + + def _get_service_handler(self, service_name: str) -> ServiceHandler: + """Return a service handler, given the service name.""" + service = self.service_handlers.get(service_name) + if service is None: + raise HandlerError( + f"No handler for service '{service_name}'.", + type=HandlerErrorType.NOT_FOUND, + ) + return service + + +class Handler(BaseServiceCollectionHandler): + """ + A Nexus handler manages a collection of Nexus service handlers. + + Operation requests are dispatched to a :py:class:`ServiceHandler` based on the + service name in the operation context. + + This class supports user operation handlers that are either `async def` or `def`. If + `def` user operation handlers are to be supported, an executor must be provided. + + The methods of this class itself are `async def`. For a handler class with `def` + methods, see :py:class:`nexusrpc.syncio.Handler`. + + Example: + .. code-block:: python + + import concurrent.futures + from nexusrpc.handler import Handler + + # Create service handler instances + my_service = MyServiceHandler() + + # Create handler with async operations only + handler = Handler([my_service]) + + # Create handler that supports both async and sync operations + executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) + handler = Handler([my_service], executor=executor) + + # Use handler to process requests + result = await handler.start_operation(ctx, input_lazy_value) + """ + + def __init__( + self, + user_service_handlers: Sequence[Any], + executor: Optional[concurrent.futures.Executor] = None, + ): + super().__init__(user_service_handlers, executor=executor) + if not self.executor: + self._validate_all_operation_handlers_are_async() + + async def start_operation( + self, + ctx: StartOperationContext, + input: LazyValueT, + ) -> Union[ + StartOperationResultSync[Any], + StartOperationResultAsync, + ]: + """Handle a Start Operation request. + + Args: + ctx: The operation context. + input: The input to the operation, as a LazyValue. + """ + service_handler = self._get_service_handler(ctx.service) + op_handler = service_handler._get_operation_handler(ctx.operation) + op = service_handler.service.operations[ctx.operation] + deserialized_input = await input.consume(as_type=op.input_type) + # TODO(preview): apply middleware stack + if is_async_callable(op_handler.start): + return await op_handler.start(ctx, deserialized_input) + else: + assert self.executor + return await self.executor.submit_to_event_loop( + op_handler.start, ctx, deserialized_input + ) + + async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: + """Handle a Cancel Operation request. + + Args: + ctx: The operation context. + token: The operation token. + """ + service_handler = self._get_service_handler(ctx.service) + op_handler = service_handler._get_operation_handler(ctx.operation) + if is_async_callable(op_handler.cancel): + return await op_handler.cancel(ctx, token) + else: + assert self.executor + return self.executor.submit(op_handler.cancel, ctx, token).result() + + async def fetch_operation_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> OperationInfo: + service_handler = self._get_service_handler(ctx.service) + op_handler = service_handler._get_operation_handler(ctx.operation) + if is_async_callable(op_handler.fetch_info): + return await op_handler.fetch_info(ctx, token) + else: + assert self.executor + return self.executor.submit(op_handler.fetch_info, ctx, token).result() + + async def fetch_operation_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Any: + if ctx.wait is not None or ctx.headers.get("request-timeout"): + raise NotImplementedError( + "The Nexus SDK is in pre-release and does not support the fetch result " + "wait parameter or request-timeout header." + ) + service_handler = self._get_service_handler(ctx.service) + op_handler = service_handler._get_operation_handler(ctx.operation) + if is_async_callable(op_handler.fetch_result): + return await op_handler.fetch_result(ctx, token) + else: + assert self.executor + return self.executor.submit(op_handler.fetch_result, ctx, token).result() + + def _validate_all_operation_handlers_are_async(self) -> None: + for service_handler in self.service_handlers.values(): + for op_handler in service_handler.operation_handlers.values(): + self._assert_async_callable(op_handler.start) + self._assert_async_callable(op_handler.cancel) + self._assert_async_callable(op_handler.fetch_info) + self._assert_async_callable(op_handler.fetch_result) + + def _assert_async_callable( + self, method: Callable[..., Any] + ) -> TypeGuard[Callable[..., Awaitable[Any]]]: + if not is_async_callable(method): + raise RuntimeError( + f"Operation handler method {method} is not an `async def` method, " + f"but you are using nexusrpc.handler.Handler, which is for " + "`async def` methods. Use nexusrpc.syncio.handler.Handler instead." + ) + return True + + +@dataclass(frozen=True) +class ServiceHandler: + """Internal representation of a user's Nexus service implementation instance. + + A user's service implementation is a class decorated with + :py:func:`@nexusrpc.handler.service_handler` that defines operation handler methods + using decorators such as :py:func:`@nexusrpc.handler.operation_handler`. + + Instances of this class are created automatically from user service implementation instances + on creation of a Handler instance, at Nexus handler start time. While the user's class + defines operation handlers as factory methods to be called at handler start time, this + class contains the :py:class:`OperationHandler` instances themselves. + + You may create instances of this class manually and pass them to the Handler + constructor, for example when programmatically creating Nexus service implementations. + """ + + service: ServiceDefinition + operation_handlers: dict[str, OperationHandler[Any, Any]] + + @classmethod + def from_user_instance(cls, user_instance: Any) -> Self: + """Create a :py:class:`ServiceHandler` from a user service instance.""" + + service = get_service_definition(user_instance.__class__) + if not isinstance(service, ServiceDefinition): + raise RuntimeError( + f"Service '{user_instance}' does not have a service definition. " + f"Use the :py:func:`@nexusrpc.handler.service_handler` decorator on your class to define " + f"a Nexus service implementation." + ) + + # Construct a map of operation handlers keyed by the op name from the service + # definition (i.e. by the name by which the operation can be requested) + factories_by_method_name = collect_operation_handler_factories_by_method_name( + user_instance.__class__, service + ) + op_handlers = { + op_name: factories_by_method_name[op.method_name](user_instance) + for op_name, op in service.operations.items() + # TODO(preview): op.method_name should be non-nullable + if op.method_name + } + return cls( + service=service, + operation_handlers=op_handlers, + ) + + def _get_operation_handler(self, operation: str) -> OperationHandler[Any, Any]: + """Return an operation handler, given the operation name.""" + if operation not in self.service.operations: + raise HandlerError( + f"Nexus service definition '{self.service.name}' has no operation " + f"'{operation}'. There are {len(self.service.operations)} operations " + f"in the definition.", + type=HandlerErrorType.NOT_FOUND, + ) + operation_handler = self.operation_handlers.get(operation) + if operation_handler is None: + raise HandlerError( + f"Nexus service implementation '{self.service.name}' has no handler for " + f"operation '{operation}'. There are {len(self.operation_handlers)} " + f"available operation handlers.", + type=HandlerErrorType.NOT_FOUND, + ) + return operation_handler + + +class _Executor: + """An executor for synchronous functions.""" + + # Users are require to pass in a `concurrent.futures.Executor` in order to use + # non-`async def`s. This is what `run_in_executor` is documented to require. + # This means that nexusrpc initially has a hard-coded dependency on the asyncio + # event loop; if necessary we can add the ability to pass in an event loop + # implementation at the level of Handler. + + def __init__(self, executor: concurrent.futures.Executor): + self._executor = executor + + def submit_to_event_loop( + self, fn: Callable[..., Any], *args: Any + ) -> Awaitable[Any]: + return asyncio.get_event_loop().run_in_executor(self._executor, fn, *args) + + def submit( + self, fn: Callable[..., Any], *args: Any + ) -> concurrent.futures.Future[Any]: + return self._executor.submit(fn, *args) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py new file mode 100644 index 0000000..d8dd5d7 --- /dev/null +++ b/src/nexusrpc/handler/_decorators.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import typing +import warnings +from typing import ( + Any, + Awaitable, + Callable, + Optional, + Type, + TypeVar, + Union, + overload, +) + +from nexusrpc._common import InputT, OutputT, ServiceHandlerT +from nexusrpc._service import Operation +from nexusrpc._util import ( + get_callable_name, + get_service_definition, + is_async_callable, + set_operation_definition, + set_operation_factory, + set_service_definition, +) +from nexusrpc.handler._common import StartOperationContext +from nexusrpc.handler._util import ( + get_start_method_input_and_output_type_annotations, +) + +from ._operation_handler import ( + OperationHandler, + SyncOperationHandler, + collect_operation_handler_factories_by_method_name, + service_definition_from_operation_handler_methods, + validate_operation_handler_methods, +) + + +@overload +def service_handler(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: ... + + +# TODO(preview): allow service to be provided as positional argument? +@overload +def service_handler( + *, + service: Optional[Type[Any]] = None, +) -> Callable[[Type[ServiceHandlerT]], Type[ServiceHandlerT]]: ... + + +@overload +def service_handler( + *, name: str +) -> Callable[[Type[ServiceHandlerT]], Type[ServiceHandlerT]]: ... + + +def service_handler( + cls: Optional[Type[ServiceHandlerT]] = None, + *, + service: Optional[Type[Any]] = None, + name: Optional[str] = None, +) -> Union[ + Type[ServiceHandlerT], Callable[[Type[ServiceHandlerT]], Type[ServiceHandlerT]] +]: + """Decorator that marks a class as a Nexus service handler. + + A service handler is a class that implements the Nexus service by providing + operation handler implementations for all operations in the service. + + The class should implement Nexus operation handlers as methods decorated with + operation handler decorators such as :py:func:`@nexusrpc.handler.operation_handler`. + + Args: + cls: The service handler class to decorate. + service: The service definition that the service handler implements. + name: Optional name to use for the service, if a service definition is not provided. + `service` and `name` are mutually exclusive. If neither is provided, the + class name will be used. + + Example: + .. code-block:: python + + from nexusrpc.handler import service_handler, sync_operation + + @service_handler(service=MyService) + class MyServiceHandler: + @sync_operation + async def my_operation( + self, ctx: StartOperationContext, input: MyInput + ) -> MyOutput: + return MyOutput(processed=input.data) + """ + if service and name: + raise ValueError( + "You cannot specify both service and name: " + "if you provide a service then the name will be taken from the service." + ) + _service = None + if service: + _service = get_service_definition(service) + if not _service: + raise ValueError( + f"{service} is not a valid Nexus service definition. " + f"Use the @nexusrpc.service decorator on a class to define a Nexus service definition." + ) + + def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: + # The name by which the service must be addressed in Nexus requests. + _name = ( + _service.name if _service else name if name is not None else cls.__name__ + ) + if not _name: + raise ValueError("Service name must not be empty.") + factories_by_method_name = collect_operation_handler_factories_by_method_name( + cls, _service + ) + service = _service or service_definition_from_operation_handler_methods( + _name, factories_by_method_name + ) + validate_operation_handler_methods(cls, factories_by_method_name, service) + set_service_definition(cls, service) + return cls + + if cls is None: + return decorator + + return decorator(cls) + + +OperationHandlerFactoryT = TypeVar( + "OperationHandlerFactoryT", bound=Callable[[Any], OperationHandler[Any, Any]] +) + + +@overload +def operation_handler( + method: OperationHandlerFactoryT, +) -> OperationHandlerFactoryT: ... + + +@overload +def operation_handler( + *, name: Optional[str] = None +) -> Callable[[OperationHandlerFactoryT], OperationHandlerFactoryT]: ... + + +def operation_handler( + method: Optional[OperationHandlerFactoryT] = None, + *, + name: Optional[str] = None, +) -> Union[ + OperationHandlerFactoryT, + Callable[[OperationHandlerFactoryT], OperationHandlerFactoryT], +]: + """ + Decorator marking an operation handler factory method in a service handler class. + + An operation handler factory method is a method that takes no arguments other than + `self` and returns an :py:class:`OperationHandler` instance. + + Args: + method: The method to decorate. + name: Optional name for the operation. If not provided, the method name will be used. + """ + + def decorator( + method: OperationHandlerFactoryT, + ) -> OperationHandlerFactoryT: + # Extract input and output types from the return type annotation + input_type = None + output_type = None + + return_type = typing.get_type_hints(method).get("return") + if typing.get_origin(return_type) == OperationHandler: + type_args = typing.get_args(return_type) + if len(type_args) == 2: + input_type, output_type = type_args + else: + warnings.warn( + f"OperationHandler return type should have two type parameters (input and output type), " + f"but operation {method.__name__} has {len(type_args)} type parameters: {type_args}" + ) + + set_operation_definition( + method, + Operation( + name=name or method.__name__, + method_name=method.__name__, + input_type=input_type, + output_type=output_type, + ), + ) + return method + + if method is None: + return decorator + + return decorator(method) + + +@overload +def sync_operation( + start: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + ], +) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: ... + + +@overload +def sync_operation( + *, + name: Optional[str] = None, +) -> Callable[ + [Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]], + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], +]: ... + + +def sync_operation( + start: Optional[ + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]] + ] = None, + *, + name: Optional[str] = None, +) -> Union[ + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], + Callable[ + [ + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + ] + ], + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], + ], +]: + """ + Decorator marking a method as the start method for a synchronous operation. + + Example: + .. code-block:: python + + import httpx + from nexusrpc.handler import service_handler, sync_operation + + @service_handler + class MyServiceHandler: + @sync_operation + async def process_data( + self, ctx: StartOperationContext, input: str + ) -> str: + # You can use asynchronous I/O libraries + async with httpx.AsyncClient() as client: + response = await client.get("https://api.example.com/data") + + data = response.json() + return f"Processed: {data}" + """ + + def decorator( + start: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + ], + ) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: + if not is_async_callable(start): + raise TypeError( + "sync_operation decorator must be used on an `async def` operation method. " + "To use a `def` method see `@nexusrpc.syncio.handler.sync_operation`." + ) + + def operation_handler_factory( + self: ServiceHandlerT, + ) -> OperationHandler[InputT, OutputT]: + async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: + return await start(self, ctx, input) + + _start.__doc__ = start.__doc__ + return SyncOperationHandler(_start) + + input_type, output_type = get_start_method_input_and_output_type_annotations( # type: ignore[var-annotated] + start # type: ignore[arg-type] + ) + + method_name = get_callable_name(start) + set_operation_definition( + operation_handler_factory, + Operation( + name=name or method_name, + method_name=method_name, + input_type=input_type, + output_type=output_type, + ), + ) + + set_operation_factory(start, operation_handler_factory) + return start + + if start is None: + return decorator + + return decorator(start) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py new file mode 100644 index 0000000..8f992db --- /dev/null +++ b/src/nexusrpc/handler/_operation_handler.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import inspect +from abc import ABC, abstractmethod +from typing import ( + Any, + Awaitable, + Callable, + Generic, + Optional, + Type, + Union, +) + +from nexusrpc._common import InputT, OperationInfo, OutputT, ServiceHandlerT +from nexusrpc._service import Operation, ServiceDefinition +from nexusrpc._util import ( + get_operation_factory, + is_async_callable, + is_callable, + is_subtype, +) + +from ._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) + + +class OperationHandler(ABC, Generic[InputT, OutputT]): + """ + Base class for an operation handler in a Nexus service implementation. + + To define a Nexus operation handler, create a method on your service handler class + that takes `self` and returns an instance of :py:class:`OperationHandler`, and apply + the :py:func:`@nexusrpc.handler.operation_handler` decorator. + + To create an operation handler that is limited to returning synchronously, use + :py:func:`@nexusrpc.handler.SyncOperationHandler` to create the + instance of :py:class:`OperationHandler` from the start method. + """ + + @abstractmethod + def start( + self, ctx: StartOperationContext, input: InputT + ) -> Union[ + StartOperationResultSync[OutputT], + Awaitable[StartOperationResultSync[OutputT]], + StartOperationResultAsync, + Awaitable[StartOperationResultAsync], + ]: + """ + Start the operation, completing either synchronously or asynchronously. + + Returns the result synchronously, or returns an operation token. Which path is + taken may be decided at operation handling time. + """ + ... + + @abstractmethod + def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> Union[OperationInfo, Awaitable[OperationInfo]]: + """ + Return information about the current status of the operation. + """ + ... + + @abstractmethod + def fetch_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Union[OutputT, Awaitable[OutputT]]: + """ + Return the result of the operation. + """ + ... + + @abstractmethod + def cancel( + self, ctx: CancelOperationContext, token: str + ) -> Union[None, Awaitable[None]]: + """ + Cancel the operation. + """ + ... + + +class SyncOperationHandler(OperationHandler[InputT, OutputT]): + """ + An :py:class:`OperationHandler` that is limited to responding synchronously. + + This version of the class uses `async def` methods. For the syncio version, see + :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`. + """ + + def __init__( + self, start: Callable[[StartOperationContext, InputT], Awaitable[OutputT]] + ): + if not is_async_callable(start): + raise RuntimeError( + f"{start} is not an `async def` method. " + "SyncOperationHandler must be initialized with an `async def` method. " + "To use `def` methods, see :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`." + ) + self._start = start + if start.__doc__: + if start_func := getattr(self.start, "__func__", None): + start_func.__doc__ = start.__doc__ + + async def start( + self, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + """ + Start the operation and return its final result synchronously. + + The name 'SyncOperationHandler' means that it responds synchronously in the + sense that the start method delivers the final operation result as its return + value, rather than returning an operation token representing an in-progress + operation. This version of the class uses `async def` methods. For the syncio + version, see :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`. + """ + return StartOperationResultSync(await self._start(ctx, input)) + + async def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> OperationInfo: + raise NotImplementedError( + "Cannot fetch operation info for an operation that responded synchronously." + ) + + async def fetch_result( + self, ctx: FetchOperationResultContext, token: str + ) -> OutputT: + raise NotImplementedError( + "Cannot fetch the result of an operation that responded synchronously." + ) + + async def cancel(self, ctx: CancelOperationContext, token: str) -> None: + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + +def collect_operation_handler_factories_by_method_name( + user_service_cls: Type[ServiceHandlerT], + service: Optional[ServiceDefinition], +) -> dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]]: + """ + Collect operation handler methods from a user service handler class. + """ + factories: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]] = {} + service_method_names = ( + { + op.method_name + for op in service.operations.values() + if op.method_name is not None + } + if service + else set() + ) + seen = set() + for _, method in inspect.getmembers(user_service_cls, is_callable): + factory, op_defn = get_operation_factory(method) # type: ignore[var-annotated] + if factory and isinstance(op_defn, Operation): + # This is a method decorated with one of the *operation_handler decorators + if op_defn.name in seen: + raise RuntimeError( + f"Operation '{op_defn.name}' in service '{user_service_cls.__name__}' " + f"is defined multiple times." + ) + if service and op_defn.method_name not in service_method_names: + _names = ", ".join(f"'{s}'" for s in sorted(service_method_names)) + msg = ( + f"Operation method name '{op_defn.method_name}' in service handler {user_service_cls} " + f"does not match an operation method name in the service definition. " + f"Available method names in the service definition: " + ) + msg += _names if _names else "[none]" + msg += "." + raise TypeError(msg) + + # TODO(preview) op_defn.method name should be non-nullable + assert op_defn.method_name, ( + f"Operation '{op_defn}' method name should not be None. This is an SDK bug." + ) + factories[op_defn.method_name] = factory + seen.add(op_defn.name) + return factories + + +def validate_operation_handler_methods( + user_service_cls: Type[ServiceHandlerT], + user_methods_by_method_name: dict[ + str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]] + ], + service_definition: ServiceDefinition, +) -> None: + """Validate operation handler methods against a service definition. + + For every operation in ``service_definition``: + + 1. There must be a method in ``user_methods`` whose method name matches the method + name from the service definition. + + 2. The input and output types of the user method must be such that the user method + is a subtype of the operation defined in the service definition, i.e. respecting + input type contravariance and output type covariance. + """ + user_methods_by_method_name = user_methods_by_method_name.copy() + for op_defn in service_definition.operations.values(): + if not op_defn.method_name: + raise ValueError( + f"Operation '{op_defn}' in service definition '{service_definition}' " + f"does not have a method name. " + ) + method = user_methods_by_method_name.pop(op_defn.method_name, None) + if not method: + raise TypeError( + f"Service '{user_service_cls}' does not implement an operation with " + f"method name '{op_defn.method_name}'. But this operation is in service " + f"definition '{service_definition}'." + ) + method, method_op_defn = get_operation_factory(method) + if not isinstance(method_op_defn, Operation): + raise ValueError( + f"Method '{method}' in class '{user_service_cls.__name__}' " + f"does not have a valid __nexus_operation__ attribute. " + f"Did you forget to decorate the operation method with an operation handler decorator such as " + f":py:func:`@nexusrpc.handler.operation_handler`?" + ) + if method_op_defn.name not in [method_op_defn.method_name, op_defn.name]: + raise TypeError( + f"Operation '{op_defn.method_name}' in service '{user_service_cls}' " + f"has name '{method_op_defn.name}', but the name in the service definition " + f"is '{op_defn.name}'. Operation handlers may not override the name of an operation " + f"in the service definition." + ) + # Input type is contravariant: op handler input must be superclass of op defn output + if ( + method_op_defn.input_type is not None + and op_defn.input_type is not None + and Any not in (method_op_defn.input_type, op_defn.input_type) + and not ( + op_defn.input_type == method_op_defn.input_type + or is_subtype(op_defn.input_type, method_op_defn.input_type) + ) + ): + raise TypeError( + f"Operation '{op_defn.method_name}' in service '{user_service_cls}' " + f"has input type '{method_op_defn.input_type}', which is not " + f"compatible with the input type '{op_defn.input_type}' in interface " + f"'{service_definition.name}'. The input type must be the same as or a " + f"superclass of the operation definition input type." + ) + + # Output type is covariant: op handler output must be subclass of op defn output + if ( + method_op_defn.output_type is not None + and op_defn.output_type is not None + and Any not in (method_op_defn.output_type, op_defn.output_type) + and not is_subtype(method_op_defn.output_type, op_defn.output_type) + ): + raise TypeError( + f"Operation '{op_defn.method_name}' in service '{user_service_cls}' " + f"has output type '{method_op_defn.output_type}', which is not " + f"compatible with the output type '{op_defn.output_type}' in interface " + f" '{service_definition}'. The output type must be the same as or a " + f"subclass of the operation definition output type." + ) + if user_methods_by_method_name: + raise ValueError( + f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " + f"Extra operations: {', '.join(sorted(user_methods_by_method_name.keys()))}." + ) + + +def service_definition_from_operation_handler_methods( + service_name: str, + user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], +) -> ServiceDefinition: + """ + Create a service definition from operation handler factory methods. + + In general, users should have access to, or define, a service definition, and validate + their service handler against it by passing the service definition to the + :py:func:`@nexusrpc.handler.service_handler` decorator. This function is used when + that is not the case. + """ + op_defns: dict[str, Operation[Any, Any]] = {} + for name, method in user_methods.items(): + _, op_defn = get_operation_factory(method) + if not isinstance(op_defn, Operation): + raise ValueError( + f"In service '{service_name}', could not locate operation definition for " + f"user operation handler method '{name}'. Did you forget to decorate the operation " + f"method with an operation handler decorator such as " + f":py:func:`@nexusrpc.handler.operation_handler`?" + ) + op_defns[op_defn.name] = op_defn + + return ServiceDefinition(name=service_name, operations=op_defns) diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py new file mode 100644 index 0000000..6474c7e --- /dev/null +++ b/src/nexusrpc/handler/_util.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import typing +import warnings +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + Optional, + Type, + TypeVar, + Union, +) + +from nexusrpc.handler import StartOperationContext + +if TYPE_CHECKING: + from nexusrpc import InputT, OutputT + + +ServiceHandlerT = TypeVar("ServiceHandlerT") + + +def get_start_method_input_and_output_type_annotations( + start: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], +) -> tuple[ + Optional[Type[InputT]], + Optional[Type[OutputT]], +]: + """Return operation input and output types. + + `start` must be a type-annotated start method that returns a synchronous result. + """ + try: + type_annotations = typing.get_type_hints(start) + except TypeError: + # TODO(preview): stacklevel + warnings.warn( + f"Expected decorated start method {start} to have type annotations" + ) + return None, None + output_type = type_annotations.pop("return", None) + + if len(type_annotations) != 2: + # TODO(preview): stacklevel + suffix = f": {type_annotations}" if type_annotations else "" + warnings.warn( + f"Expected decorated start method {start} to have exactly 2 " + f"type-annotated parameters (ctx and input), but it has {len(type_annotations)}" + f"{suffix}." + ) + input_type = None + else: + ctx_type, input_type = type_annotations.values() + if not issubclass(ctx_type, StartOperationContext): + # TODO(preview): stacklevel + warnings.warn( + f"Expected first parameter of {start} to be an instance of " + f"StartOperationContext, but is {ctx_type}." + ) + input_type = None + + return input_type, output_type diff --git a/src/nexus_rpc/py.typed b/src/nexusrpc/py.typed similarity index 100% rename from src/nexus_rpc/py.typed rename to src/nexusrpc/py.typed diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py new file mode 100644 index 0000000..7de40be --- /dev/null +++ b/src/nexusrpc/syncio/__init__.py @@ -0,0 +1,19 @@ +""" +Components for implementing Nexus handlers that use synchronous I/O. + +By default the components of the nexusrpc library use asynchronous I/O (`async def`). +This module provides alternative components based on traditional synchronous I/O +(`def`). + +Server/worker authors will use this module to create top-level Nexus handlers that +expose `def` methods such as `start_operation` and `cancel_operation`. + +Nexus service/operation authors will use this module to obtain a synchronous I/O +version of the `sync_operation` decorator. +""" + +from ._serializer import LazyValue + +__all__ = [ + "LazyValue", +] diff --git a/src/nexusrpc/syncio/_serializer.py b/src/nexusrpc/syncio/_serializer.py new file mode 100644 index 0000000..3cb9b18 --- /dev/null +++ b/src/nexusrpc/syncio/_serializer.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import ( + Any, + Iterable, + Mapping, + Optional, + Type, +) + +from nexusrpc._serializer import Content, LazyValueT, Serializer + + +class LazyValue(LazyValueT): + """ + A container for a value encoded in an underlying stream. + + It is used to stream inputs and outputs in the various client and server APIs. + + For the `async def` version of this class, see :py:class:`nexusrpc.LazyValue`. + """ + + def __init__( + self, + serializer: Serializer, + headers: Mapping[str, str], + stream: Optional[Iterable[bytes]] = None, + ) -> None: + """ + Args: + serializer: The serializer to use for consuming the value. + headers: Headers that include information on how to process the stream's content. + Headers constructed by the framework always have lower case keys. + User provided keys are treated case-insensitively. + stream: Iterable that contains request or response data. None means empty data. + """ + self.serializer = serializer + self.headers = headers + self.stream = stream + + def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + if self.stream is None: + return self.serializer.deserialize( + Content(headers=self.headers, data=b""), as_type=as_type + ) + elif not isinstance(self.stream, Iterable): + raise ValueError("When using consume_sync, stream must be an Iterable") + + return self.serializer.deserialize( + Content( + headers=self.headers, + data=b"".join([c for c in self.stream]), + ), + as_type=as_type, + ) diff --git a/src/nexusrpc/syncio/handler/__init__.py b/src/nexusrpc/syncio/handler/__init__.py new file mode 100644 index 0000000..20dda3b --- /dev/null +++ b/src/nexusrpc/syncio/handler/__init__.py @@ -0,0 +1,18 @@ +""" +Components for implementing Nexus handlers that use synchronous I/O. + +See :py:mod:`nexusrpc.handler` for the asynchronous I/O version of this module. + +Server/worker authors will use this module to create the top-level Nexus handlers +responsible for dispatching requests to Nexus operations. + +Nexus service/operation authors will use this module to implement operation handler +methods within service handler classes. +""" + +from ._core import Handler, sync_operation + +__all__ = [ + "Handler", + "sync_operation", +] diff --git a/src/nexusrpc/syncio/handler/_core.py b/src/nexusrpc/syncio/handler/_core.py new file mode 100644 index 0000000..f378d20 --- /dev/null +++ b/src/nexusrpc/syncio/handler/_core.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import concurrent.futures +from typing import ( + Any, + Callable, + Optional, + Sequence, + Union, + overload, +) + +from typing_extensions import TypeGuard + +import nexusrpc +from nexusrpc import InputT, OperationInfo, OutputT +from nexusrpc._common import ServiceHandlerT +from nexusrpc._serializer import LazyValueT +from nexusrpc._util import ( + get_callable_name, + is_async_callable, + set_operation_definition, + set_operation_factory, +) +from nexusrpc.handler._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from nexusrpc.handler._core import BaseServiceCollectionHandler +from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations + +from ...handler._operation_handler import OperationHandler + + +class Handler(BaseServiceCollectionHandler): + """ + A Nexus handler with non-async `def` methods. + + This class does not support user operation handlers that are `async def` methods. + For a Handler class with `async def` methods that supports `async def` and `def` + user operation handlers, see :py:class:`nexusrpc.handler.Handler`. + + A Nexus handler manages a collection of Nexus service handlers. + + Operation requests are dispatched to a :py:class:`ServiceHandler` based on the + service name in the operation context. + + Example: + .. code-block:: python + + import concurrent.futures + import nexusrpc.syncio.handler + + # Create service handler instances with sync operations + my_service = MySyncServiceHandler() + + # Create executor for running sync operations + executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) + + # Create syncio handler (requires executor) + handler = nexusrpc.syncio.handler.Handler([my_service], executor=executor) + + # Use handler to process requests (methods are non-async) + result = handler.start_operation(ctx, input_lazy_value) + + """ + + executor: concurrent.futures.Executor # type: ignore[assignment] + + def __init__( + self, + user_service_handlers: Sequence[Any], + executor: concurrent.futures.Executor, + ): + super().__init__(user_service_handlers, executor) + self._validate_all_operation_handlers_are_sync() + if not self.executor: + raise RuntimeError("A syncio Handler must be initialized with an executor.") + + def start_operation( + self, + ctx: StartOperationContext, + input: LazyValueT, + ) -> Union[ + StartOperationResultSync[Any], + StartOperationResultAsync, + ]: + """Handle a Start Operation request. + + Args: + ctx: The operation context. + input: The input to the operation, as a LazyValue. + """ + service_handler = self._get_service_handler(ctx.service) + op_handler = service_handler._get_operation_handler(ctx.operation) + op = service_handler.service.operations[ctx.operation] + deserialized_input = input.consume(as_type=op.input_type) + assert self._assert_not_async_callable(op_handler.start) + # TODO(preview): apply middleware stack + return self.executor.submit(op_handler.start, ctx, deserialized_input).result() + + def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: + """Handle a Cancel Operation request. + + Args: + ctx: The operation context. + token: The operation token. + """ + service_handler = self._get_service_handler(ctx.service) + op_handler = service_handler._get_operation_handler(ctx.operation) + assert self._assert_not_async_callable(op_handler.cancel) + if not self.executor: + raise RuntimeError( + "Operation cancel handler method is not an `async def` method but " + "no executor was provided to the Handler constructor." + ) + return self.executor.submit(op_handler.cancel, ctx, token).result() + + def fetch_operation_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> OperationInfo: + raise NotImplementedError + + def fetch_operation_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Any: + raise NotImplementedError + + def _validate_all_operation_handlers_are_sync(self) -> None: + for service_handler in self.service_handlers.values(): + for op_handler in service_handler.operation_handlers.values(): + self._assert_not_async_callable(op_handler.start) + self._assert_not_async_callable(op_handler.cancel) + self._assert_not_async_callable(op_handler.fetch_info) + self._assert_not_async_callable(op_handler.fetch_result) + + def _assert_not_async_callable( + self, method: Callable[..., Any] + ) -> TypeGuard[Callable[..., Any]]: + if is_async_callable(method): + raise RuntimeError( + f"Operation handler method {method} is an `async def` method, " + "but you are using nexusrpc.syncio.handler.Handler, " + "which is for `def` methods. Use nexusrpc.handler.Handler instead." + ) + return True + + +class SyncOperationHandler(OperationHandler[InputT, OutputT]): + """ + An :py:class:`nexusrpc.handler.OperationHandler` that is limited to responding synchronously. + + The name 'SyncOperationHandler' means that it responds synchronously, in the + sense that the start method delivers the final operation result as its return + value, rather than returning an operation token representing an in-progress + operation. + + This version of the class uses `def` methods. For the async version, see + :py:class:`nexusrpc.handler.SyncOperationHandler`. + """ + + def __init__(self, start: Callable[[StartOperationContext, InputT], OutputT]): + if is_async_callable(start): + raise RuntimeError( + f"{start} is an `async def` method. " + "SyncOperationHandler must be initialized with a `def` method. " + "To use `async def` methods, use nexusrpc.handler.SyncOperationHandler." + ) + self._start = start + if start.__doc__: + if start_func := getattr(self.start, "__func__", None): + start_func.__doc__ = start.__doc__ + + def start( + self, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + """ + Start the operation and return its final result synchronously. + """ + return StartOperationResultSync(self._start(ctx, input)) + + def fetch_info(self, ctx: FetchOperationInfoContext, token: str) -> OperationInfo: + raise NotImplementedError( + "Cannot fetch operation info for an operation that responded synchronously." + ) + + def fetch_result(self, ctx: FetchOperationResultContext, token: str) -> OutputT: + raise NotImplementedError( + "Cannot fetch the result of an operation that responded synchronously." + ) + + def cancel(self, ctx: CancelOperationContext, token: str) -> None: + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + +@overload +def sync_operation( + start: Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], +) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT]: ... + + +@overload +def sync_operation( + *, + name: Optional[str] = None, +) -> Callable[ + [Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT]], + Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], +]: ... + + +def sync_operation( + start: Optional[ + Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT] + ] = None, + *, + name: Optional[str] = None, +) -> Union[ + Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], + Callable[ + [Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT]], + Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], + ], +]: + """ + Decorator marking a method as the start method for a synchronous operation. + + This is the synchronous I/O version using `def` methods. + + Example: + .. code-block:: python + + import requests + from nexusrpc.handler import service_handler + from nexusrpc.syncio.handler import sync_operation + + @service_handler + class MySyncServiceHandler: + @sync_operation + def process_data( + self, ctx: StartOperationContext, input: str + ) -> str: + # You can use synchronous I/O libraries + response = requests.get("https://api.example.com/data") + data = response.json() + return f"Processed: {data}" + """ + if is_async_callable(start): + raise TypeError( + "syncio sync_operation decorator must be used on a `def` operation method" + ) + + def decorator( + start: Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], + ) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT]: + def operation_handler_factory( + self: ServiceHandlerT, + ) -> OperationHandler[InputT, OutputT]: + def _start(ctx: StartOperationContext, input: InputT) -> OutputT: + return start(self, ctx, input) + + _start.__doc__ = start.__doc__ + return SyncOperationHandler(_start) + + input_type, output_type = get_start_method_input_and_output_type_annotations( + start + ) + + method_name = get_callable_name(start) + set_operation_definition( + operation_handler_factory, + nexusrpc.Operation( + name=name or method_name, + method_name=method_name, + input_type=input_type, + output_type=output_type, + ), + ) + + set_operation_factory(start, operation_handler_factory) + return start + + if start is None: + return decorator + + return decorator(start) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/handler/__init__.py b/tests/handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py new file mode 100644 index 0000000..c206af5 --- /dev/null +++ b/tests/handler/test_async_operation.py @@ -0,0 +1,114 @@ +import dataclasses +import uuid +from datetime import timedelta +from typing import Any + +import pytest + +from nexusrpc import ( + HandlerError, + HandlerErrorType, + LazyValue, + OperationInfo, + OperationState, +) +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + Handler, + OperationHandler, + StartOperationContext, + StartOperationResultAsync, + service_handler, +) +from nexusrpc.handler._decorators import operation_handler +from tests.helpers import DummySerializer + + +class _TestCase: + user_service_handler: Any + + +_operation_results: dict[str, int] = {} + + +class MyAsyncOperationHandler(OperationHandler[int, int]): + async def start( + self, ctx: StartOperationContext, input: int + ) -> StartOperationResultAsync: + token = str(uuid.uuid4()) + _operation_results[token] = input + 1 + return StartOperationResultAsync(token) + + async def cancel(self, ctx: CancelOperationContext, token: str) -> None: + del _operation_results[token] + + async def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> OperationInfo: + assert token in _operation_results + return OperationInfo( + token=token, + state=OperationState.RUNNING, + ) + + async def fetch_result(self, ctx: FetchOperationResultContext, token: str) -> int: + if ctx.wait: + raise HandlerError( + "Operation timed out", + type=HandlerErrorType.UPSTREAM_TIMEOUT, + ) + return _operation_results[token] + + +@service_handler +class MyService: + @operation_handler + def incr(self) -> OperationHandler[int, int]: + return MyAsyncOperationHandler() + + +@pytest.mark.asyncio +async def test_async_operation_happy_path(): + handler = Handler(user_service_handlers=[MyService()]) + start_ctx = StartOperationContext( + service="MyService", + operation="incr", + headers={}, + request_id="request_id", + ) + start_result = await handler.start_operation( + start_ctx, LazyValue(DummySerializer(1), headers={}) + ) + assert isinstance(start_result, StartOperationResultAsync) + assert start_result.token + + fetch_info_ctx = FetchOperationInfoContext( + service="MyService", + operation="incr", + headers={}, + ) + info = await handler.fetch_operation_info(fetch_info_ctx, start_result.token) + assert info.state == OperationState.RUNNING + + fetch_result_ctx = FetchOperationResultContext( + service="MyService", + operation="incr", + headers={}, + ) + result = await handler.fetch_operation_result(fetch_result_ctx, start_result.token) + assert result == 2 + + # Fetch it again but with wait set + fetch_result_ctx = dataclasses.replace(fetch_result_ctx, wait=timedelta(seconds=0)) + with pytest.raises(NotImplementedError): + await handler.fetch_operation_result(fetch_result_ctx, start_result.token) + + cancel_ctx = CancelOperationContext( + service="MyService", + operation="incr", + headers={}, + ) + await handler.cancel_operation(cancel_ctx, start_result.token) + assert start_result.token not in _operation_results diff --git a/tests/handler/test_handler_syncio.py b/tests/handler/test_handler_syncio.py new file mode 100644 index 0000000..92af6be --- /dev/null +++ b/tests/handler/test_handler_syncio.py @@ -0,0 +1,56 @@ +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Any, Optional, Type + +import pytest + +from nexusrpc import Content +from nexusrpc.handler import ( + StartOperationContext, + StartOperationResultSync, + service_handler, +) +from nexusrpc.syncio import LazyValue +from nexusrpc.syncio.handler import Handler, sync_operation + + +class _TestCase: + user_service_handler: Any + + +class SyncHandlerHappyPath: + @service_handler + class MyService: + @sync_operation + def incr(self, ctx: StartOperationContext, input: int) -> int: + return input + 1 + + user_service_handler = MyService() + + +@pytest.mark.parametrize("test_case", [SyncHandlerHappyPath]) +def test_sync_handler_happy_path(test_case: Type[_TestCase]): + handler = Handler( + user_service_handlers=[test_case.user_service_handler], + executor=ThreadPoolExecutor(max_workers=1), + ) + ctx = StartOperationContext( + service="MyService", + operation="incr", + headers={}, + request_id="request_id", + ) + result = handler.start_operation(ctx, LazyValue(DummySerializer(1), headers={})) + assert isinstance(result, StartOperationResultSync) + assert result.value == 2 + + +@dataclass +class DummySerializer: + value: int + + def serialize(self, value: Any) -> Content: + raise NotImplementedError + + def deserialize(self, content: Content, as_type: Optional[Type[Any]] = None) -> Any: + return self.value diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py new file mode 100644 index 0000000..a16a6c7 --- /dev/null +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -0,0 +1,80 @@ +""" +Test that the Handler constructor processes the supplied collection of service handlers +correctly. +""" + +import pytest + +from nexusrpc import OperationInfo +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + Handler, + OperationHandler, + StartOperationContext, + StartOperationResultSync, + service_handler, +) +from nexusrpc.handler._decorators import operation_handler + + +def test_service_must_use_decorator(): + class Service: + pass + + with pytest.raises(RuntimeError): + Handler([Service()]) + + +def test_services_are_collected(): + class OpHandler(OperationHandler[int, int]): + async def start( + self, + ctx: StartOperationContext, + input: int, + ) -> StartOperationResultSync[int]: ... + + async def cancel( + self, + ctx: CancelOperationContext, + token: str, + ) -> None: ... + + async def fetch_info( + self, + ctx: FetchOperationInfoContext, + token: str, + ) -> OperationInfo: ... + + async def fetch_result( + self, + ctx: FetchOperationResultContext, + token: str, + ) -> int: ... + + @service_handler + class Service1: + @operation_handler + def op(self) -> OperationHandler[int, int]: + return OpHandler() + + service_handlers = Handler([Service1()]) + assert service_handlers.service_handlers.keys() == {"Service1"} + assert service_handlers.service_handlers["Service1"].service.name == "Service1" + assert service_handlers.service_handlers["Service1"].operation_handlers.keys() == { + "op" + } + + +def test_service_names_must_be_unique(): + @service_handler(name="a") + class Service1: + pass + + @service_handler(name="a") + class Service2: + pass + + with pytest.raises(RuntimeError): + Handler([Service1(), Service2()]) diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py new file mode 100644 index 0000000..8cdfb2a --- /dev/null +++ b/tests/handler/test_invalid_usage.py @@ -0,0 +1,213 @@ +""" +Tests for invalid ways that users may attempt to write service definition and service +handler implementations. +""" + +import concurrent.futures +from typing import Any, Callable + +import pytest + +import nexusrpc +from nexusrpc.handler import ( + Handler, + StartOperationContext, + service_handler, + sync_operation, +) +from nexusrpc.handler._decorators import operation_handler +from nexusrpc.handler._operation_handler import OperationHandler +from nexusrpc.syncio.handler import ( + Handler as SyncioHandler, + sync_operation as syncio_sync_operation, +) + + +class _TestCase: + build: Callable[..., Any] + error_message: str + + +class OperationHandlerOverridesNameInconsistentlyWithServiceDefinition(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op: nexusrpc.Operation[None, None] + + @service_handler(service=SD) + class SH: + @sync_operation(name="foo") + async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + error_message = "Operation handlers may not override the name of an operation in the service definition" + + +class ServiceDefinitionHasExtraOp(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op_1: nexusrpc.Operation[None, None] + my_op_2: nexusrpc.Operation[None, None] + + @service_handler(service=SD) + class SH: + @sync_operation + async def my_op_1( + self, ctx: StartOperationContext, input: None + ) -> None: ... + + error_message = "does not implement an operation with method name 'my_op_2'" + + +class ServiceHandlerHasExtraOp(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op_1: nexusrpc.Operation[None, None] + + @service_handler(service=SD) + class SH: + @sync_operation + async def my_op_1( + self, ctx: StartOperationContext, input: None + ) -> None: ... + + @sync_operation + async def my_op_2( + self, ctx: StartOperationContext, input: None + ) -> None: ... + + error_message = "does not match an operation method name in the service definition" + + +class ServiceDefinitionOperationHasNoTypeParams(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op: nexusrpc.Operation + + @service_handler(service=SD) + class SH: + @sync_operation + async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + error_message = "has 0 type parameters" + + +class AsyncioDecoratorWithSyncioMethod(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op: nexusrpc.Operation[None, None] + + @service_handler(service=SD) + class SH: + @sync_operation # assert-type-error: 'Argument 1 to "sync_operation" has incompatible type' + def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + error_message = ( + "sync_operation decorator must be used on an `async def` operation method" + ) + + +class SyncioDecoratorWithAsyncioMethod(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op: nexusrpc.Operation[None, None] + + @service_handler(service=SD) + class SH: + @syncio_sync_operation + async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + error_message = ( + "syncio sync_operation decorator must be used on a `def` operation method" + ) + + +class AsyncioHandlerWithSyncioOperation(_TestCase): + @staticmethod + def build(): + @service_handler + class SH: + @syncio_sync_operation + def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + Handler([SH()]) + + error_message = "Use nexusrpc.syncio.handler.Handler instead" + + +class SyncioHandlerWithAsyncioOperation(_TestCase): + @staticmethod + def build(): + @service_handler + class SH: + @sync_operation + async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + SyncioHandler([SH()], concurrent.futures.ThreadPoolExecutor()) + + error_message = "Use nexusrpc.handler.Handler instead" + + +class ServiceDefinitionHasDuplicateMethodNames(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class SD: + my_op: nexusrpc.Operation[None, None] = nexusrpc.Operation( + name="my_op", + method_name="my_op", + input_type=None, + output_type=None, + ) + my_op_2: nexusrpc.Operation[None, None] = nexusrpc.Operation( + name="my_op_2", + method_name="my_op", + input_type=None, + output_type=None, + ) + + error_message = "Operation method name 'my_op' is not unique" + + +class OperationHandlerNoInputOutputTypeAnnotationsWithoutServiceDefinition(_TestCase): + @staticmethod + def build(): + @service_handler + class SubclassingNoInputOutputTypeAnnotationsWithoutServiceDefinition: + @operation_handler + def op(self) -> OperationHandler: ... + + error_message = r"has no input type.+has no output type" + + +@pytest.mark.parametrize( + "test_case", + [ + OperationHandlerOverridesNameInconsistentlyWithServiceDefinition, + ServiceDefinitionOperationHasNoTypeParams, + ServiceDefinitionHasExtraOp, + ServiceHandlerHasExtraOp, + AsyncioDecoratorWithSyncioMethod, + SyncioDecoratorWithAsyncioMethod, + AsyncioHandlerWithSyncioOperation, + SyncioHandlerWithAsyncioOperation, + ServiceDefinitionHasDuplicateMethodNames, + OperationHandlerNoInputOutputTypeAnnotationsWithoutServiceDefinition, + ], +) +def test_invalid_usage(test_case: _TestCase): + if test_case.error_message: + with pytest.raises(Exception, match=test_case.error_message): + test_case.build() + else: + test_case.build() diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py new file mode 100644 index 0000000..36a3ef0 --- /dev/null +++ b/tests/handler/test_request_routing.py @@ -0,0 +1,183 @@ +from typing import Any, Callable, Type, cast + +import pytest + +import nexusrpc +from nexusrpc import LazyValue +from nexusrpc._util import get_operation_factory, get_service_definition +from nexusrpc.handler import ( + Handler, + StartOperationContext, + service_handler, + sync_operation, +) +from nexusrpc.handler._common import StartOperationResultSync + +from ..helpers import DummySerializer + + +class _TestCase: + UserService: Type[Any] + # (service_name, op_name) + supported_request: tuple[str, str] + + class UserServiceHandler: + op: Callable[..., Any] + + async def _op_impl(self, ctx: StartOperationContext, input: None) -> bool: + assert (service_defn := get_service_definition(self.__class__)) + assert ctx.service == service_defn.name + _, op_handler_op_defn = get_operation_factory(self.op) + assert op_handler_op_defn + assert service_defn.operations.get(ctx.operation) + return True + + +class NoOverrides(_TestCase): + @nexusrpc.service + class UserService: + op: nexusrpc.Operation[None, bool] + + @service_handler(service=UserService) + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserService", "op") + + +class OverrideServiceName(_TestCase): + @nexusrpc.service(name="UserService-renamed") + class UserService: + op: nexusrpc.Operation[None, bool] + + @service_handler(service=UserService) + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserService-renamed", "op") + + +class OverrideOperationName(_TestCase): + @nexusrpc.service + class UserService: + op: nexusrpc.Operation[None, bool] = nexusrpc.Operation(name="op-renamed") + + @service_handler(service=UserService) + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserService", "op-renamed") + + +class OverrideServiceAndOperationName(_TestCase): + @nexusrpc.service(name="UserService-renamed") + class UserService: + op: nexusrpc.Operation[None, bool] = nexusrpc.Operation(name="op-renamed") + + @service_handler(service=UserService) + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserService-renamed", "op-renamed") + + +@pytest.mark.parametrize( + "test_case", + [ + NoOverrides, + OverrideServiceName, + OverrideOperationName, + OverrideServiceAndOperationName, + ], +) +@pytest.mark.asyncio +async def test_request_routing_with_service_definition( + test_case: _TestCase, +): + request_service, request_op = test_case.supported_request + ctx = StartOperationContext( + service=request_service, + operation=request_op, + headers={}, + request_id="request-id", + ) + handler = Handler(user_service_handlers=[test_case.UserServiceHandler()]) + result = await handler.start_operation( + ctx, LazyValue(serializer=DummySerializer(None), headers={}) + ) + assert cast(StartOperationResultSync[bool], result).value is True + + +class NoServiceDefinitionNoOverrides(_TestCase): + @service_handler + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserServiceHandler", "op") + + +class NoServiceDefinitionOverrideServiceName(_TestCase): + @service_handler(name="UserServiceHandler-renamed") + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserServiceHandler-renamed", "op") + + +class NoServiceDefinitionOverrideOperationName(_TestCase): + @service_handler + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation(name="op-renamed") + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserServiceHandler", "op-renamed") + + +class NoServiceDefinitionOverrideServiceAndOperationName(_TestCase): + @service_handler(name="UserServiceHandler-renamed") + class UserServiceHandler(_TestCase.UserServiceHandler): + @sync_operation(name="op-renamed") + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await self._op_impl(ctx, input) + + supported_request = ("UserServiceHandler-renamed", "op-renamed") + + +@pytest.mark.parametrize( + "test_case", + [ + NoServiceDefinitionNoOverrides, + NoServiceDefinitionOverrideServiceName, + NoServiceDefinitionOverrideOperationName, + NoServiceDefinitionOverrideServiceAndOperationName, + ], +) +@pytest.mark.asyncio +async def test_request_routing_without_service_definition( + test_case: _TestCase, +): + request_service, request_op = test_case.supported_request + ctx = StartOperationContext( + service=request_service, + operation=request_op, + headers={}, + request_id="request-id", + ) + handler = Handler(user_service_handlers=[test_case.UserServiceHandler()]) + result = await handler.start_operation( + ctx, LazyValue(serializer=DummySerializer(None), headers={}) + ) + assert cast(StartOperationResultSync[bool], result).value is True diff --git a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py new file mode 100644 index 0000000..4eaaedf --- /dev/null +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -0,0 +1,209 @@ +""" +Test that operation decorators result in operation definitions with the correct name and +input/ouput types. +""" + +from dataclasses import dataclass +from typing import Any, Optional, Type + +import pytest + +import nexusrpc +from nexusrpc._util import get_operation_definition, get_service_definition +from nexusrpc.handler import ( + OperationHandler, + StartOperationContext, + service_handler, + sync_operation, +) +from nexusrpc.handler._decorators import operation_handler + + +@dataclass +class Input: + pass + + +@dataclass +class Output: + pass + + +@dataclass +class _TestCase: + Service: Type[Any] + expected_operations: dict[str, nexusrpc.Operation] + Contract: Optional[Type[Any]] = None + + +class ManualOperationHandler(_TestCase): + @service_handler + class Service: + @operation_handler + def operation(self) -> OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation( + name="operation", + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +class ManualOperationHandlerWithNameOverride(_TestCase): + @service_handler + class Service: + @operation_handler(name="operation-name") + def operation(self) -> OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation( + name="operation-name", + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +class SyncOperation(_TestCase): + @service_handler + class Service: + @operation_handler + def sync_operation_handler( + self, + ) -> OperationHandler[Input, Output]: ... + + expected_operations = { + "sync_operation_handler": nexusrpc.Operation( + name="sync_operation_handler", + method_name="sync_operation_handler", + input_type=Input, + output_type=Output, + ), + } + + +class SyncOperationWithOperationHandlerNameOverride(_TestCase): + @service_handler + class Service: + @operation_handler(name="sync-operation-name") + def sync_operation_handler( + self, + ) -> OperationHandler[Input, Output]: ... + + expected_operations = { + "sync_operation_handler": nexusrpc.Operation( + name="sync-operation-name", + method_name="sync_operation_handler", + input_type=Input, + output_type=Output, + ), + } + + +class ManualOperationWithContract(_TestCase): + @nexusrpc.service + class Contract: + operation: nexusrpc.Operation[Input, Output] + + @service_handler(service=Contract) + class Service: + @operation_handler + def operation(self) -> OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation( + name="operation", + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +class ManualOperationWithContractNameOverrideAndOperationHandlerNameOverride(_TestCase): + @nexusrpc.service + class Contract: + operation: nexusrpc.Operation[Input, Output] = nexusrpc.Operation( + name="operation-override", + ) + + @service_handler(service=Contract) + class Service: + @operation_handler(name="operation-override") + def operation(self) -> OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation( + name="operation-override", + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +if False: + # TODO(preview): support callable instances + class SyncOperationWithCallableInstance(_TestCase): + @nexusrpc.service + class Contract: + sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] + + @service_handler(service=Contract) + class Service: + class sync_operation_with_callable_instance: + async def __call__( + self, + _handler: Any, + ctx: StartOperationContext, + input: Input, + ) -> Output: ... + + _sync_operation_with_callable_instance = sync_operation( + sync_operation_with_callable_instance() + ) + + expected_operations = { + "sync_operation_with_callable_instance": nexusrpc.Operation( + name="sync_operation_with_callable_instance", + method_name="CallableInstanceStartMethod", + input_type=Input, + output_type=Output, + ), + } + + +@pytest.mark.parametrize( + "test_case", + [ + ManualOperationHandler, + ManualOperationHandlerWithNameOverride, + SyncOperation, + SyncOperationWithOperationHandlerNameOverride, + ManualOperationWithContract, + ManualOperationWithContractNameOverrideAndOperationHandlerNameOverride, + ], +) +@pytest.mark.asyncio +async def test_collected_operation_definitions( + test_case: Type[_TestCase], +): + service = get_service_definition(test_case.Service) + assert isinstance(service, nexusrpc.ServiceDefinition) + if test_case.Contract: + defn = get_service_definition(test_case.Contract) + assert isinstance(defn, nexusrpc.ServiceDefinition) + assert defn.name == service.name + else: + assert service.name == "Service" + + for method_name, expected_op in test_case.expected_operations.items(): + actual_op = get_operation_definition(getattr(test_case.Service, method_name)) + assert isinstance(actual_op, nexusrpc.Operation) + assert actual_op.name == expected_op.name + assert actual_op.input_type == expected_op.input_type + assert actual_op.output_type == expected_op.output_type diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py new file mode 100644 index 0000000..73a8ee2 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import Any, Type + +import pytest + +import nexusrpc +from nexusrpc._util import get_service_definition +from nexusrpc.handler import ( + OperationHandler, + service_handler, +) +from nexusrpc.handler._core import ServiceHandler +from nexusrpc.handler._decorators import operation_handler + + +class _DecoratorValidationTestCase: + UserService: Type[Any] + UserServiceHandler: Type[Any] + expected_error_message_pattern: str + + +class MissingOperationFromDefinition(_DecoratorValidationTestCase): + @nexusrpc.service + class UserService: + op_A: nexusrpc.Operation[int, str] + op_B: nexusrpc.Operation[bool, float] + + class UserServiceHandler: + @operation_handler + def op_A(self) -> OperationHandler[int, str]: ... + + expected_error_message_pattern = ( + r"does not implement an operation with method name 'op_B'" + ) + + +class MethodNameDoesNotMatchDefinition(_DecoratorValidationTestCase): + @nexusrpc.service + class UserService: + op_A: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="foo") + + class UserServiceHandler: + @operation_handler + def op_A_incorrect_method_name( + self, + ) -> OperationHandler[int, str]: ... + + expected_error_message_pattern = ( + r"does not match an operation method name in the service definition." + ) + + +@pytest.mark.parametrize( + "test_case", + [ + MissingOperationFromDefinition, + MethodNameDoesNotMatchDefinition, + ], +) +def test_decorator_validates_definition_compliance( + test_case: _DecoratorValidationTestCase, +): + with pytest.raises(TypeError, match=test_case.expected_error_message_pattern): + service_handler(service=test_case.UserService)(test_case.UserServiceHandler) + + +class _ServiceHandlerInheritanceTestCase: + UserServiceHandler: Type[Any] + expected_operations: set[str] + + +class ServiceHandlerInheritanceWithServiceDefinition( + _ServiceHandlerInheritanceTestCase +): + @nexusrpc.service + class BaseUserService: + base_op: nexusrpc.Operation[int, str] + + @nexusrpc.service + class UserService: + base_op: nexusrpc.Operation[int, str] + child_op: nexusrpc.Operation[bool, float] + + @service_handler(service=BaseUserService) + class BaseUserServiceHandler: + @operation_handler + def base_op(self) -> OperationHandler[int, str]: ... + + @service_handler(service=UserService) + class UserServiceHandler(BaseUserServiceHandler): + @operation_handler + def child_op(self) -> OperationHandler[bool, float]: ... + + expected_operations = {"base_op", "child_op"} + + +class ServiceHandlerInheritanceWithoutDefinition(_ServiceHandlerInheritanceTestCase): + @service_handler + class BaseUserServiceHandler: + @operation_handler + def base_op_nc(self) -> OperationHandler[int, str]: ... + + @service_handler + class UserServiceHandler(BaseUserServiceHandler): + @operation_handler + def child_op_nc(self) -> OperationHandler[bool, float]: ... + + expected_operations = {"base_op_nc", "child_op_nc"} + + +@pytest.mark.parametrize( + "test_case", + [ + ServiceHandlerInheritanceWithServiceDefinition, + ServiceHandlerInheritanceWithoutDefinition, + ], +) +def test_service_implementation_inheritance( + test_case: _ServiceHandlerInheritanceTestCase, +): + service_handler = ServiceHandler.from_user_instance(test_case.UserServiceHandler()) + + assert set(service_handler.operation_handlers) == test_case.expected_operations + assert set(service_handler.service.operations) == test_case.expected_operations + + +class _ServiceDefinitionInheritanceTestCase: + UserService: Type[Any] + expected_ops: set[str] + + +class ServiceDefinitionInheritance(_ServiceDefinitionInheritanceTestCase): + @nexusrpc.service + class BaseUserService: + op_from_base_definition: nexusrpc.Operation[int, str] + + @nexusrpc.service + class UserService(BaseUserService): + op_from_child_definition: nexusrpc.Operation[bool, float] + + expected_ops = { + "op_from_base_definition", + "op_from_child_definition", + } + + +@pytest.mark.parametrize( + "test_case", + [ + ServiceDefinitionInheritance, + ], +) +def test_service_definition_inheritance_behavior( + test_case: _ServiceDefinitionInheritanceTestCase, +): + service_defn = get_service_definition(test_case.UserService) + + assert service_defn is not None, ( + f"{test_case.UserService.__name__} lacks __nexus_service__ attribute." + ) + assert isinstance(service_defn, nexusrpc.ServiceDefinition), ( + "__nexus_service__ is not a nexusrpc.ServiceDefinition instance." + ) + + assert set(service_defn.operations) == test_case.expected_ops + + with pytest.raises( + TypeError, + match="does not implement an operation with method name 'op_from_child_definition'", + ): + + @service_handler(service=test_case.UserService) + class HandlerMissingChildOp: + @operation_handler + def op_from_base_definition( + self, + ) -> OperationHandler[int, str]: ... diff --git a/tests/handler/test_service_handler_decorator_results_in_correctly_functioning_operation_factories.py b/tests/handler/test_service_handler_decorator_results_in_correctly_functioning_operation_factories.py new file mode 100644 index 0000000..d93d967 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_results_in_correctly_functioning_operation_factories.py @@ -0,0 +1,121 @@ +""" +Test that operation decorators result in operation factories that return the correct result. +""" + +from dataclasses import dataclass +from typing import Any, Type, Union, cast + +import pytest + +import nexusrpc +from nexusrpc import InputT, OutputT +from nexusrpc._util import get_service_definition, is_async_callable +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationHandler, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, + service_handler, + sync_operation, +) +from nexusrpc.handler._core import collect_operation_handler_factories_by_method_name +from nexusrpc.handler._decorators import operation_handler + + +@dataclass +class _TestCase: + Service: Type[Any] + expected_operation_factories: dict[str, Any] + + +class ManualOperationDefinition(_TestCase): + @service_handler + class Service: + @operation_handler + def operation(self) -> OperationHandler[int, int]: + class OpHandler(OperationHandler[int, int]): + async def start( + self, ctx: StartOperationContext, input: int + ) -> StartOperationResultSync[int]: + return StartOperationResultSync(7) + + def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> nexusrpc.OperationInfo: + raise NotImplementedError + + def fetch_result( + self, ctx: FetchOperationResultContext, token: str + ) -> int: + raise NotImplementedError + + def cancel(self, ctx: CancelOperationContext, token: str) -> None: + raise NotImplementedError + + return OpHandler() + + expected_operation_factories = {"operation": 7} + + +class SyncOperation(_TestCase): + @service_handler + class Service: + @sync_operation + async def sync_operation_handler( + self, ctx: StartOperationContext, input: int + ) -> int: + return 7 + + expected_operation_factories = {"sync_operation_handler": 7} # type: ignore + + +@pytest.mark.parametrize( + "test_case", + [ + ManualOperationDefinition, + SyncOperation, + ], +) +@pytest.mark.asyncio +async def test_collected_operation_factories_match_service_definition( + test_case: Type[_TestCase], +): + service = get_service_definition(test_case.Service) + assert isinstance(service, nexusrpc.ServiceDefinition) + assert service.name == "Service" + operation_factories = collect_operation_handler_factories_by_method_name( + test_case.Service, service + ) + assert operation_factories.keys() == test_case.expected_operation_factories.keys() + ctx = StartOperationContext( + service="Service", + operation="operation", + headers={}, + request_id="request_id", + ) + + async def execute( + op: OperationHandler[InputT, OutputT], + ctx: StartOperationContext, + input: InputT, + ) -> Union[ + StartOperationResultSync[OutputT], + StartOperationResultAsync, + ]: + if is_async_callable(op.start): + return await op.start(ctx, input) + else: + return cast( + StartOperationResultSync[OutputT], + op.start(ctx, input), + ) + + for op_name, expected_result in test_case.expected_operation_factories.items(): + op_factory = operation_factories[op_name] + op = op_factory(test_case.Service) + result = await execute(op, ctx, 0) + assert isinstance(result, StartOperationResultSync) + assert result.value == expected_result diff --git a/tests/handler/test_service_handler_decorator_selects_correct_service_name.py b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py new file mode 100644 index 0000000..a57874a --- /dev/null +++ b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py @@ -0,0 +1,90 @@ +from typing import Optional, Type + +import pytest + +import nexusrpc +from nexusrpc._util import get_service_definition +from nexusrpc.handler import service_handler + + +@nexusrpc.service +class ServiceInterface: + pass + + +@nexusrpc.service(name="Service-With-Name-Override") +class ServiceInterfaceWithNameOverride: + pass + + +class _NameOverrideTestCase: + ServiceImpl: Type + expected_name: str + expected_error: Optional[Type[Exception]] = None + + +class NotCalled(_NameOverrideTestCase): + @service_handler + class ServiceImpl: + pass + + expected_name = "ServiceImpl" + + +class CalledWithoutArgs(_NameOverrideTestCase): + @service_handler() + class ServiceImpl: + pass + + expected_name = "ServiceImpl" + + +class CalledWithNameArg(_NameOverrideTestCase): + @service_handler(name="my-service-impl-🌈") + class ServiceImpl: + pass + + expected_name = "my-service-impl-🌈" + + +class CalledWithInterface(_NameOverrideTestCase): + @service_handler(service=ServiceInterface) + class ServiceImpl: + pass + + expected_name = "ServiceInterface" + + +class CalledWithInterfaceWithNameOverride(_NameOverrideTestCase): + @service_handler(service=ServiceInterfaceWithNameOverride) + class ServiceImpl: + pass + + expected_name = "Service-With-Name-Override" + + +@pytest.mark.parametrize( + "test_case", + [ + NotCalled, + CalledWithoutArgs, + CalledWithNameArg, + CalledWithInterface, + CalledWithInterfaceWithNameOverride, + ], +) +def test_service_decorator_name_overrides(test_case: Type[_NameOverrideTestCase]): + service = get_service_definition(test_case.ServiceImpl) + assert isinstance(service, nexusrpc.ServiceDefinition) + assert service.name == test_case.expected_name + + +def test_name_must_not_be_empty(): + with pytest.raises(ValueError): + service_handler(name="")(object) + + +def test_name_and_interface_are_mutually_exclusive(): + with pytest.raises(ValueError): + # Type error due to deliberately violating overload + service_handler(name="my-service-impl-🌈", service=ServiceInterface) # type: ignore diff --git a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py new file mode 100644 index 0000000..3f54784 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -0,0 +1,306 @@ +import warnings +from typing import Any, Optional, Type + +import pytest + +import nexusrpc +from nexusrpc.handler import ( + StartOperationContext, + service_handler, + sync_operation, +) + + +class _InterfaceImplementationTestCase: + Interface: Type + Impl: Type + error_message: Optional[str] + + +class ValidImpl(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + def unrelated_method(self) -> None: ... + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> None: ... + + error_message = None + + +class ValidImplWithEmptyInterfaceAndExtraOperation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + pass + + class Impl: + @sync_operation + async def extra_op(self, ctx: StartOperationContext, input: None) -> None: ... + + def unrelated_method(self) -> None: ... + + error_message = "does not match an operation method name in the service definition" + + +class ValidImplWithoutTypeAnnotations(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[int, str] + + with warnings.catch_warnings(record=True) as _warnings: + warnings.simplefilter("always") + + class Impl: + @sync_operation + async def op(self, ctx, input): ... + + captured_warnings = _warnings + expected_warning = ( + "to have exactly 2 type-annotated parameters (ctx and input), but it has 0" + ) + + error_message = None + + +class MissingOperation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + class Impl: + pass + + error_message = "does not implement an operation with method name 'op'" + + +class MissingInputAnnotation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + with warnings.catch_warnings(record=True) as _warnings: + warnings.simplefilter("always") + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input) -> None: ... + + captured_warnings = _warnings + expected_warning = ( + "to have exactly 2 type-annotated parameters (ctx and input), but it has 1" + ) + + error_message = None + + +class MissingContextAnnotation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + with warnings.catch_warnings(record=True) as _warnings: + warnings.simplefilter("always") + + class Impl: + @sync_operation + async def op(self, ctx, input: None) -> None: ... + + captured_warnings = _warnings + expected_warning = ( + "to have exactly 2 type-annotated parameters (ctx and input), but it has 1" + ) + + error_message = None + + +class WrongOutputType(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, int] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> str: ... + + error_message = "is not compatible with the output type" + + +class WrongOutputTypeWithNone(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[str, None] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: str) -> str: ... + + error_message = "is not compatible with the output type" + + +class ValidImplWithNone(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[str, None] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: str) -> None: ... + + error_message = None + + +class MoreSpecificImplAllowed(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[Any, Any] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: str) -> str: ... + + error_message = None + + +class X: + pass + + +class SuperClass: + pass + + +class Subclass(SuperClass): + pass + + +class OutputCovarianceImplOutputCanBeSameType(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, X] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: X) -> X: ... + + error_message = None + + +class OutputCovarianceImplOutputCanBeSubclass(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, SuperClass] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: X) -> Subclass: ... + + error_message = None + + +class OutputCovarianceImplOutputCannnotBeStrictSuperclass( + _InterfaceImplementationTestCase +): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, Subclass] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: X) -> SuperClass: ... + + error_message = "is not compatible with the output type" + + +class InputContravarianceImplInputCanBeSameType(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, X] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: X) -> X: ... + + error_message = None + + +class InputContravarianceImplInputCanBeSuperclass(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[Subclass, X] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: SuperClass) -> X: ... + + error_message = None + + +class InputContravarianceImplInputCannotBeSubclass(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[SuperClass, X] + + class Impl: + @sync_operation + async def op(self, ctx: StartOperationContext, input: Subclass) -> X: ... + + error_message = "is not compatible with the input type" + + +@pytest.mark.parametrize( + "test_case", + [ + ValidImpl, + ValidImplWithEmptyInterfaceAndExtraOperation, + ValidImplWithoutTypeAnnotations, + MissingOperation, + MissingInputAnnotation, + MissingContextAnnotation, + WrongOutputType, + WrongOutputTypeWithNone, + ValidImplWithNone, + MoreSpecificImplAllowed, + OutputCovarianceImplOutputCanBeSameType, + OutputCovarianceImplOutputCanBeSubclass, + OutputCovarianceImplOutputCannnotBeStrictSuperclass, + InputContravarianceImplInputCanBeSameType, + InputContravarianceImplInputCanBeSuperclass, + ], +) +def test_service_decorator_enforces_interface_implementation( + test_case: Type[_InterfaceImplementationTestCase], +): + if test_case.error_message: + with pytest.raises(Exception) as ei: + service_handler(service=test_case.Interface)(test_case.Impl) + err = ei.value + assert test_case.error_message in str(err) + else: + if expected_warning := getattr(test_case, "expected_warning", None): + [warning] = test_case.captured_warnings + assert expected_warning in str(warning.message) + assert issubclass(warning.category, UserWarning) + + service_handler(service=test_case.Interface)(test_case.Impl) + + +# TODO(preview): duplicate test? +def test_service_does_not_implement_operation_name(): + @nexusrpc.service + class Contract: + operation_a: nexusrpc.Operation[None, None] + + class Service: + @sync_operation + async def operation_b( + self, ctx: StartOperationContext, input: None + ) -> None: ... + + with pytest.raises( + TypeError, + match="does not match an operation method name in the service definition", + ): + service_handler(service=Contract)(Service) diff --git a/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py new file mode 100644 index 0000000..195115a --- /dev/null +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -0,0 +1,38 @@ +from typing import Any, Type + +import pytest + +from nexusrpc.handler import ( + OperationHandler, + service_handler, +) +from nexusrpc.handler._decorators import operation_handler + + +class _TestCase: + UserServiceHandler: Type[Any] + expected_error_message: str + + +class DuplicateOperationName(_TestCase): + class UserServiceHandler: + @operation_handler(name="a") + def op_1(self) -> OperationHandler[int, int]: ... + + @operation_handler(name="a") + def op_2(self) -> OperationHandler[str, int]: ... + + expected_error_message = ( + "Operation 'a' in service 'UserServiceHandler' is defined multiple times." + ) + + +@pytest.mark.parametrize( + "test_case", + [ + DuplicateOperationName, + ], +) +def test_service_handler_decorator(test_case: _TestCase): + with pytest.raises(RuntimeError, match=test_case.expected_error_message): + service_handler(test_case.UserServiceHandler) diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py new file mode 100644 index 0000000..83c93a1 --- /dev/null +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest + +from nexusrpc.handler import StartOperationContext, service_handler +from nexusrpc.syncio import handler as syncio_handler + +if False: + + @service_handler + class MyServiceHandlerWithCallableInstance: + class SyncOperationWithCallableInstance: + def __call__( + self, + _handler: MyServiceHandlerWithCallableInstance, + ctx: StartOperationContext, + input: int, + ) -> int: + return input + + sync_operation_with_callable_instance = syncio_handler.sync_operation( + name="sync_operation_with_callable_instance", + )( + SyncOperationWithCallableInstance(), + ) + + +@pytest.mark.skip(reason="TODO(preview): support callable instance") +def test_service_handler_from_user_instance(): + # service_handler = MyServiceHandlerWithCallableInstance() + # ServiceHandler.from_user_instance(service_handler) + pass diff --git a/tests/handler/test_sync_operation_handler_decorator_creates_valid_operation_handler.py b/tests/handler/test_sync_operation_handler_decorator_creates_valid_operation_handler.py new file mode 100644 index 0000000..535dd57 --- /dev/null +++ b/tests/handler/test_sync_operation_handler_decorator_creates_valid_operation_handler.py @@ -0,0 +1,71 @@ +from unittest import mock + +import pytest + +from nexusrpc._util import get_operation_factory, is_async_callable +from nexusrpc.handler import ( + StartOperationContext, + StartOperationResultSync, + service_handler, + sync_operation, +) +from nexusrpc.syncio import handler as syncio_handler + + +@service_handler +class MyServiceHandler: + def __init__(self): + self.mutable_container = [] + + @syncio_handler.sync_operation + def my_def_op(self, ctx: StartOperationContext, input: int) -> int: + """ + This is the docstring for the `my_def_op` sync operation. + """ + self.mutable_container.append(input) + return input + 1 + + @sync_operation(name="foo") + async def my_async_def_op(self, ctx: StartOperationContext, input: int) -> int: + """ + This is the docstring for the `my_async_def_op` sync operation. + """ + self.mutable_container.append(input) + return input + 2 + + +def test_def_sync_handler(): + user_instance = MyServiceHandler() + op_handler_factory, _ = get_operation_factory(user_instance.my_def_op) + assert op_handler_factory + op_handler = op_handler_factory(user_instance) + assert not is_async_callable(op_handler.start) + assert ( + str(op_handler.start.__doc__).strip() + == "This is the docstring for the `my_def_op` sync operation." + ) + assert not user_instance.mutable_container + ctx = mock.Mock(spec=StartOperationContext) + result = op_handler.start(ctx, 1) + assert isinstance(result, StartOperationResultSync) + assert result.value == 2 + assert user_instance.mutable_container == [1] + + +@pytest.mark.asyncio +async def test_async_def_sync_handler(): + user_instance = MyServiceHandler() + op_handler_factory, _ = get_operation_factory(user_instance.my_async_def_op) + assert op_handler_factory + op_handler = op_handler_factory(user_instance) + assert is_async_callable(op_handler.start) + assert ( + str(op_handler.start.__doc__).strip() + == "This is the docstring for the `my_async_def_op` sync operation." + ) + assert not user_instance.mutable_container + ctx = mock.Mock(spec=StartOperationContext) + result = await op_handler.start(ctx, 1) + assert isinstance(result, StartOperationResultSync) + assert result.value == 3 + assert user_instance.mutable_container == [1] diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..18788e8 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Any, Optional, Type + +from nexusrpc import Content + + +@dataclass +class DummySerializer: + value: Any + + async def serialize(self, value: Any) -> Content: + raise NotImplementedError + + async def deserialize( + self, content: Content, as_type: Optional[Type[Any]] = None + ) -> Any: + return self.value diff --git a/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py b/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py new file mode 100644 index 0000000..fec974c --- /dev/null +++ b/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py @@ -0,0 +1,46 @@ +from typing import Any, Type + +import pytest + +import nexusrpc +from nexusrpc._util import get_service_definition + + +class Output: + pass + + +class OperationDeclarationTestCase: + Interface: Type + expected_ops: dict[str, tuple[Type[Any], Type[Any]]] + + +class OperationDeclarations(OperationDeclarationTestCase): + @nexusrpc.service + class Interface: + a: nexusrpc.Operation[None, Output] + b: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="b-name") + + expected_ops = { + "a": (type(None), Output), + "b-name": (int, str), + } + + +@pytest.mark.parametrize( + "test_case", + [ + OperationDeclarations, + ], +) +def test_interface_operation_declarations( + test_case: Type[OperationDeclarationTestCase], +): + defn = get_service_definition(test_case.Interface) + assert isinstance(defn, nexusrpc.ServiceDefinition) + actual_ops = { + op.name: (op.input_type, op.output_type) + for op in test_case.Interface.__dict__.values() + if isinstance(op, nexusrpc.Operation) + } + assert actual_ops == test_case.expected_ops diff --git a/tests/service_definition/test_service_decorator_selects_correct_service_name.py b/tests/service_definition/test_service_decorator_selects_correct_service_name.py new file mode 100644 index 0000000..6a52176 --- /dev/null +++ b/tests/service_definition/test_service_decorator_selects_correct_service_name.py @@ -0,0 +1,54 @@ +from typing import Type + +import pytest + +import nexusrpc +from nexusrpc._util import get_service_definition + + +class NameOverrideTestCase: + Interface: Type + expected_name: str + + +class NotCalled(NameOverrideTestCase): + @nexusrpc.service + class Interface: + pass + + expected_name = "Interface" + + +class CalledWithoutArgs(NameOverrideTestCase): + @nexusrpc.service() + class Interface: + pass + + expected_name = "Interface" + + +class CalledWithNameArg(NameOverrideTestCase): + @nexusrpc.service(name="my-service-interface-🌈") + class Interface: + pass + + expected_name = "my-service-interface-🌈" + + +@pytest.mark.parametrize( + "test_case", + [ + NotCalled, + CalledWithoutArgs, + CalledWithNameArg, + ], +) +def test_interface_name_overrides(test_case: Type[NameOverrideTestCase]): + defn = get_service_definition(test_case.Interface) + assert defn + assert defn.name == test_case.expected_name + + +def test_name_must_not_be_empty(): + with pytest.raises(ValueError): + nexusrpc.service(name="")(object) diff --git a/tests/service_definition/test_service_decorator_validation.py b/tests/service_definition/test_service_decorator_validation.py new file mode 100644 index 0000000..0a6a57a --- /dev/null +++ b/tests/service_definition/test_service_decorator_validation.py @@ -0,0 +1,38 @@ +from typing import Type + +import pytest + +import nexusrpc + + +class Output: + pass + + +class _TestCase: + Contract: Type + expected_error: Exception + + +class DuplicateOperationNameOverride(_TestCase): + class Contract: + a: nexusrpc.Operation[None, Output] = nexusrpc.Operation(name="a") + b: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="a") + + expected_error = ValueError(r"Operation 'a' in class .* is defined multiple times") + + +@pytest.mark.parametrize( + "test_case", + [ + DuplicateOperationNameOverride, + ], +) +def test_operation_validation( + test_case: Type[_TestCase], +): + with pytest.raises( + type(test_case.expected_error), + match=str(test_case.expected_error), + ): + nexusrpc.service(test_case.Contract) diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py new file mode 100644 index 0000000..c729d71 --- /dev/null +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -0,0 +1,125 @@ +# It's important to test that retrieving annotation works both with and without `from __future__ import annotations`. +# Currently we do so by applying it in some test files and not others. +# See https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older +from __future__ import annotations + +from typing import Any, Optional, Type + +import pytest + +import nexusrpc +from nexusrpc import Operation, ServiceDefinition +from nexusrpc._util import get_service_definition + +# See https://docs.python.org/3/howto/annotations.html + + +class _TestCase: + UserService: Type[Any] + expected_operation_names: set[str] + expected_error: Optional[str] = None + + +class TypeAnnotationsOnly(_TestCase): + @nexusrpc.service + class A1: + a: Operation[int, str] + + class A2(A1): + b: Operation[int, str] + + UserService = A2 + expected_operation_names = {"a", "b"} + + +class TypeAnnotationsWithValues(_TestCase): + @nexusrpc.service + class A1: + a: Operation[int, str] = Operation[int, str](name="a-name") + + class A2(A1): + b: Operation[int, str] = Operation[int, str](name="b-name") + + UserService = A2 + expected_operation_names = {"a-name", "b-name"} + + +class TypeAnnotationsWithValuesAllFromParentClass(_TestCase): + # See https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older + # A2.__annotations__ returns annotations from parent + @nexusrpc.service + class A1: + a: Operation[int, str] = Operation[int, str](name="a-name") + b: Operation[int, str] = Operation[int, str](name="b-name") + + class A2(A1): + pass + + UserService = A2 + expected_operation_names = {"a-name", "b-name"} + + +class TypeAnnotationWithInheritedInstance(_TestCase): + @nexusrpc.service + class A1: + a: Operation[int, str] = Operation[int, str](name="a-name") + + class A2(A1): + a: Operation[int, str] + + UserService = A2 + expected_operation_names = {"a-name", "b-name"} + + +class InstanceWithoutTypeAnnotationIsAnError(_TestCase): + class A1: + a = Operation[int, str](name="a-name") + + UserService = A1 + expected_error = ( + "Operation 'a-name' has no input type, Operation 'a-name' has no output type" + ) + + +class InvalidUseOfTypeAsValue(_TestCase): + class A1: + a = Operation[int, str] + + UserService = A1 + expected_error = "Did you accidentally use '=' instead of ':'" + + +class ChildClassSynthesizedWithTypeValues(_TestCase): + @nexusrpc.service + class A1: + a: Operation[int, str] + + A2 = type("A2", (A1,), {name: Operation[int, str] for name in ["b"]}) + + UserService = A2 + expected_error = "Did you accidentally use '=' instead of ':'" + + +@pytest.mark.parametrize( + "test_case", + [ + TypeAnnotationsOnly, + TypeAnnotationsWithValues, + TypeAnnotationsWithValuesAllFromParentClass, + InstanceWithoutTypeAnnotationIsAnError, + InvalidUseOfTypeAsValue, + ChildClassSynthesizedWithTypeValues, + ], +) +def test_user_service_definition_inheritance(test_case: Type[_TestCase]): + if test_case.expected_error: + with pytest.raises(Exception, match=test_case.expected_error): + nexusrpc.service(test_case.UserService) + return + + service_defn = get_service_definition(nexusrpc.service(test_case.UserService)) + assert isinstance(service_defn, ServiceDefinition) + assert set(service_defn.operations) == test_case.expected_operation_names + for op in service_defn.operations.values(): + assert op.input_type is int + assert op.output_type is str diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py new file mode 100644 index 0000000..63186fa --- /dev/null +++ b/tests/test_get_input_and_output_types.py @@ -0,0 +1,140 @@ +import warnings +from typing import ( + Any, + Awaitable, + Callable, + Type, + Union, + get_args, + get_origin, +) + +import pytest + +from nexusrpc.handler import StartOperationContext +from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations + + +class Input: + pass + + +class Output: + pass + + +class _TestCase: + start: Callable[..., Any] + expected_types: tuple[Any, Any] + + +class SyncMethod(_TestCase): + async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... + + expected_types = (Input, Output) + + +class AsyncMethod(_TestCase): + async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... + + expected_types = (Input, Output) + + +class UnionMethod(_TestCase): + async def start( + self, ctx: StartOperationContext, i: Input + ) -> Union[Output, Awaitable[Output]]: ... + + expected_types = (Input, Union[Output, Awaitable[Output]]) + + +class MissingInputAnnotationInUnionMethod(_TestCase): + async def start( + self, ctx: StartOperationContext, i + ) -> Union[Output, Awaitable[Output]]: ... + + expected_types = (None, Union[Output, Awaitable[Output]]) + + +class TooFewParams(_TestCase): + async def start(self, i: Input) -> Output: ... + + expected_types = (None, Output) + + +class TooManyParams(_TestCase): + async def start( + self, ctx: StartOperationContext, i: Input, extra: int + ) -> Output: ... + + expected_types = (None, Output) + + +class WrongOptionsType(_TestCase): + async def start(self, ctx: int, i: Input) -> Output: ... + + expected_types = (None, Output) + + +class NoReturnHint(_TestCase): + async def start(self, ctx: StartOperationContext, i: Input): ... + + expected_types = (Input, None) + + +class NoInputAnnotation(_TestCase): + async def start(self, ctx: StartOperationContext, i) -> Output: ... + + expected_types = (None, Output) + + +class NoOptionsAnnotation(_TestCase): + async def start(self, ctx, i: Input) -> Output: ... + + expected_types = (None, Output) + + +class AllAnnotationsMissing(_TestCase): + async def start(self, ctx: StartOperationContext, i): ... + + expected_types = (None, None) + + +class ExplicitNoneTypes(_TestCase): + async def start(self, ctx: StartOperationContext, i: None) -> None: ... + + expected_types = (type(None), type(None)) + + +@pytest.mark.parametrize( + "test_case", + [ + SyncMethod, + AsyncMethod, + UnionMethod, + TooFewParams, + TooManyParams, + WrongOptionsType, + NoReturnHint, + NoInputAnnotation, + NoOptionsAnnotation, + MissingInputAnnotationInUnionMethod, + AllAnnotationsMissing, + ExplicitNoneTypes, + ], +) +def test_get_input_and_output_types(test_case: Type[_TestCase]): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + input_type, output_type = get_start_method_input_and_output_type_annotations( + test_case.start + ) + expected_input_type, expected_output_type = test_case.expected_types + assert input_type is expected_input_type + + expected_origin = get_origin(expected_output_type) + if expected_origin: # Awaitable and Union cases + assert get_origin(output_type) is expected_origin + assert get_args(output_type) == get_args(expected_output_type) + else: + assert output_type is expected_output_type diff --git a/tests/test_type_checking.py b/tests/test_type_checking.py new file mode 100644 index 0000000..b35929f --- /dev/null +++ b/tests/test_type_checking.py @@ -0,0 +1,92 @@ +import re +import subprocess +from pathlib import Path + +import pytest + + +def pytest_generate_tests(metafunc): + """Dynamically generate test cases for files with type error assertions.""" + if metafunc.function.__name__ == "test_type_checking": + tests_dir = Path(__file__).parent + files_with_assertions = [] + + for test_file in tests_dir.rglob("test_*.py"): + if test_file.name == "test_type_checking.py": + continue + + if _has_type_error_assertions(test_file): + files_with_assertions.append(test_file) + + metafunc.parametrize("test_file", files_with_assertions, ids=lambda f: f.name) + + +def test_type_checking(test_file: Path): + """ + Validate type error assertions in a single test file. + + For each line with a comment of the form `# assert-type-error: "pattern"`, + verify that mypy reports an error on that line matching the pattern. + Also verify that there are no unexpected type errors. + """ + expected_errors = _get_expected_errors(test_file) + actual_errors = _get_actual_errors(test_file) + + # Check that all expected errors are present and match + for line_num, expected_pattern in expected_errors.items(): + if line_num not in actual_errors: + pytest.fail( + f"{test_file}:{line_num}: Expected type error matching '{expected_pattern}' but no error found" + ) + + actual_msg = actual_errors[line_num] + if not re.search(expected_pattern, actual_msg): + pytest.fail( + f"{test_file}:{line_num}: Expected error matching '{expected_pattern}' but got '{actual_msg}'" + ) + + # Check that there are no unexpected errors + for line_num, actual_msg in actual_errors.items(): + if line_num not in expected_errors: + pytest.fail(f"{test_file}:{line_num}: Unexpected type error: {actual_msg}") + + +def _has_type_error_assertions(test_file: Path) -> bool: + """Check if a file contains any type error assertions.""" + with open(test_file) as f: + for line in f: + if re.search(r'# assert-type-error:\s*["\'](.+)["\']', line): + return True + return False + + +def _get_expected_errors(test_file: Path) -> dict[int, str]: + """Parse expected type errors from comments in a file.""" + expected_errors = {} + with open(test_file) as f: + for line_num, line in enumerate(f, 1): + match = re.search(r'# assert-type-error:\s*["\'](.+)["\']', line) + if match: + expected_errors[line_num] = match.group(1) + return expected_errors + + +def _get_actual_errors(test_file: Path) -> dict[int, str]: + """Run mypy on a file and parse the actual type errors.""" + result = subprocess.run( + ["uv", "run", "mypy", "--check-untyped-defs", str(test_file)], + capture_output=True, + text=True, + ) + + actual_errors = {} + for line in result.stdout.splitlines(): + # mypy output format: filename:line: error: message (uses relative path from cwd) + rel_path = test_file.relative_to(Path.cwd()) + match = re.match(rf"{re.escape(str(rel_path))}:(\d+):\s*error:\s*(.+)", line) + if match: + line_num = int(match.group(1)) + error_msg = match.group(2) + actual_errors[line_num] = error_msg + + return actual_errors diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..4e62382 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,60 @@ +from functools import partial + +from nexusrpc._util import is_async_callable, is_callable + + +def test_def(): + def f(a: int, b: int) -> None: + pass + + g = partial(f, a=1) + assert is_callable(f) + assert is_callable(g) + assert not is_async_callable(f) + assert not is_async_callable(g) + + +def test_callable_instance(): + class f_cls: + def __call__(self, a: int, b: int) -> None: + pass + + f = f_cls() + g = partial(f, a=1) + + assert is_callable(f) + assert is_callable(g) + assert not is_async_callable(f) + assert not is_async_callable(g) + + +def test_async_def(): + async def f(a: int, b: int) -> None: + pass + + g = partial(f, a=1) + assert is_callable(f) + assert is_callable(g) + assert is_async_callable(f) + assert is_async_callable(g) + + +def test_async_callable_instance(): + class f_cls: + async def __call__(self, a: int, b: int) -> None: + pass + + f = f_cls() + g = partial(f, a=1) + + assert is_callable(f) + assert is_callable(g) + assert is_async_callable(f) + assert is_async_callable(g) + + +def test_partial(): + def f(a: int, b: int) -> None: + pass + + g = partial(f, a=1) diff --git a/uv.lock b/uv.lock index 5b7d681..52da269 100644 --- a/uv.lock +++ b/uv.lock @@ -1,22 +1,888 @@ version = 1 +revision = 2 requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "automat" +version = "25.4.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/3a/0cbeb04ea57d2493f3ec5a069a117ab467f85e4a10017c6d854ddcbff104/cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11", size = 28985, upload-time = "2025-04-30T16:45:06.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4c/800b0607b00b3fd20f1087f80ab53d6b4d005515b0f773e4831e37cfa83f/cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae", size = 21802, upload-time = "2025-04-30T16:45:03.863Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[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 = "configargparse" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/4d/6c9ef746dfcc2a32e26f3860bb4a011c008c392b83eabdfb598d1a8bbe5d/configargparse-1.7.1.tar.gz", hash = "sha256:79c2ddae836a1e5914b71d58e4b9adbd9f7779d4e6351a637b7d2d9b6c46d3d9", size = 43958, upload-time = "2025-05-23T14:26:17.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size = 25607, upload-time = "2025-05-23T14:26:15.923Z" }, +] + +[[package]] +name = "constantly" +version = "23.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, +] + +[[package]] +name = "coverage" +version = "7.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/998afa4a0ecdf9b1981ae05415dad2d4e7716e1b1f00abbd91691ac09ac9/coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27", size = 812759, upload-time = "2025-05-23T11:39:57.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6b/7dd06399a5c0b81007e3a6af0395cd60e6a30f959f8d407d3ee04642e896/coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a", size = 211573, upload-time = "2025-05-23T11:37:47.207Z" }, + { url = "https://files.pythonhosted.org/packages/f0/df/2b24090820a0bac1412955fb1a4dade6bc3b8dcef7b899c277ffaf16916d/coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be", size = 212006, upload-time = "2025-05-23T11:37:50.289Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c4/e4e3b998e116625562a872a342419652fa6ca73f464d9faf9f52f1aff427/coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3", size = 241128, upload-time = "2025-05-23T11:37:52.229Z" }, + { url = "https://files.pythonhosted.org/packages/b1/67/b28904afea3e87a895da850ba587439a61699bf4b73d04d0dfd99bbd33b4/coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6", size = 239026, upload-time = "2025-05-23T11:37:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0f/47bf7c5630d81bc2cd52b9e13043685dbb7c79372a7f5857279cc442b37c/coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622", size = 240172, upload-time = "2025-05-23T11:37:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/ba/38/af3eb9d36d85abc881f5aaecf8209383dbe0fa4cac2d804c55d05c51cb04/coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c", size = 240086, upload-time = "2025-05-23T11:37:57.724Z" }, + { url = "https://files.pythonhosted.org/packages/9e/64/c40c27c2573adeba0fe16faf39a8aa57368a1f2148865d6bb24c67eadb41/coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3", size = 238792, upload-time = "2025-05-23T11:37:59.737Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ab/b7c85146f15457671c1412afca7c25a5696d7625e7158002aa017e2d7e3c/coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404", size = 239096, upload-time = "2025-05-23T11:38:01.693Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/9446dad1310905fb1dc284d60d4320a5b25d4e3e33f9ea08b8d36e244e23/coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7", size = 214144, upload-time = "2025-05-23T11:38:03.68Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/792e66ad7b8b0df757db8d47af0c23659cdb5a65ef7ace8b111cacdbee89/coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347", size = 215043, upload-time = "2025-05-23T11:38:05.217Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4d/1ff618ee9f134d0de5cc1661582c21a65e06823f41caf801aadf18811a8e/coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9", size = 211692, upload-time = "2025-05-23T11:38:08.485Z" }, + { url = "https://files.pythonhosted.org/packages/96/fa/c3c1b476de96f2bc7a8ca01a9f1fcb51c01c6b60a9d2c3e66194b2bdb4af/coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879", size = 212115, upload-time = "2025-05-23T11:38:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c2/5414c5a1b286c0f3881ae5adb49be1854ac5b7e99011501f81c8c1453065/coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a", size = 244740, upload-time = "2025-05-23T11:38:11.947Z" }, + { url = "https://files.pythonhosted.org/packages/cd/46/1ae01912dfb06a642ef3dd9cf38ed4996fda8fe884dab8952da616f81a2b/coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5", size = 242429, upload-time = "2025-05-23T11:38:13.955Z" }, + { url = "https://files.pythonhosted.org/packages/06/58/38c676aec594bfe2a87c7683942e5a30224791d8df99bcc8439fde140377/coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11", size = 244218, upload-time = "2025-05-23T11:38:15.631Z" }, + { url = "https://files.pythonhosted.org/packages/80/0c/95b1023e881ce45006d9abc250f76c6cdab7134a1c182d9713878dfefcb2/coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a", size = 243865, upload-time = "2025-05-23T11:38:17.622Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/0ae95989285a39e0839c959fe854a3ae46c06610439350d1ab860bf020ac/coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb", size = 242038, upload-time = "2025-05-23T11:38:19.966Z" }, + { url = "https://files.pythonhosted.org/packages/4d/82/40e55f7c0eb5e97cc62cbd9d0746fd24e8caf57be5a408b87529416e0c70/coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54", size = 242567, upload-time = "2025-05-23T11:38:21.912Z" }, + { url = "https://files.pythonhosted.org/packages/f9/35/66a51adc273433a253989f0d9cc7aa6bcdb4855382cf0858200afe578861/coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a", size = 214194, upload-time = "2025-05-23T11:38:23.571Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8f/a543121f9f5f150eae092b08428cb4e6b6d2d134152c3357b77659d2a605/coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975", size = 215109, upload-time = "2025-05-23T11:38:25.137Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/6cc84b68d4f35186463cd7ab1da1169e9abb59870c0f6a57ea6aba95f861/coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53", size = 213521, upload-time = "2025-05-23T11:38:27.123Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2a/1da1ada2e3044fcd4a3254fb3576e160b8fe5b36d705c8a31f793423f763/coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c", size = 211876, upload-time = "2025-05-23T11:38:29.01Z" }, + { url = "https://files.pythonhosted.org/packages/70/e9/3d715ffd5b6b17a8be80cd14a8917a002530a99943cc1939ad5bb2aa74b9/coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1", size = 212130, upload-time = "2025-05-23T11:38:30.675Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/fdce62bb3c21649abfd91fbdcf041fb99be0d728ff00f3f9d54d97ed683e/coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279", size = 246176, upload-time = "2025-05-23T11:38:32.395Z" }, + { url = "https://files.pythonhosted.org/packages/a7/52/decbbed61e03b6ffe85cd0fea360a5e04a5a98a7423f292aae62423b8557/coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99", size = 243068, upload-time = "2025-05-23T11:38:33.989Z" }, + { url = "https://files.pythonhosted.org/packages/38/6c/d0e9c0cce18faef79a52778219a3c6ee8e336437da8eddd4ab3dbd8fadff/coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20", size = 245328, upload-time = "2025-05-23T11:38:35.568Z" }, + { url = "https://files.pythonhosted.org/packages/f0/70/f703b553a2f6b6c70568c7e398ed0789d47f953d67fbba36a327714a7bca/coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2", size = 245099, upload-time = "2025-05-23T11:38:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fb/4cbb370dedae78460c3aacbdad9d249e853f3bc4ce5ff0e02b1983d03044/coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57", size = 243314, upload-time = "2025-05-23T11:38:39.238Z" }, + { url = "https://files.pythonhosted.org/packages/39/9f/1afbb2cb9c8699b8bc38afdce00a3b4644904e6a38c7bf9005386c9305ec/coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f", size = 244489, upload-time = "2025-05-23T11:38:40.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/fa/f3e7ec7d220bff14aba7a4786ae47043770cbdceeea1803083059c878837/coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8", size = 214366, upload-time = "2025-05-23T11:38:43.551Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/9cbeade19b7e8e853e7ffc261df885d66bf3a782c71cba06c17df271f9e6/coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223", size = 215165, upload-time = "2025-05-23T11:38:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/e2528bf1237d2448f882bbebaec5c3500ef07301816c5c63464b9da4d88a/coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f", size = 213548, upload-time = "2025-05-23T11:38:46.74Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/eb6400a745ad3b265bac36e8077fdffcf0268bdbbb6c02b7220b624c9b31/coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca", size = 211898, upload-time = "2025-05-23T11:38:49.066Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7c/bdbf113f92683024406a1cd226a199e4200a2001fc85d6a6e7e299e60253/coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d", size = 212171, upload-time = "2025-05-23T11:38:51.207Z" }, + { url = "https://files.pythonhosted.org/packages/91/22/594513f9541a6b88eb0dba4d5da7d71596dadef6b17a12dc2c0e859818a9/coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85", size = 245564, upload-time = "2025-05-23T11:38:52.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/2860fd6abeebd9f2efcfe0fd376226938f22afc80c1943f363cd3c28421f/coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257", size = 242719, upload-time = "2025-05-23T11:38:54.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/f5f50f61b6332451520e6cdc2401700c48310c64bc2dd34027a47d6ab4ca/coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108", size = 244634, upload-time = "2025-05-23T11:38:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/3b/70/7f4e919039ab7d944276c446b603eea84da29ebcf20984fb1fdf6e602028/coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0", size = 244824, upload-time = "2025-05-23T11:38:59.421Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/36297a4c0cea4de2b2c442fe32f60c3991056c59cdc3cdd5346fbb995c97/coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050", size = 242872, upload-time = "2025-05-23T11:39:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/e041f1b9420f7b786b1367fa2a375703889ef376e0d48de9f5723fb35f11/coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48", size = 244179, upload-time = "2025-05-23T11:39:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/bd/db/3c2bf49bdc9de76acf2491fc03130c4ffc51469ce2f6889d2640eb563d77/coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7", size = 214393, upload-time = "2025-05-23T11:39:05.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dc/947e75d47ebbb4b02d8babb1fad4ad381410d5bc9da7cfca80b7565ef401/coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3", size = 215194, upload-time = "2025-05-23T11:39:07.171Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/a980f7df8a37eaf0dc60f932507fda9656b3a03f0abf188474a0ea188d6d/coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7", size = 213580, upload-time = "2025-05-23T11:39:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6a/25a37dd90f6c95f59355629417ebcb74e1c34e38bb1eddf6ca9b38b0fc53/coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008", size = 212734, upload-time = "2025-05-23T11:39:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/3a728b3118988725f40950931abb09cd7f43b3c740f4640a59f1db60e372/coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36", size = 212959, upload-time = "2025-05-23T11:39:12.751Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/212d94e6add3a3c3f412d664aee452045ca17a066def8b9421673e9482c4/coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46", size = 257024, upload-time = "2025-05-23T11:39:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/afc03f0883b1e51bbe804707aae62e29c4e8c8bbc365c75e3e4ddeee9ead/coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be", size = 252867, upload-time = "2025-05-23T11:39:17.64Z" }, + { url = "https://files.pythonhosted.org/packages/18/a2/3699190e927b9439c6ded4998941a3c1d6fa99e14cb28d8536729537e307/coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740", size = 255096, upload-time = "2025-05-23T11:39:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/b4/06/16e3598b9466456b718eb3e789457d1a5b8bfb22e23b6e8bbc307df5daf0/coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625", size = 256276, upload-time = "2025-05-23T11:39:21.077Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d5/4b5a120d5d0223050a53d2783c049c311eea1709fa9de12d1c358e18b707/coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b", size = 254478, upload-time = "2025-05-23T11:39:22.838Z" }, + { url = "https://files.pythonhosted.org/packages/ba/85/f9ecdb910ecdb282b121bfcaa32fa8ee8cbd7699f83330ee13ff9bbf1a85/coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199", size = 255255, upload-time = "2025-05-23T11:39:24.644Z" }, + { url = "https://files.pythonhosted.org/packages/50/63/2d624ac7d7ccd4ebbd3c6a9eba9d7fc4491a1226071360d59dd84928ccb2/coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8", size = 215109, upload-time = "2025-05-23T11:39:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/5e/7053b71462e970e869111c1853afd642212568a350eba796deefdfbd0770/coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d", size = 216268, upload-time = "2025-05-23T11:39:28.429Z" }, + { url = "https://files.pythonhosted.org/packages/07/69/afa41aa34147655543dbe96994f8a246daf94b361ccf5edfd5df62ce066a/coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b", size = 214071, upload-time = "2025-05-23T11:39:30.55Z" }, + { url = "https://files.pythonhosted.org/packages/71/1e/388267ad9c6aa126438acc1ceafede3bb746afa9872e3ec5f0691b7d5efa/coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a", size = 211566, upload-time = "2025-05-23T11:39:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a5/acc03e5cf0bba6357f5e7c676343de40fbf431bb1e115fbebf24b2f7f65e/coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d", size = 211996, upload-time = "2025-05-23T11:39:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a2/0fc0a9f6b7c24fa4f1d7210d782c38cb0d5e692666c36eaeae9a441b6755/coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca", size = 240741, upload-time = "2025-05-23T11:39:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/e6/da/1c6ba2cf259710eed8916d4fd201dccc6be7380ad2b3b9f63ece3285d809/coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d", size = 238672, upload-time = "2025-05-23T11:39:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/ac/51/c8fae0dc3ca421e6e2509503696f910ff333258db672800c3bdef256265a/coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787", size = 239769, upload-time = "2025-05-23T11:39:40.24Z" }, + { url = "https://files.pythonhosted.org/packages/59/8e/b97042ae92c59f40be0c989df090027377ba53f2d6cef73c9ca7685c26a6/coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7", size = 239555, upload-time = "2025-05-23T11:39:42.3Z" }, + { url = "https://files.pythonhosted.org/packages/47/35/b8893e682d6e96b1db2af5997fc13ef62219426fb17259d6844c693c5e00/coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3", size = 237768, upload-time = "2025-05-23T11:39:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/03/6c/023b0b9a764cb52d6243a4591dcb53c4caf4d7340445113a1f452bb80591/coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7", size = 238757, upload-time = "2025-05-23T11:39:46.195Z" }, + { url = "https://files.pythonhosted.org/packages/03/ed/3af7e4d721bd61a8df7de6de9e8a4271e67f3d9e086454558fd9f48eb4f6/coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a", size = 214166, upload-time = "2025-05-23T11:39:47.934Z" }, + { url = "https://files.pythonhosted.org/packages/9d/30/ee774b626773750dc6128354884652507df3c59d6aa8431526107e595227/coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e", size = 215050, upload-time = "2025-05-23T11:39:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/69/2f/572b29496d8234e4a7773200dd835a0d32d9e171f2d974f3fe04a9dbc271/coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837", size = 203636, upload-time = "2025-05-23T11:39:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/0b9c32220ad694d66062f571cc5cedfa9997b64a591e8a500bb63de1bd40/coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32", size = 203623, upload-time = "2025-05-23T11:39:53.846Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "incremental" +version = "24.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/87/156b374ff6578062965afe30cc57627d35234369b3336cf244b240c8d8e6/incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9", size = 28157, upload-time = "2024-07-29T20:03:55.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/38/221e5b2ae676a3938c2c1919131410c342b6efc2baffeda395dd66eeca8f/incremental-24.7.2-py3-none-any.whl", hash = "sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe", size = 20516, upload-time = "2024-07-29T20:03:53.677Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "lunr" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e9/b3dee02312eaa2a1b3212b6e20a90a81adba489b404d4f0ffbbe8258b761/lunr-0.8.0.tar.gz", hash = "sha256:b46cf5059578d277a14bfc901bb3d5666d013bf73c035331ac0222fdac358228", size = 1147598, upload-time = "2025-03-08T13:31:40.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8b/bf975fabd26195915ebdf3e4252baa936f1863bcd9eb49598b705638f5d5/lunr-0.8.0-py3-none-any.whl", hash = "sha256:a2bc4e08dbb35b32723006bf2edbe6dc1f4f4b95955eea0d23165a184d276ce8", size = 35211, upload-time = "2025-03-08T13:31:38.657Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/7555686ae7ff5731205df1012ede15dd9d927f6227ea151e901c7406af4f/msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e", size = 167260, upload-time = "2024-09-10T04:25:52.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/f9/a892a6038c861fa849b11a2bb0502c07bc698ab6ea53359e5771397d883b/msgpack-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd", size = 150428, upload-time = "2024-09-10T04:25:43.089Z" }, + { url = "https://files.pythonhosted.org/packages/df/7a/d174cc6a3b6bb85556e6a046d3193294a92f9a8e583cdbd46dc8a1d7e7f4/msgpack-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d", size = 84131, upload-time = "2024-09-10T04:25:30.22Z" }, + { url = "https://files.pythonhosted.org/packages/08/52/bf4fbf72f897a23a56b822997a72c16de07d8d56d7bf273242f884055682/msgpack-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:914571a2a5b4e7606997e169f64ce53a8b1e06f2cf2c3a7273aa106236d43dd5", size = 81215, upload-time = "2024-09-10T04:24:54.329Z" }, + { url = "https://files.pythonhosted.org/packages/02/95/dc0044b439b518236aaf012da4677c1b8183ce388411ad1b1e63c32d8979/msgpack-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c921af52214dcbb75e6bdf6a661b23c3e6417f00c603dd2070bccb5c3ef499f5", size = 371229, upload-time = "2024-09-10T04:25:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/ff/75/09081792db60470bef19d9c2be89f024d366b1e1973c197bb59e6aabc647/msgpack-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8ce0b22b890be5d252de90d0e0d119f363012027cf256185fc3d474c44b1b9e", size = 378034, upload-time = "2024-09-10T04:25:22.097Z" }, + { url = "https://files.pythonhosted.org/packages/32/d3/c152e0c55fead87dd948d4b29879b0f14feeeec92ef1fd2ec21b107c3f49/msgpack-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:73322a6cc57fcee3c0c57c4463d828e9428275fb85a27aa2aa1a92fdc42afd7b", size = 363070, upload-time = "2024-09-10T04:24:43.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/82e73506dd55f9e43ac8aa007c9dd088c6f0de2aa19e8f7330e6a65879fc/msgpack-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1f3c3d21f7cf67bcf2da8e494d30a75e4cf60041d98b3f79875afb5b96f3a3f", size = 359863, upload-time = "2024-09-10T04:24:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a0/3d093b248837094220e1edc9ec4337de3443b1cfeeb6e0896af8ccc4cc7a/msgpack-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64fc9068d701233effd61b19efb1485587560b66fe57b3e50d29c5d78e7fef68", size = 368166, upload-time = "2024-09-10T04:24:19.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/7646f14f06838b406cf5a6ddbb7e8dc78b4996d891ab3b93c33d1ccc8678/msgpack-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:42f754515e0f683f9c79210a5d1cad631ec3d06cea5172214d2176a42e67e19b", size = 370105, upload-time = "2024-09-10T04:25:35.141Z" }, + { url = "https://files.pythonhosted.org/packages/67/fa/dbbd2443e4578e165192dabbc6a22c0812cda2649261b1264ff515f19f15/msgpack-1.1.0-cp310-cp310-win32.whl", hash = "sha256:3df7e6b05571b3814361e8464f9304c42d2196808e0119f55d0d3e62cd5ea044", size = 68513, upload-time = "2024-09-10T04:24:36.099Z" }, + { url = "https://files.pythonhosted.org/packages/24/ce/c2c8fbf0ded750cb63cbcbb61bc1f2dfd69e16dca30a8af8ba80ec182dcd/msgpack-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:685ec345eefc757a7c8af44a3032734a739f8c45d1b0ac45efc5d8977aa4720f", size = 74687, upload-time = "2024-09-10T04:24:23.394Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5e/a4c7154ba65d93be91f2f1e55f90e76c5f91ccadc7efc4341e6f04c8647f/msgpack-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7", size = 150803, upload-time = "2024-09-10T04:24:40.911Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/687684164698f1d51c41778c838d854965dd284a4b9d3a44beba9265c931/msgpack-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa", size = 84343, upload-time = "2024-09-10T04:24:50.283Z" }, + { url = "https://files.pythonhosted.org/packages/42/ae/d3adea9bb4a1342763556078b5765e666f8fdf242e00f3f6657380920972/msgpack-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701", size = 81408, upload-time = "2024-09-10T04:25:12.774Z" }, + { url = "https://files.pythonhosted.org/packages/dc/17/6313325a6ff40ce9c3207293aee3ba50104aed6c2c1559d20d09e5c1ff54/msgpack-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6", size = 396096, upload-time = "2024-09-10T04:24:37.245Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/ad7b84b91ab5a324e707f4c9761633e357820b011a01e34ce658c1dda7cc/msgpack-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59", size = 403671, upload-time = "2024-09-10T04:25:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0b/fd5b7c0b308bbf1831df0ca04ec76fe2f5bf6319833646b0a4bd5e9dc76d/msgpack-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0", size = 387414, upload-time = "2024-09-10T04:25:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/ff8233b7c6e9929a1f5da3c7860eccd847e2523ca2de0d8ef4878d354cfa/msgpack-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e", size = 383759, upload-time = "2024-09-10T04:25:03.366Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1b/eb82e1fed5a16dddd9bc75f0854b6e2fe86c0259c4353666d7fab37d39f4/msgpack-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6", size = 394405, upload-time = "2024-09-10T04:25:07.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/2e/962c6004e373d54ecf33d695fb1402f99b51832631e37c49273cc564ffc5/msgpack-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5", size = 396041, upload-time = "2024-09-10T04:25:48.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/20/6e03342f629474414860c48aeffcc2f7f50ddaf351d95f20c3f1c67399a8/msgpack-1.1.0-cp311-cp311-win32.whl", hash = "sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88", size = 68538, upload-time = "2024-09-10T04:24:29.953Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c4/5a582fc9a87991a3e6f6800e9bb2f3c82972912235eb9539954f3e9997c7/msgpack-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788", size = 74871, upload-time = "2024-09-10T04:25:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/716b7ca1dbde63290d2973d22bbef1b5032ca634c3ff4384a958ec3f093a/msgpack-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d", size = 152421, upload-time = "2024-09-10T04:25:49.63Z" }, + { url = "https://files.pythonhosted.org/packages/70/da/5312b067f6773429cec2f8f08b021c06af416bba340c912c2ec778539ed6/msgpack-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2", size = 85277, upload-time = "2024-09-10T04:24:48.562Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/da7f3ae4462e8bb98af0d5bdf2707f1b8c65a0d4f496e46b6afb06cbc286/msgpack-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420", size = 82222, upload-time = "2024-09-10T04:25:36.49Z" }, + { url = "https://files.pythonhosted.org/packages/33/af/dc95c4b2a49cff17ce47611ca9ba218198806cad7796c0b01d1e332c86bb/msgpack-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2", size = 392971, upload-time = "2024-09-10T04:24:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/65af8de681fa8255402c80eda2a501ba467921d5a7a028c9c22a2c2eedb5/msgpack-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39", size = 401403, upload-time = "2024-09-10T04:25:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/97/8c/e333690777bd33919ab7024269dc3c41c76ef5137b211d776fbb404bfead/msgpack-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f", size = 385356, upload-time = "2024-09-10T04:25:31.406Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/406795ba478dc1c890559dd4e89280fa86506608a28ccf3a72fbf45df9f5/msgpack-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247", size = 383028, upload-time = "2024-09-10T04:25:17.08Z" }, + { url = "https://files.pythonhosted.org/packages/e7/69/053b6549bf90a3acadcd8232eae03e2fefc87f066a5b9fbb37e2e608859f/msgpack-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c", size = 391100, upload-time = "2024-09-10T04:25:08.993Z" }, + { url = "https://files.pythonhosted.org/packages/23/f0/d4101d4da054f04274995ddc4086c2715d9b93111eb9ed49686c0f7ccc8a/msgpack-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b", size = 394254, upload-time = "2024-09-10T04:25:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/1c/12/cf07458f35d0d775ff3a2dc5559fa2e1fcd06c46f1ef510e594ebefdca01/msgpack-1.1.0-cp312-cp312-win32.whl", hash = "sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b", size = 69085, upload-time = "2024-09-10T04:25:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/73/80/2708a4641f7d553a63bc934a3eb7214806b5b39d200133ca7f7afb0a53e8/msgpack-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f", size = 75347, upload-time = "2024-09-10T04:25:33.106Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b0/380f5f639543a4ac413e969109978feb1f3c66e931068f91ab6ab0f8be00/msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf", size = 151142, upload-time = "2024-09-10T04:24:59.656Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ee/be57e9702400a6cb2606883d55b05784fada898dfc7fd12608ab1fdb054e/msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330", size = 84523, upload-time = "2024-09-10T04:25:37.924Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3a/2919f63acca3c119565449681ad08a2f84b2171ddfcff1dba6959db2cceb/msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734", size = 81556, upload-time = "2024-09-10T04:24:28.296Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/a11113d9e5c1498c145a8925768ea2d5fce7cbab15c99cda655aa09947ed/msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e", size = 392105, upload-time = "2024-09-10T04:25:20.153Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7b/2c1d74ca6c94f70a1add74a8393a0138172207dc5de6fc6269483519d048/msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca", size = 399979, upload-time = "2024-09-10T04:25:41.75Z" }, + { url = "https://files.pythonhosted.org/packages/82/8c/cf64ae518c7b8efc763ca1f1348a96f0e37150061e777a8ea5430b413a74/msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915", size = 383816, upload-time = "2024-09-10T04:24:45.826Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/a847ef7a0f5ef3fa94ae20f52a4cacf596a4e4a010197fbcc27744eb9a83/msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d", size = 380973, upload-time = "2024-09-10T04:25:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/c74cf6e1126faa93185d3b830ee97246ecc4fe12cf9d2d31318ee4246994/msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434", size = 387435, upload-time = "2024-09-10T04:24:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/7a/40/631c238f1f338eb09f4acb0f34ab5862c4e9d7eda11c1b685471a4c5ea37/msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c", size = 399082, upload-time = "2024-09-10T04:25:18.398Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/fa8a952be252a1555ed39f97c06778e3aeb9123aa4cccc0fd2acd0b4e315/msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc", size = 69037, upload-time = "2024-09-10T04:24:52.798Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/8bd826dd03e022153bfa1766dcdec4976d6c818865ed54223d71f07862b3/msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f", size = 75140, upload-time = "2024-09-10T04:24:31.288Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3b/544a5c5886042b80e1f4847a4757af3430f60d106d8d43bb7be72c9e9650/msgpack-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:53258eeb7a80fc46f62fd59c876957a2d0e15e6449a9e71842b6d24419d88ca1", size = 150713, upload-time = "2024-09-10T04:25:23.397Z" }, + { url = "https://files.pythonhosted.org/packages/93/af/d63f25bcccd3d6f06fd518ba4a321f34a4370c67b579ca5c70b4a37721b4/msgpack-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e7b853bbc44fb03fbdba34feb4bd414322180135e2cb5164f20ce1c9795ee48", size = 84277, upload-time = "2024-09-10T04:24:34.656Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/5c0dfb0009b9f96328664fecb9f8e4e9c8a1ae919e6d53986c1b813cb493/msgpack-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3e9b4936df53b970513eac1758f3882c88658a220b58dcc1e39606dccaaf01c", size = 81357, upload-time = "2024-09-10T04:24:56.603Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/3a9ee6ec9fc3e47681ad39b4d344ee04ff20a776b594fba92d88d8b68356/msgpack-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46c34e99110762a76e3911fc923222472c9d681f1094096ac4102c18319e6468", size = 371256, upload-time = "2024-09-10T04:25:11.473Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0a/8a213cecea7b731c540f25212ba5f9a818f358237ac51a44d448bd753690/msgpack-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a706d1e74dd3dea05cb54580d9bd8b2880e9264856ce5068027eed09680aa74", size = 377868, upload-time = "2024-09-10T04:25:24.535Z" }, + { url = "https://files.pythonhosted.org/packages/1b/94/a82b0db0981e9586ed5af77d6cfb343da05d7437dceaae3b35d346498110/msgpack-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:534480ee5690ab3cbed89d4c8971a5c631b69a8c0883ecfea96c19118510c846", size = 363370, upload-time = "2024-09-10T04:24:21.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/fc/6c7f0dcc1c913e14861e16eaf494c07fc1dde454ec726ff8cebcf348ae53/msgpack-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8cf9e8c3a2153934a23ac160cc4cba0ec035f6867c8013cc6077a79823370346", size = 358970, upload-time = "2024-09-10T04:24:24.741Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c6/e4a04c0089deace870dabcdef5c9f12798f958e2e81d5012501edaff342f/msgpack-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3180065ec2abbe13a4ad37688b61b99d7f9e012a535b930e0e683ad6bc30155b", size = 366358, upload-time = "2024-09-10T04:25:45.955Z" }, + { url = "https://files.pythonhosted.org/packages/b6/54/7d8317dac590cf16b3e08e3fb74d2081e5af44eb396f0effa13f17777f30/msgpack-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c5a91481a3cc573ac8c0d9aace09345d989dc4a0202b7fcb312c88c26d4e71a8", size = 370336, upload-time = "2024-09-10T04:24:26.918Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6f/a5a1f43b6566831e9630e5bc5d86034a8884386297302be128402555dde1/msgpack-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f80bc7d47f76089633763f952e67f8214cb7b3ee6bfa489b3cb6a84cfac114cd", size = 68683, upload-time = "2024-09-10T04:24:32.984Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/2162621e18dbc36e2bc8492fd0e97b3975f5d89fe0472ae6d5f7fbdd8cf7/msgpack-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:4d1b7ff2d6146e16e8bd665ac726a89c74163ef8cd39fa8c1087d4e52d3a2325", size = 74787, upload-time = "2024-09-10T04:25:14.524Z" }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433, upload-time = "2025-02-05T03:49:29.145Z" }, + { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472, upload-time = "2025-02-05T03:49:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424, upload-time = "2025-02-05T03:49:46.908Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450, upload-time = "2025-02-05T03:50:05.89Z" }, + { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765, upload-time = "2025-02-05T03:49:33.56Z" }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload-time = "2025-02-05T03:49:38.981Z" }, + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338, upload-time = "2025-02-05T03:50:17.287Z" }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540, upload-time = "2025-02-05T03:49:51.21Z" }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051, upload-time = "2025-02-05T03:50:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751, upload-time = "2025-02-05T03:49:42.408Z" }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783, upload-time = "2025-02-05T03:49:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload-time = "2025-02-05T03:49:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129, upload-time = "2025-02-05T03:50:24.509Z" }, + { url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335, upload-time = "2025-02-05T03:49:36.398Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935, upload-time = "2025-02-05T03:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827, upload-time = "2025-02-05T03:48:59.458Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924, upload-time = "2025-02-05T03:50:03.12Z" }, + { url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176, upload-time = "2025-02-05T03:50:10.86Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] [[package]] name = "nexus-rpc" -version = "0.1.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "typing-extensions" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "poethepoet" }, + { name = "pydoctor" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-pretty" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [{ name = "typing-extensions", specifier = ">=4.12.2" }] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15.0" }, + { name = "poethepoet", specifier = ">=0.35.0" }, + { name = "pydoctor", specifier = ">=25.4.0" }, + { name = "pyright", specifier = ">=1.1.402" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.26.0" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-pretty", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.12.0" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/b1/d4f4361b278fae10f6074675385ce3acf53c647f8e6eeba22c652f8ba985/poethepoet-0.35.0.tar.gz", hash = "sha256:b396ae862d7626e680bbd0985b423acf71634ce93a32d8b5f38340f44f5fbc3e", size = 66006, upload-time = "2025-06-09T12:58:18.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/08/abc2d7e2400dd8906e3208f9b88ac610f097d7ee0c7a1fa4a157b49a9e86/poethepoet-0.35.0-py3-none-any.whl", hash = "sha256:bed5ae1fd63f179dfa67aabb93fa253d79695c69667c927d8b24ff378799ea75", size = 87164, upload-time = "2025-06-09T12:58:17.084Z" }, +] + +[[package]] +name = "pydoctor" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cachecontrol", extra = ["filecache"] }, + { name = "configargparse" }, + { name = "docutils" }, + { name = "lunr" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "toml", marker = "python_full_version < '3.11'" }, + { name = "twisted" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/51/b9d82166b905bf4c3b682a93065dbbf590c2a7dfa1ce851f7d75d8437acd/pydoctor-25.4.0.tar.gz", hash = "sha256:c460413a31d83103481fe61479cf6da896694c9b6258243154abd01e4eee14bf", size = 969073, upload-time = "2025-05-22T20:13:55.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/98/189c27fdf86cd67cf32ce88dad165919bcd62f4cc2d79a5b2a240785a87e/pydoctor-25.4.0-py3-none-any.whl", hash = "sha256:9fb5bff6fb28e4071080f2580812133e374289dba49798f3eaeb7f8d68a043cb", size = 1601262, upload-time = "2025-05-22T20:13:53.157Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.402" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, +] + +[[package]] +name = "pytest-pretty" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/d7/c699e0be5401fe9ccad484562f0af9350b4e48c05acf39fb3dab1932128f/pytest_pretty-1.3.0.tar.gz", hash = "sha256:97e9921be40f003e40ae78db078d4a0c1ea42bf73418097b5077970c2cc43bf3", size = 219297, upload-time = "2025-06-04T12:54:37.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/85/2f97a1b65178b0f11c9c77c35417a4cc5b99a80db90dad4734a129844ea5/pytest_pretty-1.3.0-py3-none-any.whl", hash = "sha256:074b9d5783cef9571494543de07e768a4dda92a3e85118d6c7458c67297159b7", size = 5620, upload-time = "2025-06-04T12:54:36.229Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, + { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, + { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, + { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, + { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, + { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "twisted" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "automat" }, + { name = "constantly" }, + { name = "hyperlink" }, + { name = "incremental" }, + { name = "typing-extensions" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, +] + [[package]] name = "typing-extensions" -version = "4.12.2" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] + +[[package]] +name = "zope-interface" +version = "7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/76/71/e6177f390e8daa7e75378505c5ab974e0bf59c1d3b19155638c7afbf4b2d/zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2", size = 208243, upload-time = "2024-11-28T08:47:29.781Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/7e5f4226bef540f6d55acfd95cd105782bc6ee044d9b5587ce2c95558a5e/zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a", size = 208759, upload-time = "2024-11-28T08:47:31.908Z" }, + { url = "https://files.pythonhosted.org/packages/28/ea/fdd9813c1eafd333ad92464d57a4e3a82b37ae57c19497bcffa42df673e4/zope.interface-7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6", size = 254922, upload-time = "2024-11-28T09:18:11.795Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0000a4d497ef9fbf4f66bb6828b8d0a235e690d57c333be877bec763722f/zope.interface-7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d", size = 249367, upload-time = "2024-11-28T08:48:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/0b359e99084f033d413419eff23ee9c2bd33bca2ca9f4e83d11856f22d10/zope.interface-7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d", size = 254488, upload-time = "2024-11-28T08:48:28.816Z" }, + { url = "https://files.pythonhosted.org/packages/7b/90/12d50b95f40e3b2fc0ba7f7782104093b9fd62806b13b98ef4e580f2ca61/zope.interface-7.2-cp310-cp310-win_amd64.whl", hash = "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b", size = 211947, upload-time = "2024-11-28T08:48:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/98/7d/2e8daf0abea7798d16a58f2f3a2bf7588872eee54ac119f99393fdd47b65/zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2", size = 208776, upload-time = "2024-11-28T08:47:53.009Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/0c03c7170fe61d0d371e4c7ea5b62b8cb79b095b3d630ca16719bf8b7b18/zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22", size = 209296, upload-time = "2024-11-28T08:47:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/49/b4/451f19448772b4a1159519033a5f72672221e623b0a1bd2b896b653943d8/zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7", size = 260997, upload-time = "2024-11-28T09:18:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/5aa4461c10718062c8f8711161faf3249d6d3679c24a0b81dd6fc8ba1dd3/zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c", size = 255038, upload-time = "2024-11-28T08:48:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/9f/aa/1a28c02815fe1ca282b54f6705b9ddba20328fabdc37b8cf73fc06b172f0/zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a", size = 259806, upload-time = "2024-11-28T08:48:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2c/82028f121d27c7e68632347fe04f4a6e0466e77bb36e104c8b074f3d7d7b/zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1", size = 212305, upload-time = "2024-11-28T08:49:14.525Z" }, + { url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959, upload-time = "2024-11-28T08:47:47.788Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357, upload-time = "2024-11-28T08:47:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235, upload-time = "2024-11-28T09:18:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253, upload-time = "2024-11-28T08:48:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702, upload-time = "2024-11-28T08:48:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466, upload-time = "2024-11-28T08:49:14.397Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2c/1f49dc8b4843c4f0848d8e43191aed312bad946a1563d1bf9e46cf2816ee/zope.interface-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bd449c306ba006c65799ea7912adbbfed071089461a19091a228998b82b1fdb", size = 208349, upload-time = "2024-11-28T08:49:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7d/83ddbfc8424c69579a90fc8edc2b797223da2a8083a94d8dfa0e374c5ed4/zope.interface-7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a19a6cc9c6ce4b1e7e3d319a473cf0ee989cbbe2b39201d7c19e214d2dfb80c7", size = 208799, upload-time = "2024-11-28T08:49:30.616Z" }, + { url = "https://files.pythonhosted.org/packages/36/22/b1abd91854c1be03f5542fe092e6a745096d2eca7704d69432e119100583/zope.interface-7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cd1790b48c16db85d51fbbd12d20949d7339ad84fd971427cf00d990c1f137", size = 254267, upload-time = "2024-11-28T09:18:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dd/fcd313ee216ad0739ae00e6126bc22a0af62a74f76a9ca668d16cd276222/zope.interface-7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52e446f9955195440e787596dccd1411f543743c359eeb26e9b2c02b077b0519", size = 248614, upload-time = "2024-11-28T08:48:41.953Z" }, + { url = "https://files.pythonhosted.org/packages/88/d4/4ba1569b856870527cec4bf22b91fe704b81a3c1a451b2ccf234e9e0666f/zope.interface-7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ad9913fd858274db8dd867012ebe544ef18d218f6f7d1e3c3e6d98000f14b75", size = 253800, upload-time = "2024-11-28T08:48:46.637Z" }, + { url = "https://files.pythonhosted.org/packages/69/da/c9cfb384c18bd3a26d9fc6a9b5f32ccea49ae09444f097eaa5ca9814aff9/zope.interface-7.2-cp39-cp39-win_amd64.whl", hash = "sha256:1090c60116b3da3bfdd0c03406e2f14a1ff53e5771aebe33fec1edc0a350175d", size = 211980, upload-time = "2024-11-28T08:50:35.681Z" }, ]